first pass of merging in warp (doesn't build)

This commit is contained in:
Ryan Ward
2026-07-01 16:08:58 -05:00
parent 2f64909469
commit 4770ac06b5
3662 changed files with 414574 additions and 89772 deletions
+56
View File
@@ -0,0 +1,56 @@
# 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.
+60
View File
@@ -0,0 +1,60 @@
# Empty-Prompt Local-to-Cloud Handoff — Stage 1 Sub-Tech-Spec (warp-4)
Sub-tech-spec for what **Stage 1** of REMOTE-1499 delivers on the warp-4 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-wire-contract`.
Branch: `harry/empty-prompt-handoff-wire-contract` (warp-4 half of the paired-branch cross-repo PR shared with warp-server-4).
Sibling: `../../../warp-server-4/specs/REMOTE-1499/STAGE-1.md` (server-side relaxations).
## Scope
Stage 1 widens the wire shape of `SpawnAgentRequest.prompt` from `String` to `Option<String>` so a Stage 2 client can omit the field when the user submits an empty handoff. By itself Stage 1 introduces no interactive behavior changes: every interactive call site continues to send `Some(...)` of the same string, and the only non-test sites that emit `prompt: None` at runtime are the `oz agent run` CLI skill-only and conversation-only invocations. The skill-only path is end-to-end functional pre-Stage-2 because the warp-server-4 `prompt+skill` gate accepts the omitted field as the Go zero value. The conversation-only path requires the server-side validator relaxations that ship in the sibling warp-server-4 Stage 1 PR.
The server-side relaxations that accept additional shapes (empty `prompt` paired with `ConversationID`, `InitialSnapshotToken`, or rehydration metadata) land in the sibling warp-server-4 PR on the same branch name. Cross-repo coordination is purely through the JSON wire shape — there are no shared edit surfaces.
## Wire shape
`SpawnAgentRequest` in `app/src/server/server_api/ai.rs:206-208`:
```rust path=/Users/harryalbert/warp-4/app/src/server/server_api/ai.rs start=206
/// None for skill-only or conversation-only invocations; omitted on the wire.
#[serde(skip_serializing_if = "Option::is_none")]
pub prompt: Option<String>,
```
`Option<T>` serializes transparently under serde: `Some("hello")` emits `"prompt": "hello"`, and `skip_serializing_if = "Option::is_none"` causes `None` to omit the field entirely. The struct derives only `Serialize`, not `Deserialize`, so wire compatibility only has to hold client→server. Warp-server-4 deserializers that treat `prompt` as a string see the omitted field as the Go zero value `""`, which the existing skill-only validator already accepts — so the wire shape is compatible with both pre- and post-Stage-1 servers.
## Construction sites
All twelve `SpawnAgentRequest { … }` construction sites wrap their prompt value in `Some(...)`:
- `app/src/terminal/view/ambient_agent/model.rs:632` (`build_handoff_spawn_request`) and `:1120` (`spawn_agent`): both wrap the result of `extract_user_query_mode(prompt)`.
- `app/src/pane_group/pane/terminal_pane.rs:2137`: orchestration-spawned child runs.
- `app/src/ai/agent_sdk/ambient.rs:481-482`: `oz agent run` CLI (see CLI path below).
- Test fixtures: `spawn_tests.rs:702/770/838/901/1047`, `model_tests.rs:54`, `view_tests.rs:1323`, `mcp_config_tests.rs:272`, `ai_tests.rs:39`.
## CLI path
`app/src/ai/agent_sdk/ambient.rs:267-313` resolves the prompt as `Option<String>` directly:
- `Some(Prompt::PlainText(text)) → Some(text)`
- `Some(Prompt::SavedPrompt(id))` → resolves to `Some(prompt_text.to_string())` on hit; fatal-errors on miss
- `None → None` (skill-only or conversation-only invocations: `--skill` alone, `--conversation` alone, or `--skill` + `--conversation` with no prompt)
`ambient.rs:474-480` then computes the `(prompt, mode)` pair via a `match` that runs `extract_user_query_mode` only on the `Some` branch and defaults `mode` to `UserQueryMode::Normal` when the prompt is `None`. `UserQueryMode` is imported at the top of the file (`ambient.rs:6`). The resulting `Option<String>` flows directly into the constructed `SpawnAgentRequest` at `ambient.rs:481-482`.
These are the only non-test sites that emit `prompt: None` at runtime. The warp-server-4 `prompt+skill` gate at `agent_webhooks.go:343-347` accepts the skill-only case independently of the Stage 1 server relaxations, so the CLI's `--skill foo` flow continues to pass server validation even against an unupdated server. The `--conversation`-only flow depends on the Stage 1 server-side relaxations in the sibling warp-server-4 PR.
## Reader sites
Two reader sites use `.as_deref()` so the `Some` case dereferences to `&str` and the `None` case short-circuits cleanly:
- `app/src/terminal/view/ambient_agent/block/entry.rs:160` — entry-block title fallback chain:
```rust path=null start=null
request.prompt.as_deref().and_then(Self::meaningful_title)
```
A `None` prompt skips the fallback and the chain proceeds to the default title.
- `app/src/terminal/view/ambient_agent/view_impl.rs:159-164` — Cloud Mode Setup V2 queued-prompt insertion:
```rust path=null start=null
request.prompt.as_deref()
.map(|prompt| display_user_query_with_mode(request.mode, prompt))
```
The existing `if !prompt.is_empty()` guard at `view_impl.rs:166` suppresses the queued-prompt block insertion when the prompt is `None`. Stage 2 reuses this short-circuit for the substituted-prompt UI variants.
No other code in warp-4 pattern-matches or destructures `SpawnAgentRequest.prompt`.
## Testing
### Unit tests
- `app/src/server/server_api/ai_tests.rs:66-89` — `spawn_agent_request_omits_prompt_when_none` constructs a `SpawnAgentRequest { prompt: None, ... }`, serializes to `serde_json::Value`, and asserts `value.get("prompt").is_none()`. This is the only test that exercises the `None` branch directly and pins the `skip_serializing_if` contract.
- `ai_tests.rs:37-64` (`spawn_agent_request_serializes_agent_uid_as_agent_identity_uid`) uses `Some("hello")` and round-trips the full struct through `serde_json::to_value`. Its assertions on the `agent_identity_uid` field name implicitly verify that `Some(String)` serializes transparently — a stray `{"Some": ...}` wrapping would break the round-trip.
- `app/src/ai/agent_sdk/mcp_config_tests.rs:272` (`serializes_mcp_servers_as_object_not_string`) uses `Some("hello")` and round-trips the struct to verify nested MCP config serialization; provides the same implicit guarantee for the prompt shape.
- `app/src/terminal/view/ambient_agent/model_tests.rs:143, 276, 339` and `app/src/terminal/view_tests.rs:920, 965` assert handoff auto-submit and cloud-mode dispatch payloads via `assert_eq!(request.prompt.as_deref(), Some("..."))`, pinning the exact prompt string under the `Option<String>` shape.
- `spawn_tests.rs` fixtures at `:702/770/838/901/1047` exercise the struct shape in spawn-task polling tests.
### 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 change — the diff is a mechanical type widening plus targeted reader updates, and the listed checks plus the per-stage unit tests cover the relevant surfaces.
## Risks and mitigations
- **Pre-Stage-1 servers receiving Stage-1+ client payloads.** Mitigated by `Option<T>`'s transparent serialization plus `skip_serializing_if = "Option::is_none"`: the common `Some` case emits the same JSON shape that pre-Stage-1 servers always accepted. Of the two `None`-emitting CLI paths, skill-only is already accepted by the `agent_webhooks.go` `prompt+skill` gate as the Go zero-value `prompt: ""`; conversation-only depends on the Stage 1 server-side relaxations and was already rejected by pre-Stage-1 servers regardless of whether the client sent `""` or omitted the field, so Stage 1 does not regress that path.
- **Post-Stage-1 servers receiving pre-Stage-1 client payloads.** Not a concern: the field is non-optional on the wire from a pre-Stage-1 client; the server deserializer tolerates presence or absence of the field equivalently.
- **Borrow-site regressions.** The two `&request.prompt` borrows in the repo go through `.as_deref()` chains; the `Some` case dereferences to `&str` identically to the pre-Stage-1 shape and the `None` case short-circuits cleanly. There are no `match` / `if let` destructures of `request.prompt` to migrate.
- **Stage-coupling risk.** Stage 1 alone never produces a `None` runtime value from any interactive flow — only the CLI skill-only and conversation-only paths can — so the additive server-side relaxations in the sibling warp-server-4 PR are not load-bearing for the skill-only CLI path. They are load-bearing for the conversation-only CLI path, but that path was already broken against pre-Stage-1 servers (which reject `prompt: ""` + no skill), so Stage 1 does not regress it. Reverting the server-side PR independently is safe modulo the conversation-only CLI flow.
## Follow-ups
Stage 1 is scaffolding for Stage 2. The behaviors that justify the wire-contract change — empty-prompt handoff via chip / `&` / `/handoff`, `continue in the cloud` substitution against an in-progress source, the queued-prompt indicator label variants, the worker-derived skip-initial-turn wiring, and the `CloudModeSetupPhaseEnded` setup-phase teardown — are specced under `STAGE-2.md` on `harry/empty-prompt-handoff-local`. Stage 1 introduces no `FeatureFlag::EmptyPromptHandoff` itself; that flag lands on the Stage 2 branch.
+118
View File
@@ -0,0 +1,118 @@
# 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.md``OrderedTerminalEventType::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 telemetry** — `build_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 marker** — `AgentDriver::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-4102``maybe_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.rs``SpawnAgentRequest` 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, 337``AgentDriverOptions` 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:
```rust path=null start=null
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: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 `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.
+41
View File
@@ -0,0 +1,41 @@
# Empty-Prompt Local-to-Cloud Handoff (REMOTE-1499) — Tech Spec
Canonical tech spec for the warp side of REMOTE-1499. This document describes the end-to-end architecture; code-level minutia (specific line references, exhaustive call-site lists, considered alternatives, test enumerations) lives in the per-stage sub-tech-specs `STAGE-1.md` and `STAGE-2.md`.
Cross-repo siblings:
- `warp-server/specs/REMOTE-1499/` — server-side validator relaxations + worker-derived `ShouldSkipInitialTurn`.
- `oz-agent-worker/specs/REMOTE-1499/` — self-hosted worker side of `--skip-initial-turn`.
- `session-sharing-protocol/specs/REMOTE-1499/``CloudModeSetupPhaseEnded` variant on `OrderedTerminalEventType`.
- `session-sharing-server/specs/REMOTE-1499/` — testing-only protocol dep swap.
Stack layout:
- `harry/empty-prompt-handoff-wire-contract` (Stage 1) — wire-contract widening. See `STAGE-1.md`.
- `harry/empty-prompt-handoff-local` (Stage 2, stacked on Stage 1) — client behavior and shared-session protocol wiring. See `STAGE-2.md`.
## Architecture
Four orthogonal sub-systems on the warp side. The wire-contract widening lands in Stage 1; the other three land together in Stage 2 and ship unconditionally (no feature flag).
### Wire-contract widening
`SpawnAgentRequest.prompt` in `app/src/server/server_api/ai.rs` becomes `Option<String>` with `skip_serializing_if = "Option::is_none"` so the client can omit the field for empty submissions. The struct derives only `Serialize`, so wire compatibility only has to hold client→server. The two reader sites (entry-block title fallback in `app/src/terminal/view/ambient_agent/block/entry.rs`, queued-prompt insertion in `app/src/terminal/view/ambient_agent/view_impl.rs`) go through `.as_deref()` and short-circuit cleanly on `None`. The only runtime sites that emit `prompt: None` are the `oz agent run` CLI skill-only and conversation-only paths.
### Entry-point unification + client-side substitution
Three entry points — the "Hand off to cloud" footer chip, `&` + Enter on an empty buffer, and `/handoff` with no argument — converge in `start_local_to_cloud_handoff` (`app/src/workspace/view.rs`) when the source-content guardrail passes. The guardrail (`source_conversation_has_content` in `app/src/ai/blocklist/handoff/mod.rs`) requires the active source conversation to exist and have at least one exchange. Per-entry-point fallbacks when the guardrail fails are specified in PRODUCT.md and detailed in STAGE-2.md.
At submit time, `build_handoff_spawn_request` (`app/src/terminal/view/ambient_agent/model.rs`) chooses the wire prompt against the captured `source_conversation_active` bool and the post-upload `InitialSnapshotToken`:
- empty + in-progress source + snapshot → `"Continue. Apply the workspace changes from my previous session."`
- empty + in-progress source, no snapshot → `"Continue"`
- empty + idle source + snapshot → `"Apply the workspace changes from my previous session."`
- empty + idle source, no snapshot → `prompt: None`
- non-empty → unchanged
Display tracks the wire one-to-one — the queued-prompt block renders `request.prompt.as_deref()` via `display_user_query_with_mode`, and the existing `if !prompt.is_empty()` guard suppresses the block on `None`. There is no `EmptyPromptHandoffIndicator` enum.
Telemetry (`app/src/ai/ambient_agents/telemetry.rs`): `HandoffInitiated` gains `empty_prompt: bool` and `injection_path: HandoffInjectionPath { None | Continue | SnapshotRehydration }` (computed from the same `source_conversation_active` bool that drives the wire substitution); a new `HandoffSnapshotPrepared { derived_workspace_had_content: bool }` event fires after `derive_touched_workspace` settles.
### Worker-derived skip-initial-turn
The "should the cloud agent skip its initial LLM turn?" decision is computed fresh per execution by warp-server's `common.ShouldSkipInitialTurn(task, execution)` and reaches the AgentDriver as the `--skip-initial-turn` CLI flag — the only worker→driver contract for this signal. `ShouldSkipInitialTurn` is routed through the shared `ShouldSkipFirstTurn` predicate that also gates the runtime's first-turn validator, so the two cannot drift.
Client-side the wire and harness machinery are deliberately silent: `SpawnAgentRequest` does not carry the flag, `build_handoff_spawn_request` does not derive it, and `AgentRunPrompt::ServerSide` does not embed it (the variant must round-trip through harness-agnostic `prepare_harness`). `AgentDriverOptions`/`AgentDriver` carry a `skip_initial_turn: bool` populated from clap-parsed `RunAgentArgs::skip_initial_turn`; the gate in `AgentDriver::execute_run` matches it against `AgentRunPrompt::ServerSide { .. }` and emits a loud `[DEBUG]` warning on misconfigured `Local` pairings. The CLI flag's `requires_all = ["task_id", "idle_on_complete"]` pins the worker→driver invariant at the parser layer.
Rejected: stamping the decision onto the task config at dispatch time (drifts across executions); deriving client-side (client sees only the first execution).
### `CloudModeSetupPhaseEnded` setup-phase teardown
Every cloud agent run signals "environment setup phase complete" via a new `OrderedTerminalEventType::CloudModeSetupPhaseEnded` shared-session-protocol marker. The sharer emits it once setup commands have finished; the viewer's event loop (`app/src/terminal/shared_session/viewer/event_loop.rs`) tears down the Cloud Mode Setup V2 UI on receipt — flipping `BlockList::is_executing_oz_environment_startup_commands` off (which gates `is_cloud_agent_pre_first_exchange`, the input-vs-loading-footer toggle) and finishing/hiding the active setup command group. The marker fires on both the skip-initial-turn path (no first LLM turn) and the normal `ServerSide` path. A dedicated marker is necessary because the skip-initial-turn path never fires `AppendedExchange`, the pre-feature implicit signal — without it the input box would stay hidden behind the loading footer forever.
`AgentDriver::execute_run` emits the marker via `TerminalModel::send_cloud_mode_setup_phase_ended_for_shared_session` (a no-op on non-sharer terminals). The skip branch builds `IdleTimeoutSender` first, runs the skip block (enter agent view + emit marker + `complete_with_optional_idle`), then sets up the history subscription; scheduling the timer before the subscription lets a later `AppendedExchange` from a session-sharing-protocol follow-up correctly invalidate the timer. `IdleTimeoutSender::complete_with_optional_idle` is shared across all completion paths so they uniformly honor the optional idle window. Legacy `AppendedExchange`-driven teardowns in `app/src/terminal/view.rs` and `app/src/terminal/view/ambient_agent/block/setup_command_text.rs` remain in place as an idempotent fallback for viewers connecting to pre-feature sharers; removal is a deferred follow-up.
Rejected: reusing `AppendedExchange` (never fires on the skip-initial-turn path, so the input would stay hidden); letting the driver send `Success` directly without `IdleTimeoutSender` (driver tears down ~80ms after `Success`, too fast for a session-sharing-protocol follow-up).
## Validation
- `cargo fmt --all --check`.
- `cargo check -p warp --tests`.
Nextest and full clippy are intentionally not part of the per-PR validation. Per-stage unit tests are detailed in `STAGE-1.md` (serialization round-trip on `SpawnAgentRequest { prompt: None }` plus borrow-site fixtures) and `STAGE-2.md` (substitution outcomes in `build_handoff_spawn_request`, CLI parser tests, viewer event-loop arm, `IdleTimeoutSender::complete_with_optional_idle`).
## Wire shape coordination summary
- `SpawnAgentRequest` (`POST /agent/run`): `prompt: Option<String>` (Stage 1); does not carry `skip_initial_turn` (Stage 2).
- `TaskAssignmentMessage` (server → self-hosted worker): top-level `AdditionalOzArgs []string` (JSON tag `additional_oz_args`, `omitempty`), populated with `--skip-initial-turn` for eligible executions.
- `--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).