8.0 KiB
Tombstone metadata for cloud agent runs — Tech Spec
Pairs with PRODUCT.md.
Problem
ConversationEndedTombstoneView reads run_time / credits / artifacts off the in-memory AIConversation. Non-Oz cloud runs don't materialize one (they produce a CLIAgentConversation), so all three stay empty. Cloud Oz uses values that under-report relative to the details panel: exchange-derived run time excludes the cloud lifecycle; credits_spent() excludes compute cost; conversation-side artifacts can lag the task copy. The artifact buttons row is also constructed eagerly in the constructor and never refreshed, so the async task fetch has nothing to push artifacts into.
Separately, both the tombstone and ConversationDetailsPanel unconditionally surface the Continue locally button on non-transcript runs. ContinueConversationLocally only forks Oz-harness conversations, so for non-Oz tasks the button is dead UI.
Relevant code
app/src/terminal/view/shared_session/conversation_ended_tombstone_view.rs—TombstoneDisplayData,from_conversation,enrich_from_task(#[cfg(not(target_family = "wasm"))]),render_metadata_row,render_action_buttons.app/src/ai/conversation_details_panel.rs—ConversationDetailsData::from_task(~line 310),ConversationDetailsData::harness,ConversationDetailsPanel::continue_locally_conversation_id. Consistency reference: sets both fields straight from the task.app/src/ai/ambient_agents/task.rs—AmbientAgentTask::run_time()(Option<chrono::Duration>),credits_used()(Option<f32>),AgentConfigSnapshot::harness(Option<HarnessConfig>),HarnessConfig::harness_type(Harness).crates/warp_cli/src/agent.rs—Harnessenum (Oz,Claude,Gemini).format_credits(app/src/ai/blocklist/view_util.rs) andhuman_readable_precise_duration(app/src/util/time_format.rs) — already imported by the tombstone.
Change
Extend enrich_from_task to overwrite run_time, credits, and artifacts from the task whenever the task has values, and refresh the artifact buttons view after the async task fetch resolves. Mirrors ConversationDetailsData::from_task.
#[cfg(not(target_family = "wasm"))]
fn enrich_from_task(&mut self, task: AmbientAgentTask) {
// existing: title / source / skill / error ...
if let Some(d) = task.run_time() {
self.run_time = Some(human_readable_precise_duration(d));
}
if let Some(c) = task.credits_used() {
self.credits = Some(format_credits(c));
}
if !task.artifacts.is_empty() {
self.artifacts = task.artifacts;
}
}
The constructor now always materializes artifact_buttons_view (passing whatever display_data.artifacts start with, possibly empty), and the spawn callback that handles the task fetch calls update_artifacts(&me.display_data.artifacts, ctx) after enrichment. Render gates the row on display_data.artifacts.is_empty().
Continue Locally harness gate
TombstoneDisplayData gains one field: harness: Option<Harness>, defaulting to None. enrich_from_task sets it from task.agent_config_snapshot: snapshot present + explicit harness → Some(harness); snapshot present + no explicit harness → Some(Oz) (default); snapshot absent → stays None.
render_action_buttons (cfg not(wasm)) and ConversationDetailsPanel::continue_locally_conversation_id both apply the same gate: !matches!(harness, Some(h) if h != Harness::Oz). None and Some(Oz) both show the button; only confirmed non-Oz hides it. No is_task flag is needed — plain conversations and pre-load tasks naturally fall into the None bucket.
ConversationDetailsData::from_task already populates harness, so no upstream wiring changes are required for the panel.
Wasm Open in Warp is intentionally untouched: it just opens the same conversation in the desktop client, where the same gate hides Continue locally.
Notes:
- All three metadata fields are unguarded: when the task has a value, it wins. When it doesn't, whatever
from_conversationpopulated (or default-empty for non-Oz) survives. - Local Oz runs have no
task_id, soenrich_from_tasknever runs andfrom_conversationremains the only source. No regression there. - Tombstone keeps a single aggregated
Credits used: X.Xsegment; the panel's inference/compute split (render_credits_with_split) is panel-only. - The
artifact_buttons_viewfield is no longerOption. The view returnsEmptywhen its button list is empty, but we still render-gate to avoid the surrounding margin container. - The harness gate is permissive on
None: cloud non-Oz tasks briefly show the button until the fetch resolves and switchesharnesstoSome(non-Oz). - No new fetches, no schema work, no GraphQL changes.
Risks
- Cloud Oz numbers visibly change (both up). Intentional; matches the details panel for the same run. No flag-gating; if surprising in dogfood, can hide behind
FeatureFlag::AgentHarnessafter the fact. - Brief flash as
from_conversationvalues render before the async task fetch resolves and overwrites them. Same race the panel has today; acceptable. Continue locallyflashes briefly on non-Oz tombstones before the task fetch resolves. Conscious trade-off: hiding eagerly would also hide the button on local conversations and cloud Oz runs (we don't know the harness yet), which is worse.- Snapshot missing harness on a non-Oz run mis-shows
Continue locallybecauseSome(snapshot) + harness == Nonedefaults toSome(Oz). In practice the server always populatesharnesswhen a non-Oz run is dispatched; if that ever changes the gate becomes too permissive. - Wasm:
enrich_from_taskiscfg(not wasm), so cloud tombstones on web are unchanged. Pre-existing gap shared with the panel. The wasmOpen in Warpbutton stays visible because it just routes the user to the desktop tombstone, which then enforces the gate. - Partial task data: if
run_time()orcredits_used()isNone, we leave whateverfrom_conversationset (avoids blanking Oz when the task is partially populated).
Tests
Unit tests next to conversation_ended_tombstone_view.rs:
- Oz with task: run time / credits overwritten by task values.
- Oz with task missing those fields:
from_conversationvalues preserved. - Non-Oz happy path: empty defaults populated from a fully-populated task.
- Task artifacts populate empty defaults.
- Task artifacts override conversation artifacts.
- Empty
task.artifactspreserve conversation artifacts. - Task without
agent_config_snapshotleavesharness == None. - Snapshot without explicit harness defaults to
Some(Harness::Oz). - Snapshot with explicit harness propagates
Some(harness)for each variant.
Details panel tests (conversation_details_panel_tests.rs) already cover from_task populating harness for each variant and from_conversation_metadata passing it through, which is the input to the new continue_locally_conversation_id gate.
No cross-surface test: tests 1 and 3 already assert tombstone output equals human_readable_precise_duration(task.run_time()) and format_credits(task.credits_used()), which is the same data path the panel feeds into ConversationDetailsData::from_task. A literal panel-vs-tombstone string comparison would just rerun the same formatters and require widening visibility on private panel internals.
Manual: cloud Claude Code (Continue locally hidden in both surfaces once the task loads), cloud Oz (numbers should match the details panel; Continue locally shown), local Oz (unchanged). Run ./script/presubmit before PR.
Follow-ups
- Wasm parity (fetch the task and run
enrich_from_taskon web; share with the panel's wasm path). Directory:/Branch:for non-Oz via a futurePOST /harness-support/report-contextsibling endpoint piggy-backed undertry_join!with the existing uploads.- Shared helper translating
AmbientAgentTask→ display strings if a third surface ever needs it. - If/when
ContinueConversationLocallylearns to fork non-Oz runs, drop the harness gate (or invert it to whatever the new constraint is).