20 KiB
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 <id> is Oz-only, so there's no user-facing surface to resume a finished Claude conversation in a new cloud or local run.
Architecture
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 <t> --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=<sandbox_cwd>) + sessions-index entry → ~/.claude/projects/...
Sand->>Cld: claude --resume <uuid> --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 <X>/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 <id> 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 <id>: validated up front (no task side effects on mismatch).--task-id <id>:fetch_secrets_and_attachmentsreads the task'sconversation_idoff the fetchedAmbientAgentTaskand returns it; the harness check against the task's stored harness happens insidefetch_secrets_and_attachments, before any conversation-load side effects. When both are passed, the explicit--conversationvalue wins viaresume_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::Ozkeeps the existing path:get_ai_conversation+driver_options.conversation_restoration.HarnessKind::ThirdParty(h)parses the id toAIConversationId, grabs anArc<dyn HarnessSupportClient>viaServerApiProvider::get_harness_support_client(), and callsh.fetch_resume_payload(&conversation_id, harness_support_client).await?, stashing the returnedOption<ResumePayload>ondriver_options.resume_payload. The previousServerConversationTokenand&ServerAIConversationMetadataarguments are gone now that the server resolves the conversation from the task'sagent_conversation_id.resume_payloadandconversation_restorationare mutually exclusive onAgentDriverOptions.prepare_harnessinapp/src/ai/agent_sdk/driver.rstakes the payload offAgentDriverviame.resume_payload.take()and forwards it toThirdPartyHarness::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:
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<dyn HarnessSupportClient>) -> Result<Option<ResumePayload>, AgentDriverError>— defaults toOk(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<ServerApi>, terminal_driver: ModelHandle<TerminalDriver>, resume: Option<ResumePayload>) -> Result<Box<dyn HarnessRunner>, AgentDriverError>— implementors match on their own variant and ignore others, and decide how to surface the optionalresumption_prompt. The driver never inspects either argument; the abstraction ends atbuild_runnerand each runner stays harness-shaped internally.
Claude Code
app/src/ai/agent_sdk/driver/harness/claude_code.rs:
ClaudeHarness::fetch_resume_payloadcallsharness_support_client.fetch_transcript(), deserializes the bytes intoClaudeTranscriptEnvelopedirectly viaserde_json::from_slice, maps a 404 (string match onstatus 404) toAgentDriverError::ConversationResumeStateMissing { harness: "claude", conversation_id }, and wraps the envelope intoResumePayload::Claude(ClaudeResumeInfo { conversation_id, session_id: envelope.uuid, envelope }).ClaudeHarness::build_runnerdestructuresresume.map(|ResumePayload::Claude(info)| info)and, whenresumption_promptis non-empty, prepends"{preamble}\n\n"to the user-turnpromptbefore passing it toClaudeHarnessRunner::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: rewritesenvelope.cwd = working_dir, callswrite_envelopeunderclaude_config_dir(), callswrite_session_index_entry(best-effort), reuses the envelope's session uuid, and stashesSome(conversation_id)on a newpreexisting_conversation_idfield. Jsonl-write failures returnAgentDriverError::ConfigBuildFailedso the user gets a real error instead of a silent start-from-scratch.claude_command(..., resuming: bool)picks--resume <uuid>when resuming and--session-id <uuid>otherwise.HarnessRunner::startskipscreate_external_conversationwhenpreexisting_conversation_id.is_some()and uses the stored id.save_conversationis 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<String> 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.goregistersGET /harness-support/transcripton the existing harness-support group (which already runsValidateAmbientTask+RequireCloudAgent) and implementsGetTranscriptDownloadHandler: pullsAmbientRequestInfooff the gin context, 400s on missinginfo.Task.AgentConversationID, resolves the principal + conversation data store, and 307-redirects to the URL returned byconversation_transcript.GetConversationRawTranscriptDownloadURL. That underlying function already returnsInvalidRequestErrorfor non-GenericCLIHarnessTranscriptmanifests, so Oz conversations get a 400 for free. The previously-proposedGET /agent/conversations/:conversation_id/third-party-transcriptroute 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.yamladds thegetoperation under/harness-support/transcript(sibling of the existingpostupload-target operation) with the standard error responses, andResolvePromptResponsegains an optionalresumption_promptstring with a doc comment explaining the contract. Types are regenerated intopublic_api/types/types.gen.goviago generate ./public_api/types/.logic/ai/ambient_agents/handoff_rehydration.gointroducesRehydrationAgentKind(RehydrationForOz/RehydrationForThirdPartyCLI) and a second prompt body,HandoffRestoreInstructionsForThirdPartyCLI, that is ordered as an unconditional pre-turn checklist with explicit verbatimcat/git applycommands. It also exportsHandoffRestoreUserPromptPreambleForThirdPartyCLI, a one-line user-turn nudge pointing Claude back at the system-prompt checklist.ResolveHandoffRehydrationPromptandformatHandoffRehydrationPrompttake the kind and dispatch on it.router/handlers/public_api/harness_support.go'sResolvePromptHandlercallsResolveHandoffRehydrationPrompt(..., RehydrationForThirdPartyCLI). When the body is non-empty it appends the body tosystemPrompt(viaappendSystemPromptSection) and storesHandoffRestoreUserPromptPreambleForThirdPartyCLIin a newresumptionPromptlocal that's returned inapi.ResolvePromptResponse.ResumptionPrompt. The handler no longer mutatespromptserver-side — each harness chooses how to surface the preamble (Claude prepends it; Gemini ignores). Old clients that don't deserializeresumption_promptsimply skip it and behave as before.logic/ai/multi_agent/runtime/interceptors/input.gopassesRehydrationForOzso the Oz runtime keeps its softer UserQuery-style body.logic/ai/ambient_agents/workers/selfhosted/websocket.godrops the redundantConversationIDfield fromTaskAssignmentMessage; the worker now readstask.AgentConversationIDdirectly off the embedded*types.Task(already serialized asagent_conversation_id).test/integration/external_conversation_test.goincludesTestGetConversationRawTranscriptDownloadURL_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.goaddsAgentConversationID *string \json:"agent_conversation_id,omitempty"`onTask. This replaces the removedTaskAssignmentMessage.ConversationID` as the canonical conversation-id source for resumed runs.internal/common/task_utils.go'sAugmentArgsForTaskappends--conversation <id>fromtask.AgentConversationIDwhen set, so the embedded warp CLI can resume the conversation's state (Oz or Claude Code). The CLI accepts--task-id+--conversationtogether while the deferredconflicts_withmigration is outstanding (see follow-ups); when both are present, the CLI's runtime merge prefers the explicit--conversation.internal/worker/worker.gostops reading the removedassignment.ConversationID; the CLI args are built entirely offtask.AgentConversationIDviaAugmentArgsForTask.
Feature flags
FeatureFlag::CloudConversationsgates--conversationon the CLI (unchanged).FeatureFlag::AgentHarnessgates--harness claudeand the Claude resume path (unchanged).CloudToCloudHandoffEnabledgates the server-side rehydration body + user-turn preamble inResolvePromptHandler. Off → the resolved prompt is returned unmodified.- Worker
AgentConversationIDis transport-level; inert when unset, so old workers + new server degrade silently to "no resume".
Risks and mitigations
- cwd mismatch:
--resumeis scoped to~/.claude/projects/<encoded_cwd>/. The envelope's cwd is unconditionally rewritten toworking_dirbeforewrite_envelope. Unit-tested. sessions-index.json: recent Claude versions key--resume <uuid>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-promptis specifically to overcome Claude's baked-in prompt dominating on resumed sessions. The preamble lives in the newresumption_promptresponse 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/transcriptreturns 404; surfaced as a clearConversationResumeStateMissingerror. - Old client + new server (resumption_prompt): clients that don't deserialize the new
resumption_promptfield 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 oftask.AgentConversationID) will stop appending--conversationand silently degrade to "no resume" on both fresh--conversationinvocations 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
<X>/claude_code.json, same hazard as Oz--conversationtoday; not addressed here.
Testing and validation
Unit tests (warp-internal)
claude_code_tests.rs:--session-idvs--resumeflag selection, stdin-redirect +--dangerously-skip-permissions, resume writes envelope under current cwd, resume runner skipscreate_external_conversation,fetch_resume_payloadhappy path,fetch_resume_payload404 →ConversationResumeStateMissing.claude_transcript_tests.rs:encode_cwd,read_envelope/write_enveloperound-trips,write_session_index_entrycreate/preserve-others/overwrite-same-session/overwrite-malformed.harness_support_tests.rs:HarnessSupportClient::fetch_transcriptenvelope round-trip + transient-error retry.mod.rs: harness-mismatch pre-spawn (both directions),HarnessKind::ThirdPartypopulatesresume_payload.
Integration tests
- warp-server:
TestResolvePromptHandler_HandoffRehydrationNoPriorExecutionpins emptyprompt/system_prompt/resumption_promptwhen no prior ended execution exists;TestGetConversationRawTranscriptDownloadURL_OzRejectedcovers the Oz 400 path that the newGET /harness-support/transcripthandler relies on; existing tests cover the upload side. - oz-agent-worker:
AugmentArgsForTaskforwards--conversation <id>to the embedded CLI whentask.AgentConversationIDis set, and omits the flag otherwise.
Manual
- Short Claude cloud agent → note
<id>. agent run --harness claude --conversation <id>locally → jsonl grows, Claude/resumelists the session.- 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<String>toSpawnAgentRequest, forwardargs.conversationfromrun-cloud --conversation(with the up-frontfetch_and_validate_conversation_harnesscall insidespawn_future), and ship as part of the broader local→cloud handoff design. The server already accepts the field and the worker already forwardstask.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-idinRunAgentArgsonce the worker stops appending--conversationalongside--task-idfor Slack/Linear followups. Until then, both flags can be passed; the runtime merge prefers the explicit--conversation. - Add a second
ResumePayloadvariant + per-harnessfetch_transcriptdeserializer when another CLI harness (Gemini / Codex / opencode) gains resume support. The generic surface (raw-bytes fetch onHarnessSupportClient, harness-decidedresumption_promptinjection) is already in place. - Reconcile the duplicated
types.Taskfields between warp-server and oz-agent-worker. - Retry/fallback semantics on
write_envelopefailure (today: hard error). - Auto-detect
--harnessfrom metadata once harness reading moves below the conversation-fetch step inbuild_merged_config_and_taskandambient.rs.