Files

57 lines
12 KiB
Markdown

# Empty-Prompt Local-to-Cloud Handoff (REMOTE-1499) — Product Spec
Canonical product spec for the empty-prompt local-to-cloud handoff feature; describes the full end-to-end behavior across every stage of implementation. Per-stage sub-tech-specs cover what each stage delivers: `STAGE-1.md` (on `harry/empty-prompt-handoff-wire-contract`) and `STAGE-2.md` (on the stacked `harry/empty-prompt-handoff-local`). This is the single source of truth for the feature shape and is not modified by subsequent stages.
Sibling specs (cross-repo):
- `warp-server/specs/REMOTE-1499/PRODUCT.md`, `warp-server/specs/REMOTE-1499/TECH.md` (server-side counterparts, mirror this doc).
## Problem
Today, every local-to-cloud handoff entry point requires a non-empty prompt to start a cloud run. The "Hand off to cloud" chip only enters compose mode, `&` + Enter on an empty buffer is a no-op, and `/handoff` with no argument is a no-op. Users who want to continue an in-progress local agent run in the cloud, or rehydrate workspace changes into a fresh cloud agent, have to type a throwaway prompt first.
## Goals
1. Three functionally equivalent entry points launch an **immediate** local→cloud handoff with no compose step, provided the active source conversation has at least one exchange (the client-side guardrail described below):
- Click the "Hand off to cloud" chip on the agent input footer.
- Type `&` + Enter on an empty buffer.
- Type `/handoff` with no argument.
All three funnel through the same `start_local_to_cloud_handoff` path in `app/src/workspace/view.rs:13652-13663` and produce the same wire payload.
2. Empty-prompt handoff against an in-progress local agent source: the client substitutes `"Continue"` on the wire (local→cloud only) so the cloud agent picks up the conversation context coherently. The same string is shown verbatim in the queued-prompt indicator block — wire and display are coupled by design.
3. Empty-prompt handoff with snapshotted workspace changes: the client substitutes `"Apply the workspace changes from my previous session."` on the wire alongside the snapshot token. The cloud agent's first user-role turn carries that intent and the snapshot rehydration runs as before. The same string is shown verbatim in the queued-prompt indicator block.
4. The cloud pane's "setting up…" Cloud Mode Setup V2 UI transitions out properly after environment setup, even when the cloud agent does not fire a first LLM exchange. The viewer learns the setup phase has ended via a new shared-session-protocol marker rather than depending on a first `AppendedExchange` event.
5. Ships unconditionally on Stage 2 — no new feature flag. The entry points remain gated by `OzHandoff && HandoffLocalCloud` and the source-content guardrail.
## Non-goals
- Cloud→cloud empty `Continue` submission (deferred follow-up; out of scope here — see "Deferred follow-ups" below).
- Removing the legacy `AppendedExchange`-driven setup-phase teardown fallback paths (deferred follow-up).
## User-facing behavior
### Entry points and the source-content guardrail
On a Warp client with `OzHandoff && HandoffLocalCloud` enabled, three entry points can launch an empty-prompt local→cloud handoff. Each gates the immediate-handoff path on a **source-content guardrail**: the source terminal view must have an active conversation (`BlocklistAIHistoryModel::active_conversation(...)` returns `Some(...)`) with at least one exchange. If the guardrail fails, the entry point falls back to pre-feature behavior:
- **Chip click.** With an empty input buffer AND the guardrail passing, clicking the "Hand off to cloud" chip dispatches `WorkspaceAction::OpenLocalToCloudHandoffPane { launch: None, environment_id: None, entry_point: HandoffEntryPoint::FooterChip }` directly — a single click is a complete commit. With a non-empty input buffer (any source state), the chip instead enters `&` compose mode and preserves the in-flight prompt so the user can refine before submitting. **Fallback (empty buffer, guardrail fails):** activate `&` compose mode in the source input.
- **`&` + Enter on an empty buffer.** When the guardrail passes, the empty-prompt early-return in `maybe_launch_cloud_handoff_request` is bypassed and the path dispatches `OpenLocalToCloudHandoffPane { launch: None, ... }` so the workspace synthesizer constructs the empty launch (with collected attachments). Entry point recorded as `HandoffEntryPoint::Ampersand`. **Fallback (guardrail fails):** no-op (swallow the Enter so the compose draft is preserved).
- **`/handoff` with no argument.** Dispatches the same `OpenLocalToCloudHandoffPane { launch: None, ... }` as the chip when the user types `/handoff` with no following text and the guardrail passes. Entry point recorded as `HandoffEntryPoint::SlashCommand`. There is **no separate handoff compose pane** for this path — the dispatch is immediate. **Fallback (guardrail fails):** surface a `"Nothing to hand off — start a conversation first."` toast.
All three entry points share one helper, `crate::ai::blocklist::handoff::source_conversation_has_content`, that evaluates the guardrail against `self.terminal_view_id`. When the guardrail passes, the three dispatches converge in `start_local_to_cloud_handoff` (`app/src/workspace/view.rs`), which synthesizes an empty `PendingCloudLaunch` (collecting attachments from the source input) and proceeds through the existing handoff machinery.
### Wire-level substitution (display = wire)
At submit time, `build_handoff_spawn_request` in `app/src/terminal/view/ambient_agent/model.rs` decides the wire-level prompt client-side. The same string drives the queued-prompt indicator display — wire and display are coupled by design.
- **In-progress source + non-empty snapshot token.** Empty user prompt + in-progress / blocked source (`pending_handoff.source_conversation_active == true`) + non-empty `InitialSnapshotToken`: substitute `prompt: Some("Continue. Apply the workspace changes from my previous session.")` on the wire. The cloud agent's first LLM turn both picks up the in-flight intent and rehydrates the workspace.
- **In-progress source only.** Empty user prompt + in-progress source, no snapshot content: substitute `prompt: Some("Continue")` on the wire.
- **Idle source + non-empty snapshot token.** Empty user prompt + idle source + non-empty `InitialSnapshotToken`: substitute `prompt: Some("Apply the workspace changes from my previous session.")` on the wire. The snapshot token still rides alongside.
- **Idle source + no snapshot token.** Empty user prompt + idle source + no snapshot token: send `prompt: None` on the wire. The worker derives `--skip-initial-turn` from the execution input and the cloud agent skips its initial LLM turn.
- **Non-empty user prompt.** The user's prompt flows through unchanged.
All substitutions are local-to-cloud-only; the server never sees an in-progress or idle signal it has to interpret.
### Queued-prompt indicator
The queued-prompt indicator is the small block that appears in the cloud agent's pane during the Cloud Mode Setup V2 warmup phase. It renders whatever string is on the wire: `"Continue"`, `"Apply the workspace changes from my previous session."`, the user's typed prompt, or nothing (when the wire prompt is `None`). There is no separate label-selection enum; display tracks the wire one-to-one and the existing `if !prompt.is_empty()` guard in `app/src/terminal/view/ambient_agent/view_impl.rs:154-189` suppresses the block when the wire prompt is `None`.
### Cloud Mode Setup V2 teardown
The canonical signal that a cloud agent run's setup phase has completed is the `CloudModeSetupPhaseEnded` shared-session-protocol marker. Every cloud agent run (skip-initial-turn or normal) emits the marker once the environment setup phase finishes; the viewer receives the marker and transitions the pane out of the "setting up…" UI (flip the executing-startup-commands flag off, finish the setup command group, hide the group). This works for empty-prompt handoffs that skip the first LLM turn — no first `AppendedExchange` event is required to drive the teardown.
For compatibility with viewers that connect to sharers running pre-feature builds, `BlocklistAIHistoryEvent::AppendedExchange`-driven fallback teardowns at `app/src/terminal/view.rs:5496-5507` and `app/src/terminal/view/ambient_agent/block/setup_command_text.rs:119-136` remain in place. Both teardowns are idempotent, so a new sharer + new viewer pair triggering both is harmless. Removal is tracked under "Deferred follow-ups".
## Telemetry
- `CloudAgentTelemetryEvent::HandoffInitiated` is extended with two new fields:
- `empty_prompt: bool` — true when the user's submitted prompt was empty.
- `injection_path: HandoffInjectionPath { None | Continue | SnapshotRehydration }` — which substitution path was intended at handoff initiation (mirrors the wire substitution decision).
- New `CloudAgentTelemetryEvent::HandoffSnapshotPrepared { derived_workspace_had_content: bool }` fires after `derive_touched_workspace` settles. Analytics can join this against `HandoffInitiated.injection_path` to learn whether `SnapshotRehydration` paths actually carried snapshot content at submit time. The field reports what the snapshot pipeline produced; the upload itself may still fail downstream, so the wire prompt is not implied.
Both events live under `app/src/ai/ambient_agents/telemetry.rs`. Their schemas are documented in the wire spec but no PII is added.
## Feature flag
None. The client-side behavior changes ship unconditionally on Stage 2 — the three entry points are gated only by `OzHandoff && HandoffLocalCloud` and the source-content guardrail. Server-side validators (warp-server-4) accept empty prompts unconditionally when paired with a `ConversationID`. Because the client always carries a non-empty substituted prompt when a snapshot is present, the server no longer needs separate `InitialSnapshotToken` or rehydration-metadata branches to accept empty prompts.
## Cross-repo dependencies
- Server-side relaxations (warp-server-4) widen the validators on `POST /agent/runs`, the multi-agent runtime first-turn interceptor, and `ProcessFollowupForTask`. They land in Stage 1 on the shared branch `harry/empty-prompt-handoff-wire-contract`. See `warp-server-4/specs/REMOTE-1499/`.
- Worker-derived `skip_initial_turn` (warp-server-4 + oz-agent-worker) computes the "should the cloud agent skip its initial LLM turn?" decision fresh per dispatch from `execution.Input.Prompt.is_empty()`. With the client-side snapshot-rehydration substitution always carrying a non-empty prompt, the `InitialSnapshotToken` check in the server-side helper is redundant and removed. Wire shape: a top-level `AdditionalOzArgs []string` on `TaskAssignmentMessage`, populated with `--skip-initial-turn` for eligible executions. The CLI flag `--skip-initial-turn` is the worker→CLI contract.
- `CloudModeSetupPhaseEnded` shared-session-protocol marker (session-sharing-protocol + session-sharing-server).
## Deferred follow-ups
Not in scope for this feature but tracked here so we don't lose them:
- **Cloud→cloud empty `Continue` submission.** Gated on `HandoffCloudCloud`. Would permit empty submission via `try_submit_pending_cloud_followup` and plumb `Option<String>` through the `submit_cloud_followup` callsite.
- **Drop the legacy `AppendedExchange`-driven teardowns** at `app/src/terminal/view.rs:5496-5507` and `app/src/terminal/view/ambient_agent/block/setup_command_text.rs:119-136` once enough time has passed that new viewers no longer need to support old sharers.
- **Revert the testing-only Cargo.toml local-path swaps** in `Cargo.toml:248` (warp-4) and `session-sharing-server/server/Cargo.toml:36-40` to real `git = ..., rev = <merged SHA>` after `session-sharing-protocol` PR merges.