# Transcript Rehydration + `--conversation` Resume for Claude Code — Tech Spec Product spec: `specs/REMOTE-1373/PRODUCT.md` ## Problem Two related gaps for Claude Code cloud runs: (1) a fresh Claude sandbox resuming an existing conversation doesn't actually pick up the prior state — the stored `ClaudeTranscriptEnvelope` isn't rewritten into the on-disk layout `claude --resume` expects, and on cloud-to-cloud handoff the Oz-style rehydration system prompt gets deprioritized by Claude so workspace patches don't get applied; and (2) `--conversation ` is Oz-only, so there's no user-facing surface to resume a finished Claude conversation in a new cloud or local run. ## Architecture ```mermaid sequenceDiagram participant User participant CLI as warp CLI participant Srv as warp-server participant Wrk as oz-agent-worker participant Sand as Sandbox CLI participant GCS participant Cld as claude User->>CLI: warp agent run --harness claude --conversation X CLI->>Srv: list_ai_conversation_metadata([X]) → CLAUDE_CODE Note over Wrk,Sand: Cloud-to-cloud followups (Slack/Linear) reach the sandbox via the existing\nworker→sandbox path; this PR only changes how the sandbox CLI consumes the id. Wrk->>Sand: oz agent run --task-id --harness claude --conversation X --sandboxed Sand->>Srv: GET /harness-support/transcript (workload token → task.AgentConversationID) → signed URL Sand->>GCS: GET claude_code.json → ClaudeTranscriptEnvelope Sand->>Sand: write_envelope(cwd=) + sessions-index entry → ~/.claude/projects/... Sand->>Cld: claude --resume --dangerously-skip-permissions < (resumption_prompt + prompt) loop per-turn Cld->>Srv: POST /harness-support/resolve-prompt Srv-->>Cld: prompt, system_prompt (+ rehydration body), resumption_prompt (preamble for the harness to surface on resumed runs) Cld->>Cld: Claude harness prepends resumption_prompt to the user-turn prompt before piping into the CLI end loop periodic + final Sand->>GCS: PUT /claude_code.json + block_snapshot.json (overwrite) end ``` Local resume is the user's CLI doing the fetch, rehydrate, and launch directly. Cloud spawn-with-resume from the Rust CLI (`run-cloud --conversation`) is intentionally out of scope for this PR — see the follow-ups section. ## Implementation ### Client CLI — `warp-internal` #### CLI arg shape `RunAgentArgs` in `crates/warp_cli/src/agent.rs` accepts both `--task-id` and `--conversation` simultaneously. Eventually we want them mutually exclusive (`--task-id` already implies a server-side task whose `conversation_id` is the conversation to resume), but the worker still appends `--conversation ` alongside `--task-id` to the embedded CLI for Slack/Linear followups, so adding a `conflicts_with` on `--task-id` would break those during the rollout window. The enforcement is deferred to a follow-up that lands after the worker stops appending `--conversation`. When both are set, the runtime merge in `setup_and_run_driver` prefers the explicit `--conversation` value over the task's stored `conversation_id`. #### Shared harness validation `fetch_and_validate_conversation_harness` in `app/src/ai/agent_sdk/common.rs` fetches conversation metadata via `list_ai_conversation_metadata` and compares its `AIAgentHarness` against the caller's `warp_cli::agent::Harness`. A mismatch returns `AgentDriverError::ConversationHarnessMismatch { conversation_id, expected, got }` before any task/config side effects. The local path (`setup_and_run_driver` in `app/src/ai/agent_sdk/mod.rs`) calls it up front when `--conversation` is passed. #### Effective conversation id resolution `setup_and_run_driver` in `app/src/ai/agent_sdk/mod.rs` resolves the effective conversation id from up to two sources: - `--conversation `: validated up front (no task side effects on mismatch). - `--task-id `: `fetch_secrets_and_attachments` reads the task's `conversation_id` off the fetched `AmbientAgentTask` and returns it; the harness check against the task's stored harness happens inside `fetch_secrets_and_attachments`, before any conversation-load side effects. When both are passed, the explicit `--conversation` value wins via `resume_conversation_id.or(task_conversation_id)`. #### Harness-aware resume dispatch `load_conversation_information` in `app/src/ai/agent_sdk/mod.rs` now takes the resolved `&HarnessKind` and dispatches: - `HarnessKind::Oz` keeps the existing path: `get_ai_conversation` + `driver_options.conversation_restoration`. - `HarnessKind::ThirdParty(h)` parses the id to `AIConversationId`, grabs an `Arc` via `ServerApiProvider::get_harness_support_client()`, and calls `h.fetch_resume_payload(&conversation_id, harness_support_client).await?`, stashing the returned `Option` on `driver_options.resume_payload`. The previous `ServerConversationToken` and `&ServerAIConversationMetadata` arguments are gone now that the server resolves the conversation from the task's `agent_conversation_id`. `resume_payload` and `conversation_restoration` are mutually exclusive on `AgentDriverOptions`. `prepare_harness` in `app/src/ai/agent_sdk/driver.rs` takes the payload off `AgentDriver` via `me.resume_payload.take()` and forwards it to `ThirdPartyHarness::build_runner`. #### Harness-agnostic resume payload `app/src/ai/agent_sdk/driver/harness/mod.rs` defines a single harness-dispatched enum that the driver itself never inspects: ```rust path=null start=null pub(crate) enum ResumePayload { Claude(ClaudeResumeInfo), // Future CLI harnesses add their own variant here. } ``` `ResumePayload` is what `ThirdPartyHarness` implementations return from `fetch_resume_payload`. There is no harness-dispatched `TranscriptEnvelope` enum: each harness fetches raw bytes from `HarnessSupportClient::fetch_transcript` and deserializes them into its own envelope type directly, which keeps the abstraction local to each harness module. `ThirdPartyHarness` is `#[async_trait]` and exposes the resume-shaped methods plus the build hook: - `async fn fetch_resume_payload(&self, _conversation_id: &AIConversationId, _harness_support_client: Arc) -> Result, AgentDriverError>` — defaults to `Ok(None)`; Gemini uses the default. - `fn build_runner(&self, prompt: &str, system_prompt: Option<&str>, resumption_prompt: Option<&str>, working_dir: &Path, server_api: Arc, terminal_driver: ModelHandle, resume: Option) -> Result, AgentDriverError>` — implementors match on their own variant and ignore others, and decide how to surface the optional `resumption_prompt`. The driver never inspects either argument; the abstraction ends at `build_runner` and each runner stays harness-shaped internally. #### Claude Code `app/src/ai/agent_sdk/driver/harness/claude_code.rs`: - `ClaudeHarness::fetch_resume_payload` calls `harness_support_client.fetch_transcript()`, deserializes the bytes into `ClaudeTranscriptEnvelope` directly via `serde_json::from_slice`, maps a 404 (string match on `status 404`) to `AgentDriverError::ConversationResumeStateMissing { harness: "claude", conversation_id }`, and wraps the envelope into `ResumePayload::Claude(ClaudeResumeInfo { conversation_id, session_id: envelope.uuid, envelope })`. - `ClaudeHarness::build_runner` destructures `resume.map(|ResumePayload::Claude(info)| info)` and, when `resumption_prompt` is non-empty, prepends `"{preamble}\n\n"` to the user-turn `prompt` before passing it to `ClaudeHarnessRunner::new`. Claude treats the user-turn message as immediate intent, so a local prepend at runner construction is the most reliable way to land the preamble; other harnesses can pick a different placement (or ignore it). - `ClaudeHarnessRunner::new`, when resuming: rewrites `envelope.cwd = working_dir`, calls `write_envelope` under `claude_config_dir()`, calls `write_session_index_entry` (best-effort), reuses the envelope's session uuid, and stashes `Some(conversation_id)` on a new `preexisting_conversation_id` field. Jsonl-write failures return `AgentDriverError::ConfigBuildFailed` so the user gets a real error instead of a silent start-from-scratch. - `claude_command(..., resuming: bool)` picks `--resume ` when resuming and `--session-id ` otherwise. - `HarnessRunner::start` skips `create_external_conversation` when `preexisting_conversation_id.is_some()` and uses the stored id. `save_conversation` is unchanged; reusing `(conversation_id, session_id)` makes periodic/final saves overwrite the same GCS objects. #### Claude transcript module `app/src/ai/agent_sdk/driver/harness/claude_transcript.rs` is a sibling module that owns `ClaudeTranscriptEnvelope`, `ClaudeResumeInfo`, `encode_cwd`, `claude_config_dir`, `read_envelope`, `write_envelope`, and `write_session_index_entry`. It's extracted from `claude_code.rs` purely to keep the on-disk layout helpers separate from the runner; both modules import from it as needed. `write_envelope` lost its `#[expect(dead_code)]` — it's live now. `write_session_index_entry(session_id, cwd, config_root)` upserts an entry keyed on the session uuid into `~/.claude/sessions-index.json` with `sessionId`, `cwd`, `projectPath` (= encoded cwd), and `transcriptPath`. Existing entries and unknown fields are preserved; missing/malformed files are created/overwritten. Best-effort: failures log warn and continue (upstream Claude versions vary on how they consume this index). #### Transcript fetch client `HarnessSupportClient::fetch_transcript` in `app/src/server/server_api/harness_support.rs` downloads from `GET harness-support/transcript` via `get_public_api_response`, reads bytes, and returns them to the harness. The conversation is resolved server-side from the current task's `agent_conversation_id`, so the call takes no parameters. The endpoint sits behind the harness-support workload-token middleware; only cloud-agent contexts can reach it. Transient retries reuse the shared `with_bounded_retry` helper from `agent_sdk::retry` (3 attempts, `500ms * 2^n` backoff), classifying 5xx / 408 / 429 as transient via `HttpStatusError` in the error chain. The previous `AIClient::get_transcript` trait method and the `server_api/claude_transcript.rs` module were removed; each harness owns deserialization for its own envelope shape. #### Resolved-prompt response `ResolvedHarnessPrompt` in `app/src/server/server_api/harness_support.rs` adds an optional `resumption_prompt: Option` field (deserialized with `#[serde(default)]` so older servers that don't set it still parse cleanly). The driver in `prepare_harness` extracts the field alongside `prompt` and `system_prompt` and forwards it to `ThirdPartyHarness::build_runner`. Each harness picks how to surface it. Today only the Claude harness consumes it (prepended to the user-turn prompt); Gemini ignores it via `_resumption_prompt`. #### Error variants `AgentDriverError` in `app/src/ai/agent_sdk/driver.rs` gains two variants (both classified in `app/src/ai/agent_sdk/driver/error_classification.rs`): - `ConversationHarnessMismatch { conversation_id, expected, got }` → `EnvironmentSetupFailed`. - `ConversationResumeStateMissing { harness, conversation_id }` → `ResourceNotFound`. Harness-neutral on purpose; each harness tags the variant with its own label (`"claude"` today). ### Server — `warp-server` - `router/handlers/public_api/harness_support.go` registers `GET /harness-support/transcript` on the existing harness-support group (which already runs `ValidateAmbientTask` + `RequireCloudAgent`) and implements `GetTranscriptDownloadHandler`: pulls `AmbientRequestInfo` off the gin context, 400s on missing `info.Task.AgentConversationID`, resolves the principal + conversation data store, and 307-redirects to the URL returned by `conversation_transcript.GetConversationRawTranscriptDownloadURL`. That underlying function already returns `InvalidRequestError` for non-`GenericCLIHarnessTranscript` manifests, so Oz conversations get a 400 for free. The previously-proposed `GET /agent/conversations/:conversation_id/third-party-transcript` route was removed in favor of this one so callers don't have to pass a conversation id and the route lives next to the rest of the harness-support endpoints. - `public_api/openapi.yaml` adds the `get` operation under `/harness-support/transcript` (sibling of the existing `post` upload-target operation) with the standard error responses, and `ResolvePromptResponse` gains an optional `resumption_prompt` string with a doc comment explaining the contract. Types are regenerated into `public_api/types/types.gen.go` via `go generate ./public_api/types/`. - `logic/ai/ambient_agents/handoff_rehydration.go` introduces `RehydrationAgentKind` (`RehydrationForOz` / `RehydrationForThirdPartyCLI`) and a second prompt body, `HandoffRestoreInstructionsForThirdPartyCLI`, that is ordered as an unconditional pre-turn checklist with explicit verbatim `cat` / `git apply` commands. It also exports `HandoffRestoreUserPromptPreambleForThirdPartyCLI`, a one-line user-turn nudge pointing Claude back at the system-prompt checklist. `ResolveHandoffRehydrationPrompt` and `formatHandoffRehydrationPrompt` take the kind and dispatch on it. - `router/handlers/public_api/harness_support.go`'s `ResolvePromptHandler` calls `ResolveHandoffRehydrationPrompt(..., RehydrationForThirdPartyCLI)`. When the body is non-empty it appends the body to `systemPrompt` (via `appendSystemPromptSection`) and stores `HandoffRestoreUserPromptPreambleForThirdPartyCLI` in a new `resumptionPrompt` local that's returned in `api.ResolvePromptResponse.ResumptionPrompt`. The handler no longer mutates `prompt` server-side — each harness chooses how to surface the preamble (Claude prepends it; Gemini ignores). Old clients that don't deserialize `resumption_prompt` simply skip it and behave as before. - `logic/ai/multi_agent/runtime/interceptors/input.go` passes `RehydrationForOz` so the Oz runtime keeps its softer UserQuery-style body. - `logic/ai/ambient_agents/workers/selfhosted/websocket.go` drops the redundant `ConversationID` field from `TaskAssignmentMessage`; the worker now reads `task.AgentConversationID` directly off the embedded `*types.Task` (already serialized as `agent_conversation_id`). - `test/integration/external_conversation_test.go` includes `TestGetConversationRawTranscriptDownloadURL_OzRejected` (referenced from the new handler's doc comment) covering the Oz 400 path that the handler relies on. ### Worker — `oz-agent-worker` - `internal/types/messages.go` adds `AgentConversationID *string \`json:"agent_conversation_id,omitempty"\`` on `Task`. This replaces the removed `TaskAssignmentMessage.ConversationID` as the canonical conversation-id source for resumed runs. - `internal/common/task_utils.go`'s `AugmentArgsForTask` appends `--conversation ` from `task.AgentConversationID` when set, so the embedded warp CLI can resume the conversation's state (Oz or Claude Code). The CLI accepts `--task-id` + `--conversation` together while the deferred `conflicts_with` migration is outstanding (see follow-ups); when both are present, the CLI's runtime merge prefers the explicit `--conversation`. - `internal/worker/worker.go` stops reading the removed `assignment.ConversationID`; the CLI args are built entirely off `task.AgentConversationID` via `AugmentArgsForTask`. ## Feature flags - `FeatureFlag::CloudConversations` gates `--conversation` on the CLI (unchanged). - `FeatureFlag::AgentHarness` gates `--harness claude` and the Claude resume path (unchanged). - `CloudToCloudHandoffEnabled` gates the server-side rehydration body + user-turn preamble in `ResolvePromptHandler`. Off → the resolved prompt is returned unmodified. - Worker `AgentConversationID` is transport-level; inert when unset, so old workers + new server degrade silently to "no resume". ## Risks and mitigations - **cwd mismatch**: `--resume` is scoped to `~/.claude/projects//`. The envelope's cwd is unconditionally rewritten to `working_dir` before `write_envelope`. Unit-tested. - **`sessions-index.json`**: recent Claude versions key `--resume ` off the index, not by scanning jsonl directly (claude-code#33912, #39667, #5768). We upsert an entry alongside the jsonl, preserving other entries. Best-effort — index failures surface as normal resume errors, not rehydration aborts. - **Weak system-prompt adherence on resumed Claude**: the stronger third-party-CLI body + user-turn preamble in `/resolve-prompt` is specifically to overcome Claude's baked-in prompt dominating on resumed sessions. The preamble lives in the new `resumption_prompt` response field; the harness decides where to inject it (Claude prepends it to the user-turn prompt fed into the CLI). - **Old server + new client**: `GET /harness-support/transcript` returns 404; surfaced as a clear `ConversationResumeStateMissing` error. - **Old client + new server (resumption_prompt)**: clients that don't deserialize the new `resumption_prompt` field simply ignore it; their resumed runs lose only the user-turn preamble nudge, not any rehydration behaviour. - **Old worker + new server**: the server no longer emits the top-level `TaskAssignmentMessage.ConversationID`; old workers that read that field (instead of `task.AgentConversationID`) will stop appending `--conversation` and silently degrade to "no resume" on both fresh `--conversation` invocations AND pre-existing Slack/Linear follow-ups. Follow-ups still run, they just lose conversation continuity until self-hosted workers are rebuilt. Worth sequencing worker rollout ahead of server rollout. - **Concurrent resumed runs**: last-write-wins on `/claude_code.json`, same hazard as Oz `--conversation` today; not addressed here. ## Testing and validation ### Unit tests (warp-internal) - `claude_code_tests.rs`: `--session-id` vs `--resume` flag selection, stdin-redirect + `--dangerously-skip-permissions`, resume writes envelope under current cwd, resume runner skips `create_external_conversation`, `fetch_resume_payload` happy path, `fetch_resume_payload` 404 → `ConversationResumeStateMissing`. - `claude_transcript_tests.rs`: `encode_cwd`, `read_envelope` / `write_envelope` round-trips, `write_session_index_entry` create/preserve-others/overwrite-same-session/overwrite-malformed. - `harness_support_tests.rs`: `HarnessSupportClient::fetch_transcript` envelope round-trip + transient-error retry. - `mod.rs`: harness-mismatch pre-spawn (both directions), `HarnessKind::ThirdParty` populates `resume_payload`. ### Integration tests - warp-server: `TestResolvePromptHandler_HandoffRehydrationNoPriorExecution` pins empty `prompt` / `system_prompt` / `resumption_prompt` when no prior ended execution exists; `TestGetConversationRawTranscriptDownloadURL_OzRejected` covers the Oz 400 path that the new `GET /harness-support/transcript` handler relies on; existing tests cover the upload side. - oz-agent-worker: `AugmentArgsForTask` forwards `--conversation ` to the embedded CLI when `task.AgentConversationID` is set, and omits the flag otherwise. ### Manual 1. Short Claude cloud agent → note ``. 2. `agent run --harness claude --conversation ` locally → jsonl grows, Claude `/resume` lists the session. 3. Harness mismatch / missing transcript / missing id → clean errors pre-launch. ## Follow-ups - Wire up cloud spawn-with-resume from the Rust CLI: add `conversation_id: Option` to `SpawnAgentRequest`, forward `args.conversation` from `run-cloud --conversation` (with the up-front `fetch_and_validate_conversation_harness` call inside `spawn_future`), and ship as part of the broader local→cloud handoff design. The server already accepts the field and the worker already forwards `task.AgentConversationID`, so the wiring is small; deferring keeps this PR focused on local resume + the worker/server transcript path. - Add `conflicts_with = "conversation"` to `--task-id` in `RunAgentArgs` once the worker stops appending `--conversation` alongside `--task-id` for Slack/Linear followups. Until then, both flags can be passed; the runtime merge prefers the explicit `--conversation`. - Add a second `ResumePayload` variant + per-harness `fetch_transcript` deserializer when another CLI harness (Gemini / Codex / opencode) gains resume support. The generic surface (raw-bytes fetch on `HarnessSupportClient`, harness-decided `resumption_prompt` injection) is already in place. - Reconcile the duplicated `types.Task` fields between warp-server and oz-agent-worker. - Retry/fallback semantics on `write_envelope` failure (today: hard error). - Auto-detect `--harness` from metadata once harness reading moves below the conversation-fetch step in `build_merged_config_and_task` and `ambient.rs`.