Files

42 lines
8.0 KiB
Markdown

# 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).