From 72f92506a36524f29960d0119de1155970abbab3 Mon Sep 17 00:00:00 2001 From: Oz Date: Thu, 23 Jul 2026 21:10:10 +0000 Subject: [PATCH 01/10] Spec: restore TUI cost footer baseline Define the client-only restore baseline, cloud fallback, and validation contract for APP-4952.\n\nCo-Authored-By: Oz --- .../specs/APP-4952-tui-cost-footer-restore.md | 71 +++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 .agents/specs/APP-4952-tui-cost-footer-restore.md diff --git a/.agents/specs/APP-4952-tui-cost-footer-restore.md b/.agents/specs/APP-4952-tui-cost-footer-restore.md new file mode 100644 index 00000000000..33dde4530f9 --- /dev/null +++ b/.agents/specs/APP-4952-tui-cost-footer-restore.md @@ -0,0 +1,71 @@ +--- +ticket: APP-4952 +repository: warpdotdev/warp +estimate: M +surface: crates/warp_tui plus client restore/hydration +--- +# Restore the TUI cost-footer baseline (APP-4952) +## Product +*Summary:* When a user restores a non-empty Agent Mode conversation in the headless TUI, the footer's credits⇄cost entry must not reset the provider-dollar cost to zero. A known historical provider cost is the starting baseline, and each later response adds only its newly reported provider cost. When a legacy or cloud payload does not contain historical provider cost, cost mode must show an explicit unavailable-baseline indicator rather than render a misleading `$0.00`. + +*Key design choices:* Persist an optional cumulative provider-cost baseline in the existing client conversation usage metadata; keep the restored baseline separate from the current-session delta so a response is never counted twice; make missing historical cost explicit with an unavailable-baseline indicator. + +### Behavior +1. A new conversation with a completed response keeps its current behavior: once the response reports `TokenUsage.cost_in_cents`, cost mode renders that accumulated cost in dollars and credits mode renders cumulative inference plus platform credits. +2. Restoring a conversation whose local persisted usage metadata contains a provider-cost baseline renders that baseline before any new prompt (for example, a persisted 3.2 cents baseline renders `$0.03`, not `$0.00`). +3. After restoration with a known baseline, a follow-up response adds exactly that response's `TokenUsage.cost_in_cents` to the baseline. A 3.2-cent restored baseline followed by a 1.2-cent response therefore renders 4.4 cents (`$0.04`, subject to the existing two-decimal formatter), never only the 1.2-cent increment. +4. Credits mode remains cumulative across restore and follow-up, using the existing inference-plus-platform credit semantics. The fix must not replace or double-count the credits metadata. +5. Both local-database hydration and server-token/local conversation hydration pass the optional baseline through the same `AIConversation::new_restored` / `new_restored_synthesizing_on_empty` semantics. A restore payload that omits the optional field is treated as “historical provider cost unknown,” not as a known zero. +6. For a legacy local row or a fresh cloud transcript that has credits/token metadata but no historical provider cost, cost mode renders an explicit unavailable-baseline indicator (for example, `Cost unavailable`) instead of displaying `$0.00` or an incremental-only dollar total. The indicator is deterministic and applies until a known baseline is available; credits mode is unchanged. +7. The existing usage-update event continues to invalidate the selected TUI session, so the footer reflects the restored baseline and the follow-up delta without requiring a conversation switch or a second prompt. + +## Technical +### Current state +- `app/src/ai/agent/conversation.rs:180-194 @ e24f75b2154ffeccc18f988237f45bad42ab613e` defines `ConversationUsageTotals`; its cost field is currently a plain `f32`. +- `app/src/ai/agent/conversation.rs:380-648 @ e24f75b2154ffeccc18f988237f45bad42ab613e` implements new plus strict/lenient restore and initializes `total_request_cost` plus `total_token_usage_by_model` to zero/empty even when `conversation_usage_metadata` is hydrated. +- `app/src/ai/agent/conversation.rs:569-646 @ e24f75b2154ffeccc18f988237f45bad42ab613e` copies persisted usage metadata during restore but does not hydrate provider cost into the in-memory accumulator. +- `app/src/ai/agent/conversation.rs:2134-2206 @ e24f75b2154ffeccc18f988237f45bad42ab613e` adds each live response's `TokenUsage.cost_in_cents` only to the in-memory per-model map. +- `app/src/ai/agent/conversation.rs:3685-3696 @ e24f75b2154ffeccc18f988237f45bad42ab613e` projects credits from cumulative metadata but projects provider cost only by summing that in-memory map. +- `crates/persistence/src/model.rs:1167-1255, 1607-1627 @ e24f75b2154ffeccc18f988237f45bad42ab613e` defines `AgentConversationData` and `ConversationUsageMetadata`; the serialized usage shape has credits, token, tool, and context metadata but no provider-cost field. +- `app/src/ai/agent/api/convert_conversation.rs:65-112 @ e24f75b2154ffeccc18f988237f45bad42ab613e` hydrates cloud conversation metadata into the same persisted usage metadata passed to the restore constructors. +- `crates/graphql/src/api/queries/get_conversation_usage.rs:115-125 @ e24f75b2154ffeccc18f988237f45bad42ab613e` and `crates/warp_graphql_schema/api/schema.graphql:836-880 @ e24f75b2154ffeccc18f988237f45bad42ab613e` expose credits/token usage but no provider-dollar cost. This is why the client must distinguish “unknown baseline” from zero for legacy/fresh cloud payloads. +- `crates/warp_tui/src/terminal_session_view.rs:1420-1436, 2511-2523 @ e24f75b2154ffeccc18f988237f45bad42ab613e` subscribes the footer to usage updates and projects selected-conversation totals. +- `crates/warp_tui/src/usage.rs:35-76 @ e24f75b2154ffeccc18f988237f45bad42ab613e` renders credits or cost and currently formats any zero cost as `$0.00`. +- `app/src/ai/blocklist/history_model.rs:1884-1916 @ e24f75b2154ffeccc18f988237f45bad42ab613e` emits the usage-update event after applying response usage, which the TUI already listens to. + +### Design alternatives +- *Seed the existing `total_token_usage_by_model` map with a synthetic restored row.* This keeps the public totals shape unchanged, but introduces a fake model identity, contaminates token reporting/replay, and makes it easy to double-count the first live response. Do not use it. +- *Recompute historical provider cost from restored token counts and client model pricing.* The persisted/server payload has token counts but no authoritative historical pricing, and pricing can vary by provider, model, cache category, or billing policy. This would produce guessed totals and is not acceptable for a cost display. +- *Extend the server/GraphQL usage contract immediately.* That would make fresh cloud restores exact, but expands APP-4952 into a coordinated warp-server/proto/schema rollout. The current ticket is scoped to the warp client and the client already has a durable local conversation record. Keep the client field backward-compatible and use the explicit unavailable-baseline indicator when cloud/legacy data omits provider cost; a future server field can populate the same optional field without changing the TUI projection. +- *Chosen: add an optional client-persisted cumulative provider-cost field and a dedicated restore-aware accumulator.* A new conversation starts with a known zero baseline; a restored conversation with the field gets that baseline; a restored conversation without it remains unknown. `ConversationUsageTotals` should expose whether cost is known (prefer `Option` for `cost_in_cents`), and `UsageToggle` should render an unavailable-baseline indicator when cost is unknown. This preserves existing local data, avoids false dollars, and keeps all restore constructors on one path. + +### Proposed changes +1. Add `provider_cost_in_cents: Option` to `persistence::model::ConversationUsageMetadata` with serde default/skip behavior so old local JSON and current cloud GraphQL payloads continue to deserialize. The field means cumulative provider cost for the entire conversation, not the last request. +2. Add restore-aware state to `AIConversation` (a known baseline plus current-session provider-cost delta, or an equivalent representation) and initialize it from the optional persisted field in both restore constructors. New conversations must initialize a known zero baseline; restored payloads without the field must preserve “unknown,” not coerce to zero. +3. In `update_cost_and_usage_for_request`, continue aggregating per-model token counts for existing consumers, but add each response's `TokenUsage.cost_in_cents` exactly once to the current-session delta. Update the persisted optional cumulative field only when the baseline is known; never overwrite a known baseline with an absent incoming metadata field. +4. Change `usage_totals` to return `Some(baseline + delta)` only when provider cost is known. Keep `credits_spent` sourced from `conversation_usage_metadata.credits_spent + platform_credits_spent`. +5. Update `write_updated_conversation_state`/the persisted `AgentConversationData` path and any fork/restore helpers that copy usage metadata so the cumulative field survives local restart and server-token continuation. Preserve `None` for legacy/fresh cloud payloads. +6. Update `crates/warp_tui/src/usage.rs` so cost mode formats a known dollar value exactly as today and renders a stable unavailable-baseline indicator when cost is `None`. Keep the click toggle, setting persistence, hover state, separators, and shell-mode suppression unchanged. +7. Update the conversation projection, persistence, conversion, and TUI render tests named below. Do not change server APIs in this PR; document the optional field as a client-compatible extension point for a future server payload. + +### Open questions resolved +- The requester confirmed the GUI behavior is out of scope and the fix should remain in the warp client/TUI path. +- The ticket's confirmed code-path probe observed original cost 3.2 cents → restored cost 0.0 → follow-up cost 1.2 cents. The probe could not execute in this sandbox because Cargo's `warp` test build was SIGKILLed after dependencies compiled; implementation must rerun it in a resource-capable environment. +- Historical provider cost is not present in the current server GraphQL usage metadata. The spec therefore treats missing cost as an explicit unknown state and renders an unavailable-baseline indicator rather than fabricating a dollar value. Exact historical dollars for fresh cloud-only transcripts require a future server contract change and are out of scope for APP-4952. +- “Server-token restoration” means a conversation restored through the client/local persisted record that carries a server token; the same optional field and restore constructors must be used. A cloud payload that carries no provider-cost field follows the unknown/unavailable-indicator rule above. +- Cost values remain US cents represented as `f32`, matching `TokenUsage.cost_in_cents` and the existing formatter; the optional wrapper is for availability, not a unit change. + +### Risks / blast radius +Changing the persisted metadata shape touches local JSON round-tripping, conversation forks, cloud-to-client conversion, and TUI formatting. Mitigate with serde defaults, a restore test for `Some` and `None`, a no-double-count follow-up test, and render-to-lines assertions for known cost, unavailable cost, credits mode, shell mode, and new conversations. Do not derive cost from credits or token counts. + +### Validation & verification criteria +All criteria below must pass before merge: +1. Reproduce the ticket's confirmed sequence in a focused test: create a conversation with a persisted provider-cost baseline of 3.2 cents and cumulative credits, restore it through `AIConversation::new_restored` (and the empty-task constructor where applicable), assert the cost projection is 3.2 cents before a new prompt, apply one follow-up `TokenUsage` with `cost_in_cents = 1.2`, and assert the projection is 4.4 cents. The pre-fix implementation must fail this test by producing 0.0 then 1.2. +2. Add/maintain a named regression test in `app/src/ai/agent/conversation_tests.rs` (for example `restored_usage_totals_preserve_provider_cost_baseline_and_add_follow_up`) that covers both a known baseline and the no-baseline legacy case; it must fail before the change and pass after. +3. Verify `ConversationUsageMetadata` and `AgentConversationData` serde compatibility in `crates/persistence/src/model_tests.rs`: a payload with `provider_cost_in_cents` round-trips exactly, and a legacy payload that omits it deserializes to `None` without changing credits/token fields. +4. Verify the TUI projection in `crates/warp_tui/src/usage_tests.rs` and `crates/warp_tui/src/terminal_session_view_tests.rs`: known cost mode renders the expected dollars, unknown cost mode renders the stable unavailable-baseline indicator (never `$0.00`), credits mode remains unchanged, and shell mode still omits usage. +5. Verify restore/update redraw behavior by exercising `ConversationUsageMetadataUpdated` for the selected conversation and asserting the rendered footer changes from the known baseline to baseline-plus-delta without switching conversations. Use the existing render-to-lines helpers; no GUI integration test is required. +6. Verify no collateral regression for new conversations: the existing `usage_totals_reads_gui_credits_and_accumulates_provider_cost` test (updated for optional cost) continues to pass, including cumulative credits and two live request deltas. +7. Run the focused tests with a writable Cargo home in a resource-capable environment: `CARGO_HOME=/tmp/cargo-home cargo nextest run -p warp restored_usage_totals --no-fail-fast` (or the repository's equivalent filter), `CARGO_HOME=/tmp/cargo-home cargo nextest run -p warp_tui --no-fail-fast`, and the relevant persistence tests. +8. Run the repository checks required by `warp/AGENTS.md`: `./script/format`; `cargo clippy --workspace --all-targets --all-features --tests -- -D warnings`; `cargo nextest run --no-fail-fast --workspace --exclude command-signatures-v2`; and `cargo test --doc`. If the sandbox resource limit still SIGKILLs Cargo, record the exact failure and leave the PR unverified rather than claiming success. +9. Verify the user-visible headless TUI path in a resource-capable terminal using the repository's TUI verification/render-to-lines workflow (`./script/run-tui` or the corresponding `tui-verify-change` procedure): restore a conversation with a known baseline, select cost mode, observe the baseline before a prompt, submit one prompt, and observe baseline-plus-increment. Also exercise a legacy/cloud payload without a provider-cost field and confirm the unavailable-baseline indicator; no `$0.00` placeholder is permitted. From 46306a62683d8b538e8d4f974a0eb40ec1dd42c8 Mon Sep 17 00:00:00 2001 From: Oz Date: Thu, 23 Jul 2026 22:07:17 +0000 Subject: [PATCH 02/10] spec: align APP-4952 with server-authoritative cost Co-Authored-By: Oz --- .../specs/APP-4952-tui-cost-footer-restore.md | 108 ++++++++++-------- 1 file changed, 60 insertions(+), 48 deletions(-) diff --git a/.agents/specs/APP-4952-tui-cost-footer-restore.md b/.agents/specs/APP-4952-tui-cost-footer-restore.md index 33dde4530f9..75fe8ee12b1 100644 --- a/.agents/specs/APP-4952-tui-cost-footer-restore.md +++ b/.agents/specs/APP-4952-tui-cost-footer-restore.md @@ -2,70 +2,82 @@ ticket: APP-4952 repository: warpdotdev/warp estimate: M -surface: crates/warp_tui plus client restore/hydration +surface: app conversation restore/hydration plus crates/warp_tui cost footer --- -# Restore the TUI cost-footer baseline (APP-4952) +# Restore the TUI cost footer from server-authoritative provider cost (APP-4952) ## Product -*Summary:* When a user restores a non-empty Agent Mode conversation in the headless TUI, the footer's credits⇄cost entry must not reset the provider-dollar cost to zero. A known historical provider cost is the starting baseline, and each later response adds only its newly reported provider cost. When a legacy or cloud payload does not contain historical provider cost, cost mode must show an explicit unavailable-baseline indicator rather than render a misleading `$0.00`. +*Summary:* When a user restores a non-empty Agent Mode conversation in the headless TUI, the credits⇄cost footer must show the conversation's cumulative provider cost before the next prompt. The server-provided nullable `total_provider_cost_in_cents` value is the authoritative historical baseline and must travel with the conversation across devices. If an older or legacy payload has no historical provider cost, the TUI must say `Cost unavailable` rather than imply that the total is `$0.00` or show only a post-restore increment. -*Key design choices:* Persist an optional cumulative provider-cost baseline in the existing client conversation usage metadata; keep the restored baseline separate from the current-session delta so a response is never counted twice; make missing historical cost explicit with an unavailable-baseline indicator. +*Key design choices:* Treat the server's optional cumulative cents value as the only historical-cost source; represent absent historical cost as an explicit unknown (`Option`, never numeric zero); add a live response delta only when it cannot be double-counted by a newer server cumulative snapshot; keep credits and new-conversation behavior unchanged. ### Behavior -1. A new conversation with a completed response keeps its current behavior: once the response reports `TokenUsage.cost_in_cents`, cost mode renders that accumulated cost in dollars and credits mode renders cumulative inference plus platform credits. -2. Restoring a conversation whose local persisted usage metadata contains a provider-cost baseline renders that baseline before any new prompt (for example, a persisted 3.2 cents baseline renders `$0.03`, not `$0.00`). -3. After restoration with a known baseline, a follow-up response adds exactly that response's `TokenUsage.cost_in_cents` to the baseline. A 3.2-cent restored baseline followed by a 1.2-cent response therefore renders 4.4 cents (`$0.04`, subject to the existing two-decimal formatter), never only the 1.2-cent increment. -4. Credits mode remains cumulative across restore and follow-up, using the existing inference-plus-platform credit semantics. The fix must not replace or double-count the credits metadata. -5. Both local-database hydration and server-token/local conversation hydration pass the optional baseline through the same `AIConversation::new_restored` / `new_restored_synthesizing_on_empty` semantics. A restore payload that omits the optional field is treated as “historical provider cost unknown,” not as a known zero. -6. For a legacy local row or a fresh cloud transcript that has credits/token metadata but no historical provider cost, cost mode renders an explicit unavailable-baseline indicator (for example, `Cost unavailable`) instead of displaying `$0.00` or an incremental-only dollar total. The indicator is deterministic and applies until a known baseline is available; credits mode is unchanged. -7. The existing usage-update event continues to invalidate the selected TUI session, so the footer reflects the restored baseline and the follow-up delta without requiring a conversation switch or a second prompt. +1. A new conversation keeps current behavior: the footer remains hidden until usage exists, then cost mode displays the sum of live provider costs and credits mode displays the existing cumulative inference-plus-platform credits. +2. Restoring a conversation whose hydrated server usage metadata contains `total_provider_cost_in_cents = 3.2` displays `$0.03` in cost mode before any new prompt. The value is US cents and may be fractional. +3. A follow-up response adds exactly its newly reported provider cost to the restored total when no newer cumulative server value includes that response. A 3.2-cent baseline followed by a 1.2-cent response displays 4.4 cents (`$0.04` with the existing formatter), never only 1.2 cents and never 5.6 cents. +4. If a response/update supplies a newer server cumulative `total_provider_cost_in_cents`, that snapshot wins and the client must not add the same response's `TokenUsage.cost_in_cents` again. An absent field in a legacy response does not erase a known baseline. +5. Both `AIConversation::new_restored` (strict, non-empty task restore) and `AIConversation::new_restored_synthesizing_on_empty` (lenient local/child restore) seed the same cost state from `conversation_usage_metadata.total_provider_cost_in_cents`. +6. `convert_conversation_data_to_ai_conversation` passes the server usage metadata, including the optional provider-cost field, into the restore constructors for cloud hydration. It must not replace the server value with a client-derived total; existing continue/fork metadata semantics remain intact. +7. A restored payload that omits `total_provider_cost_in_cents` is an unknown historical baseline, not a known zero. In cost mode the selected conversation's footer renders the stable text `Cost unavailable` (or the exact approved equivalent) whenever the conversation has usage metadata, including when credits happen to be zero. It must not render `$0.00` or an incremental-only dollar total. Credits mode remains unchanged. +8. A conversation with an unknown baseline stays unavailable after a follow-up that reports only a per-request cost; the client must not mislead by presenting that increment as a cumulative total. If a later server snapshot supplies the cumulative field, cost mode switches to that known total. +9. Existing usage-update invalidation continues to redraw the selected TUI session so a restored baseline, a server snapshot, or a permitted live delta is visible without switching conversations or sending a second prompt. Shell mode still hides model and usage sections. ## Technical ### Current state -- `app/src/ai/agent/conversation.rs:180-194 @ e24f75b2154ffeccc18f988237f45bad42ab613e` defines `ConversationUsageTotals`; its cost field is currently a plain `f32`. -- `app/src/ai/agent/conversation.rs:380-648 @ e24f75b2154ffeccc18f988237f45bad42ab613e` implements new plus strict/lenient restore and initializes `total_request_cost` plus `total_token_usage_by_model` to zero/empty even when `conversation_usage_metadata` is hydrated. -- `app/src/ai/agent/conversation.rs:569-646 @ e24f75b2154ffeccc18f988237f45bad42ab613e` copies persisted usage metadata during restore but does not hydrate provider cost into the in-memory accumulator. -- `app/src/ai/agent/conversation.rs:2134-2206 @ e24f75b2154ffeccc18f988237f45bad42ab613e` adds each live response's `TokenUsage.cost_in_cents` only to the in-memory per-model map. -- `app/src/ai/agent/conversation.rs:3685-3696 @ e24f75b2154ffeccc18f988237f45bad42ab613e` projects credits from cumulative metadata but projects provider cost only by summing that in-memory map. -- `crates/persistence/src/model.rs:1167-1255, 1607-1627 @ e24f75b2154ffeccc18f988237f45bad42ab613e` defines `AgentConversationData` and `ConversationUsageMetadata`; the serialized usage shape has credits, token, tool, and context metadata but no provider-cost field. -- `app/src/ai/agent/api/convert_conversation.rs:65-112 @ e24f75b2154ffeccc18f988237f45bad42ab613e` hydrates cloud conversation metadata into the same persisted usage metadata passed to the restore constructors. -- `crates/graphql/src/api/queries/get_conversation_usage.rs:115-125 @ e24f75b2154ffeccc18f988237f45bad42ab613e` and `crates/warp_graphql_schema/api/schema.graphql:836-880 @ e24f75b2154ffeccc18f988237f45bad42ab613e` expose credits/token usage but no provider-dollar cost. This is why the client must distinguish “unknown baseline” from zero for legacy/fresh cloud payloads. -- `crates/warp_tui/src/terminal_session_view.rs:1420-1436, 2511-2523 @ e24f75b2154ffeccc18f988237f45bad42ab613e` subscribes the footer to usage updates and projects selected-conversation totals. -- `crates/warp_tui/src/usage.rs:35-76 @ e24f75b2154ffeccc18f988237f45bad42ab613e` renders credits or cost and currently formats any zero cost as `$0.00`. -- `app/src/ai/blocklist/history_model.rs:1884-1916 @ e24f75b2154ffeccc18f988237f45bad42ab613e` emits the usage-update event after applying response usage, which the TUI already listens to. +References below are pinned to the current PR branch commit `2c98d4012a75c8bd44bcfe032c6d2407a848f3f3`. + +- `app/src/ai/agent/conversation.rs:180-205 @ 2c98d4012a75c8bd44bcfe032c6d2407a848f3f3` defines `ConversationUsageTotals` with a plain `f32` cost, so zero conflates a real zero with an unavailable historical value. +- `app/src/ai/agent/conversation.rs:393-406,423-431,540-647 @ 2c98d4012a75c8bd44bcfe032c6d2407a848f3f3` constructs new and restored conversations. Restore hydrates `conversation_usage_metadata` but initializes `total_request_cost` and `total_token_usage_by_model` to empty/zero, which drops historical provider cost. +- `app/src/ai/agent/conversation.rs:2134-2206 @ 2c98d4012a75c8bd44bcfe032c6d2407a848f3f3` accumulates each response's `TokenUsage.cost_in_cents` in the in-memory per-model map and copies other usage metadata. +- `app/src/ai/agent/conversation.rs:3673-3696 @ 2c98d4012a75c8bd44bcfe032c6d2407a848f3f3` computes `usage_totals()`: credits come from cumulative metadata but provider cost is recomputed only from the in-memory map. +- `crates/persistence/src/model.rs:1167-1255,1607-1627 @ 2c98d4012a75c8bd44bcfe032c6d2407a848f3f3` defines `AgentConversationData` and the serialized `ConversationUsageMetadata`; the client shape currently has no provider-cost field. +- `app/src/ai/agent/api/convert_conversation.rs:65-117 @ 2c98d4012a75c8bd44bcfe032c6d2407a848f3f3` converts cloud `ServerAIConversationMetadata.usage` into `AgentConversationData` and invokes `AIConversation::new_restored`. +- `crates/graphql/src/api/queries/get_conversation_usage.rs:111-191 @ 2c98d4012a75c8bd44bcfe032c6d2407a848f3f3` maps GraphQL conversation usage into the persistence usage type; its query fragment currently has no nullable provider-cost field. +- `crates/warp_graphql_schema/api/schema.graphql:836-880 @ 2c98d4012a75c8bd44bcfe032c6d2407a848f3f3` is the client GraphQL schema copy and currently exposes credits/tokens but no provider cost. The coordinated server spec adds the nullable GraphQL field (`totalProviderCostInCents`) for the shared `total_provider_cost_in_cents` contract. +- `crates/warp_tui/src/usage.rs:35-76 @ 2c98d4012a75c8bd44bcfe032c6d2407a848f3f3` renders cost as dollars and currently formats every numeric zero as `$0.00`. +- `crates/warp_tui/src/terminal_session_view.rs:1420-1436,2511-2523 @ 2c98d4012a75c8bd44bcfe032c6d2407a848f3f3` invalidates the selected footer on `ConversationUsageMetadataUpdated` and hides the entry when totals equal the default, so unknown-with-zero must be distinguishable from no usage. +- Existing tests in `app/src/ai/agent/conversation_tests.rs`, `app/src/ai/agent/api/convert_conversation_tests.rs`, `crates/persistence/src/model_tests.rs`, `crates/warp_tui/src/usage_tests.rs`, and `crates/warp_tui/src/terminal_session_view_tests.rs` provide the focused restore, conversion, serde, and render-to-lines harnesses. + +### Shared field contract +- The server and client must use one semantic field: `total_provider_cost_in_cents`, nullable/optional floating-point cents, representing the conversation's cumulative provider cost across all completed requests. It is not the last-request cost, credits, or a value reconstructed from token counts. +- On the wire the GraphQL naming follows the schema (`totalProviderCostInCents`); the client query/conversion and generated types map it to Rust `Option` in `persistence::model::ConversationUsageMetadata`. Preserve `None` when the field is absent/null so older server responses and local rows deserialize safely. +- The server remains authoritative for the historical baseline. The client may cache the hydrated optional field in its existing conversation metadata for local restoration, but must not invent a baseline from credits, token counts, model pricing, or a client-only durable accumulator. The superseded client-only WIP commits on PR #14220 are not the design to implement. ### Design alternatives -- *Seed the existing `total_token_usage_by_model` map with a synthetic restored row.* This keeps the public totals shape unchanged, but introduces a fake model identity, contaminates token reporting/replay, and makes it easy to double-count the first live response. Do not use it. -- *Recompute historical provider cost from restored token counts and client model pricing.* The persisted/server payload has token counts but no authoritative historical pricing, and pricing can vary by provider, model, cache category, or billing policy. This would produce guessed totals and is not acceptable for a cost display. -- *Extend the server/GraphQL usage contract immediately.* That would make fresh cloud restores exact, but expands APP-4952 into a coordinated warp-server/proto/schema rollout. The current ticket is scoped to the warp client and the client already has a durable local conversation record. Keep the client field backward-compatible and use the explicit unavailable-baseline indicator when cloud/legacy data omits provider cost; a future server field can populate the same optional field without changing the TUI projection. -- *Chosen: add an optional client-persisted cumulative provider-cost field and a dedicated restore-aware accumulator.* A new conversation starts with a known zero baseline; a restored conversation with the field gets that baseline; a restored conversation without it remains unknown. `ConversationUsageTotals` should expose whether cost is known (prefer `Option` for `cost_in_cents`), and `UsageToggle` should render an unavailable-baseline indicator when cost is unknown. This preserves existing local data, avoids false dollars, and keeps all restore constructors on one path. +- *Keep the prior client-only durable local field.* Rejected: it cannot repair restores on another machine and can diverge from server billing. The previous PR draft is explicitly superseded. +- *Derive historical dollars from token counts and client pricing.* Rejected: the payload lacks authoritative historical provider/model pricing and cache/provider policy can change; the result would be a guess. +- *Use credits as a dollar fallback or treat missing cost as zero.* Rejected: credits and provider dollars have different units; either choice produces a misleading footer. +- *Use a synthetic entry in `total_token_usage_by_model` for the restored amount.* Rejected: it fabricates a model row, contaminates token reporting, and risks counting the first live response twice. +- *Represent unavailable cost with a numeric sentinel.* Rejected: zero is a valid cost and the existing footer would render it as `$0.00`. Chosen: `Option` (or an equivalent explicit availability type) plus a footer fallback. +- *Chosen server snapshot plus guarded live delta:* seed a known baseline from `total_provider_cost_in_cents`; while a response is in flight, add its per-request cost only if no cumulative server snapshot for that response was applied; when a cumulative snapshot arrives, replace the baseline and clear/reconcile the delta. With an unknown baseline, do not display a delta as a total. ### Proposed changes -1. Add `provider_cost_in_cents: Option` to `persistence::model::ConversationUsageMetadata` with serde default/skip behavior so old local JSON and current cloud GraphQL payloads continue to deserialize. The field means cumulative provider cost for the entire conversation, not the last request. -2. Add restore-aware state to `AIConversation` (a known baseline plus current-session provider-cost delta, or an equivalent representation) and initialize it from the optional persisted field in both restore constructors. New conversations must initialize a known zero baseline; restored payloads without the field must preserve “unknown,” not coerce to zero. -3. In `update_cost_and_usage_for_request`, continue aggregating per-model token counts for existing consumers, but add each response's `TokenUsage.cost_in_cents` exactly once to the current-session delta. Update the persisted optional cumulative field only when the baseline is known; never overwrite a known baseline with an absent incoming metadata field. -4. Change `usage_totals` to return `Some(baseline + delta)` only when provider cost is known. Keep `credits_spent` sourced from `conversation_usage_metadata.credits_spent + platform_credits_spent`. -5. Update `write_updated_conversation_state`/the persisted `AgentConversationData` path and any fork/restore helpers that copy usage metadata so the cumulative field survives local restart and server-token continuation. Preserve `None` for legacy/fresh cloud payloads. -6. Update `crates/warp_tui/src/usage.rs` so cost mode formats a known dollar value exactly as today and renders a stable unavailable-baseline indicator when cost is `None`. Keep the click toggle, setting persistence, hover state, separators, and shell-mode suppression unchanged. -7. Update the conversation projection, persistence, conversion, and TUI render tests named below. Do not change server APIs in this PR; document the optional field as a client-compatible extension point for a future server payload. +1. Extend the client GraphQL query fragment/schema mapping and `persistence::model::ConversationUsageMetadata` with optional `total_provider_cost_in_cents`, using serde defaults/skip behavior for old local JSON. Regenerate client GraphQL artifacts according to the repository workflow. Keep the server field name/meaning exactly aligned with the parallel warp-server spec. +2. Add restore-aware provider-cost state to `AIConversation` without using a synthetic model row. Both `new_restored` and `new_restored_synthesizing_on_empty` must initialize it from the hydrated metadata; a new conversation starts with a known zero baseline once it has usage. Preserve the optional field through `usage_metadata()`, existing local persistence snapshots, forks, and cloud-token continuation. +3. Update `convert_conversation_data_to_ai_conversation` and its tests so cloud metadata's optional server total reaches the restore constructors for the same conversation. Do not add a second cloud-only cost calculation or silently reset the field for a restore. +4. Update `update_cost_and_usage_for_request` and `usage_totals()` so server cumulative totals take precedence, per-request token costs are added at most once for immediate known-baseline display, and `ConversationUsageTotals` exposes explicit cost availability (prefer `Option`). Preserve credits from inference plus platform metadata exactly as today. +5. Update TUI projection and rendering: selected-conversation usage must distinguish “no usage yet” from “usage exists but historical cost unknown”; cost mode formats known cents with the current dollar formatter and renders `Cost unavailable` for unknown cost; credits mode, click toggling, hover state, separators, shell-mode suppression, and new-conversation behavior remain unchanged. +6. Keep `ConversationUsageMetadataUpdated` invalidation wired to the selected session and add tests for baseline redraw and server-snapshot reconciliation. No GUI footer behavior is in scope. ### Open questions resolved -- The requester confirmed the GUI behavior is out of scope and the fix should remain in the warp client/TUI path. -- The ticket's confirmed code-path probe observed original cost 3.2 cents → restored cost 0.0 → follow-up cost 1.2 cents. The probe could not execute in this sandbox because Cargo's `warp` test build was SIGKILLed after dependencies compiled; implementation must rerun it in a resource-capable environment. -- Historical provider cost is not present in the current server GraphQL usage metadata. The spec therefore treats missing cost as an explicit unknown state and renders an unavailable-baseline indicator rather than fabricating a dollar value. Exact historical dollars for fresh cloud-only transcripts require a future server contract change and are out of scope for APP-4952. -- “Server-token restoration” means a conversation restored through the client/local persisted record that carries a server token; the same optional field and restore constructors must be used. A cloud payload that carries no provider-cost field follows the unknown/unavailable-indicator rule above. -- Cost values remain US cents represented as `f32`, matching `TokenUsage.cost_in_cents` and the existing formatter; the optional wrapper is for availability, not a unit change. +- The requester changed the design on 2026-07-23: server persistence/API is the source of truth, and this warp spec is coordinated with the parallel warp-server spec. The earlier client-only design on PR #14220 is superseded and must not be implemented. +- The shared field name and unit are fixed: nullable/optional `total_provider_cost_in_cents`, floating-point US cents, cumulative for the conversation. If the server PR exposes a different wire spelling, reconcile it before implementation rather than introducing a second client field. +- Missing historical cost is an explicit unknown state. The implementor should use `Option` (or a semantically identical type) and the exact stable user-facing fallback may be chosen between `Cost unavailable` and an equivalent wording, but it must never be `$0.00` or an incremental-only total. +- A restored conversation with an unknown baseline cannot become a trustworthy cumulative dollar display from a per-request cost alone. It remains unavailable until a server cumulative field arrives. +- `RestorationMode::Continue` is the primary cloud-restore acceptance path. Existing fork metadata behavior is preserved; the field is passed through when the payload represents the forked conversation's usage and is not re-derived by the client. +- The prior focused probe established the failing sequence 3.2 cents → restored 0.0 → follow-up 1.2 cents. Its Cargo build was SIGKILLed by the sandbox resource limit, so implementation verification must run in a resource-capable environment. ### Risks / blast radius -Changing the persisted metadata shape touches local JSON round-tripping, conversation forks, cloud-to-client conversion, and TUI formatting. Mitigate with serde defaults, a restore test for `Some` and `None`, a no-double-count follow-up test, and render-to-lines assertions for known cost, unavailable cost, credits mode, shell mode, and new conversations. Do not derive cost from credits or token counts. +The optional field crosses the server GraphQL contract, client GraphQL conversion, local serde snapshots, restore constructors, live usage updates, and TUI rendering. A stale generated schema or a null-to-zero conversion would recreate the bug. Guard with explicit `Some`/`None` tests, server-snapshot precedence/no-double-count coverage, legacy serde fixtures, and render-to-lines assertions for known, unknown, credits, shell, and new-conversation states. ### Validation & verification criteria All criteria below must pass before merge: -1. Reproduce the ticket's confirmed sequence in a focused test: create a conversation with a persisted provider-cost baseline of 3.2 cents and cumulative credits, restore it through `AIConversation::new_restored` (and the empty-task constructor where applicable), assert the cost projection is 3.2 cents before a new prompt, apply one follow-up `TokenUsage` with `cost_in_cents = 1.2`, and assert the projection is 4.4 cents. The pre-fix implementation must fail this test by producing 0.0 then 1.2. -2. Add/maintain a named regression test in `app/src/ai/agent/conversation_tests.rs` (for example `restored_usage_totals_preserve_provider_cost_baseline_and_add_follow_up`) that covers both a known baseline and the no-baseline legacy case; it must fail before the change and pass after. -3. Verify `ConversationUsageMetadata` and `AgentConversationData` serde compatibility in `crates/persistence/src/model_tests.rs`: a payload with `provider_cost_in_cents` round-trips exactly, and a legacy payload that omits it deserializes to `None` without changing credits/token fields. -4. Verify the TUI projection in `crates/warp_tui/src/usage_tests.rs` and `crates/warp_tui/src/terminal_session_view_tests.rs`: known cost mode renders the expected dollars, unknown cost mode renders the stable unavailable-baseline indicator (never `$0.00`), credits mode remains unchanged, and shell mode still omits usage. -5. Verify restore/update redraw behavior by exercising `ConversationUsageMetadataUpdated` for the selected conversation and asserting the rendered footer changes from the known baseline to baseline-plus-delta without switching conversations. Use the existing render-to-lines helpers; no GUI integration test is required. -6. Verify no collateral regression for new conversations: the existing `usage_totals_reads_gui_credits_and_accumulates_provider_cost` test (updated for optional cost) continues to pass, including cumulative credits and two live request deltas. -7. Run the focused tests with a writable Cargo home in a resource-capable environment: `CARGO_HOME=/tmp/cargo-home cargo nextest run -p warp restored_usage_totals --no-fail-fast` (or the repository's equivalent filter), `CARGO_HOME=/tmp/cargo-home cargo nextest run -p warp_tui --no-fail-fast`, and the relevant persistence tests. -8. Run the repository checks required by `warp/AGENTS.md`: `./script/format`; `cargo clippy --workspace --all-targets --all-features --tests -- -D warnings`; `cargo nextest run --no-fail-fast --workspace --exclude command-signatures-v2`; and `cargo test --doc`. If the sandbox resource limit still SIGKILLs Cargo, record the exact failure and leave the PR unverified rather than claiming success. -9. Verify the user-visible headless TUI path in a resource-capable terminal using the repository's TUI verification/render-to-lines workflow (`./script/run-tui` or the corresponding `tui-verify-change` procedure): restore a conversation with a known baseline, select cost mode, observe the baseline before a prompt, submit one prompt, and observe baseline-plus-increment. Also exercise a legacy/cloud payload without a provider-cost field and confirm the unavailable-baseline indicator; no `$0.00` placeholder is permitted. +1. Reproduce the ticket's confirmed sequence in a focused test: restore metadata with `total_provider_cost_in_cents = Some(3.2)`, assert `usage_totals()` is 3.2 cents before a prompt, apply a follow-up `TokenUsage.cost_in_cents = 1.2`, and assert 4.4 cents. The pre-fix implementation must fail by producing 0.0 then 1.2. +2. Add a named regression test in `app/src/ai/agent/conversation_tests.rs` (for example `restored_usage_totals_preserve_server_provider_cost_and_add_follow_up`) that covers both `AIConversation::new_restored` with non-empty tasks and `new_restored_synthesizing_on_empty` with an empty task list; it must fail before and pass after the change. +3. Add/extend `app/src/ai/agent/api/convert_conversation_tests.rs` to build `ServerAIConversationMetadata` with a known optional server total, run `convert_conversation_data_to_ai_conversation` in `RestorationMode::Continue`, and assert the resulting `usage_totals()` is the server total before any follow-up. Cover the absent/null field as `None`. +4. Add/extend `crates/persistence/src/model_tests.rs` and GraphQL conversion tests: a payload with `total_provider_cost_in_cents` round-trips exactly; an older payload that omits it deserializes to `None`; GraphQL null stays `None`; GraphQL 3.2 cents becomes client 3.2 cents without unit or precision loss beyond the existing `f32` representation; credits/token fields remain unchanged. +5. Test server-snapshot precedence and no double counting: when a response's metadata supplies a cumulative total that includes its token cost, `usage_totals()` equals that snapshot once; when metadata has no cumulative field and the baseline is known, the per-request delta is added once; when the baseline is unknown, a per-request delta does not render as a cumulative dollar total. +6. Update `crates/warp_tui/src/usage_tests.rs` and `crates/warp_tui/src/terminal_session_view_tests.rs` using the `tui-testing` render-to-lines harness: known cost renders expected dollars; unknown cost renders the stable unavailable indicator and never `$0.00`; credits mode remains unchanged; shell mode omits model/usage; a new conversation preserves current visibility and formatting. +7. Exercise the real `ConversationUsageMetadataUpdated` path for a selected conversation and assert the footer redraws from the restored baseline to baseline-plus-delta (or the newer server snapshot) without switching conversations. Ensure usage metadata with zero credits but unknown cost still renders the unavailable indicator rather than being hidden by a default-total comparison. +8. Run focused checks with a writable Cargo home and bounded build parallelism in a resource-capable environment: `CARGO_HOME=/tmp/cargo-home CARGO_BUILD_JOBS=2 cargo nextest run -p warp -E 'test(restored_usage_totals)' --no-fail-fast`, `CARGO_HOME=/tmp/cargo-home CARGO_BUILD_JOBS=2 cargo nextest run -p warp_tui --no-fail-fast`, and the relevant persistence/GraphQL tests. +9. Run the repository checks required by `warp/AGENTS.md`: `./script/format`; `cargo clippy --workspace --all-targets --all-features --tests -- -D warnings`; `cargo nextest run --no-fail-fast --workspace --exclude command-signatures-v2`; and `cargo test --doc`. If the environment still SIGKILLs Cargo, record the exact failure and leave verification explicitly outstanding. +10. Because this changes user-visible headless TUI output, perform live TUI verification per `tui-verify-change` in a real PTY (prefer `./script/run-tui`, or a bounded `cargo build -p warp_tui --bin warp-tui-oss` followed by tmux/capture-pane in a signed-in capable environment): restore a conversation with a known 3.2-cent server baseline, select cost mode, capture the footer before a prompt, submit one prompt, and capture baseline-plus-increment. Also exercise a legacy/cloud payload without the field and capture `Cost unavailable` with no `$0.00` placeholder. Attach a screenshot or short recording as verification proof; text-only capture is acceptable only when a real image/video cannot be produced and the limitation is recorded in the PR/task. This is TUI-specific proof via `tui-testing`/`tui-verify-change`, not the GUI `computer_use` harness. From 15ce07bed4af397006d9203a1055596fadc08371 Mon Sep 17 00:00:00 2001 From: Oz Date: Thu, 23 Jul 2026 23:35:48 +0000 Subject: [PATCH 03/10] feat: restore TUI provider cost baseline Co-Authored-By: Oz --- .../agent/api/convert_conversation_tests.rs | 3 + app/src/ai/agent/conversation.rs | 54 +++++++++--- app/src/ai/agent/conversation_tests.rs | 88 ++++++++++++++++++- app/src/ai/agent_conversations_model_tests.rs | 1 + app/src/ai/agent_sdk/artifact_upload_tests.rs | 1 + app/src/ai/blocklist/history_model_tests.rs | 1 + .../orchestration_event_streamer_tests.rs | 1 + .../ai/conversation_details_panel_tests.rs | 1 + app/src/pane_group/mod_tests.rs | 1 + .../cloud_conversation_continuation_tests.rs | 1 + .../view/shared_session/view_impl_tests.rs | 1 + crates/graphql/src/api/ai.rs | 2 + crates/graphql/src/api/ai_tests.rs | 3 + .../src/api/queries/get_conversation_usage.rs | 3 + crates/persistence/src/model.rs | 5 ++ crates/persistence/src/model_tests.rs | 35 +++++++- crates/warp_graphql_schema/api/schema.graphql | 2 + crates/warp_tui/src/terminal_session_view.rs | 14 +++ .../src/terminal_session_view_tests.rs | 6 +- crates/warp_tui/src/usage.rs | 5 +- crates/warp_tui/src/usage_tests.rs | 18 +++- 21 files changed, 225 insertions(+), 21 deletions(-) diff --git a/app/src/ai/agent/api/convert_conversation_tests.rs b/app/src/ai/agent/api/convert_conversation_tests.rs index 8680dcd8fc0..533b0d22906 100644 --- a/app/src/ai/agent/api/convert_conversation_tests.rs +++ b/app/src/ai/agent/api/convert_conversation_tests.rs @@ -27,6 +27,7 @@ fn test_server_metadata( context_window_usage: 0.0, credits_spent: 0.0, platform_credits_spent: 0.0, + total_provider_cost_in_cents: Some(3.2), credits_spent_for_last_block: None, token_usage: vec![], tool_usage_metadata: Default::default(), @@ -106,6 +107,8 @@ fn test_convert_conversation_data_to_ai_conversation_sets_restored_run_id() { conversation.run_id(), Some(ambient_agent_task_id.to_string()) ); + assert_eq!(conversation.usage_totals().cost_in_cents, Some(3.2)); + assert!(conversation.usage_totals().has_usage); } #[test] diff --git a/app/src/ai/agent/conversation.rs b/app/src/ai/agent/conversation.rs index 33356d19d79..74de1d1650e 100644 --- a/app/src/ai/agent/conversation.rs +++ b/app/src/ai/agent/conversation.rs @@ -183,11 +183,14 @@ pub struct ConversationUsageTotals { /// shows as "Credits spent (total)" and the conversation details panel /// shows as "Credits used". pub credits_spent: f32, - /// Total provider cost across all models, in US cents. Fractional — - /// per-request provider costs are routinely sub-cent — and `f32` to match - /// both the upstream `TokenUsage.cost_in_cents` proto float it sums and - /// `credits_spent` above. - pub cost_in_cents: f32, + /// Total provider cost across all models, in US cents. `None` means the + /// server did not provide a historical baseline; it must not be rendered + /// as `$0.00` or as an incremental-only total. + pub cost_in_cents: Option, + /// Whether the conversation has any usage metadata. This distinguishes a + /// new conversation with no usage (hide the footer entry) from a restored + /// legacy conversation whose historical cost is unknown. + pub has_usage: bool, } // basic info for creating a dummy command block based on an exchange's inputs @@ -315,6 +318,13 @@ pub struct AIConversation { total_request_cost: RequestCost, total_token_usage_by_model: HashMap, + /// Server-authoritative cumulative provider cost in US cents. New + /// conversations start at a known zero; restored legacy conversations can + /// remain `None` until a server snapshot is available. + total_provider_cost_in_cents: Option, + /// True once usage metadata has been hydrated or a live response reports + /// usage, even when all numeric totals are zero. + has_usage_metadata: bool, /// Fallback title used when no task description or initial query exists. fallback_display_title: Option, @@ -404,6 +414,8 @@ impl AIConversation { dismissed_suggestion_ids: Default::default(), total_request_cost: RequestCost::new(0.), total_token_usage_by_model: Default::default(), + total_provider_cost_in_cents: Some(0.), + has_usage_metadata: false, fallback_display_title: None, artifacts: Vec::new(), parent_agent_id: None, @@ -537,6 +549,7 @@ impl AIConversation { let ( server_conversation_token, forked_from_server_conversation_token, + has_usage_metadata, conversation_usage_metadata, reverted_action_ids, artifacts, @@ -553,6 +566,7 @@ impl AIConversation { let server_conversation_token = data .server_conversation_token .map(ServerConversationToken::new); + let has_usage_metadata = data.conversation_usage_metadata.is_some(); let conversation_usage_metadata = data.conversation_usage_metadata.unwrap_or_default(); let reverted_action_ids: HashSet = data .reverted_action_ids @@ -588,6 +602,7 @@ impl AIConversation { ( server_conversation_token, forked_from_server_conversation_token, + has_usage_metadata, conversation_usage_metadata, reverted_action_ids, artifacts, @@ -605,6 +620,7 @@ impl AIConversation { ( None, None, + false, ConversationUsageMetadata::default(), HashSet::new(), Vec::new(), @@ -645,6 +661,8 @@ impl AIConversation { dismissed_suggestion_ids: Default::default(), total_request_cost: RequestCost::new(0.), total_token_usage_by_model: Default::default(), + total_provider_cost_in_cents: conversation_usage_metadata.total_provider_cost_in_cents, + has_usage_metadata, optimistic_cli_subagent_subtask_id: None, fallback_display_title: None, artifacts, @@ -1085,6 +1103,12 @@ impl AIConversation { } pub fn set_server_metadata(&mut self, metadata: ServerAIConversationMetadata) { + if let Some(total_provider_cost_in_cents) = metadata.usage.total_provider_cost_in_cents { + self.total_provider_cost_in_cents = Some(total_provider_cost_in_cents); + self.conversation_usage_metadata + .total_provider_cost_in_cents = Some(total_provider_cost_in_cents); + self.has_usage_metadata = true; + } self.server_metadata = Some(metadata); } @@ -2143,7 +2167,12 @@ impl AIConversation { was_user_initiated_request: bool, ctx: &AppContext, ) -> Result<(), UpdateConversationError> { + self.has_usage_metadata |= + request_cost.is_some() || usage_metadata.is_some() || !token_usage.is_empty(); for usage in token_usage.into_iter() { + if let Some(total_provider_cost_in_cents) = self.total_provider_cost_in_cents.as_mut() { + *total_provider_cost_in_cents += usage.cost_in_cents; + } let entry = self .total_token_usage_by_model .entry(usage.model_id.clone()) @@ -2208,6 +2237,8 @@ impl AIConversation { self.conversation_usage_metadata.was_summarized = usage_metadata.summarized; } } + self.conversation_usage_metadata + .total_provider_cost_in_cents = self.total_provider_cost_in_cents; Ok(()) } @@ -3684,17 +3715,14 @@ impl AIConversation { } /// Compact usage totals for lightweight displays (e.g. the TUI footer's - /// usage entry): the GUI-consistent credits total plus the accumulated - /// provider dollar cost from the per-request `StreamFinished` usage rows. + /// usage entry): the GUI-consistent credits total plus the server-seeded + /// provider cost and any permitted live per-request deltas. pub fn usage_totals(&self) -> ConversationUsageTotals { - let mut totals = ConversationUsageTotals { + ConversationUsageTotals { credits_spent: self.inference_credits_spent() + self.platform_credits_spent(), - cost_in_cents: 0.0, - }; - for usage in self.total_token_usage_by_model.values() { - totals.cost_in_cents += usage.cost_in_cents; + cost_in_cents: self.total_provider_cost_in_cents, + has_usage: self.has_usage_metadata, } - totals } /// Normalize all newlines to CRLF so restored blocks render lines starting at column 0, diff --git a/app/src/ai/agent/conversation_tests.rs b/app/src/ai/agent/conversation_tests.rs index e6d3a00d98c..68871d2f3a3 100644 --- a/app/src/ai/agent/conversation_tests.rs +++ b/app/src/ai/agent/conversation_tests.rs @@ -15,7 +15,7 @@ use crate::ai::llms::LLMPreferences; use crate::auth::AuthStateProvider; use crate::auth::auth_manager::AuthManager; use crate::network::NetworkStatus; -use crate::persistence::model::AgentConversationData; +use crate::persistence::model::{AgentConversationData, ConversationUsageMetadata}; use crate::server::server_api::ServerApiProvider; use crate::test_util::settings::initialize_settings_for_tests; use crate::workspaces::user_workspaces::UserWorkspaces; @@ -36,6 +36,31 @@ fn restored_conversation(conversation_data: Option) -> AI .unwrap() } +fn conversation_data_with_provider_cost( + total_provider_cost_in_cents: Option, +) -> AgentConversationData { + AgentConversationData { + server_conversation_token: None, + conversation_usage_metadata: Some(ConversationUsageMetadata { + total_provider_cost_in_cents, + ..Default::default() + }), + reverted_action_ids: None, + forked_from_server_conversation_token: None, + artifacts_json: None, + parent_agent_id: None, + agent_name: None, + orchestration_harness_type: None, + parent_conversation_id: None, + is_remote_child: false, + root_task_is_optimistic: None, + run_id: None, + autoexecute_override: None, + last_event_sequence: None, + pinned: false, + } +} + fn restored_conversation_with_root_description(description: &str) -> AIConversation { AIConversation::new_restored( AIConversationId::new(), @@ -595,6 +620,52 @@ fn restored_conversation_with_empty_task_list_creates_in_progress_optimistic_roo assert_eq!(conversation.status(), &ConversationStatus::InProgress); assert!(conversation.status_error_message().is_none()); } +#[test] +fn restored_conversation_seeds_known_provider_cost_baseline() { + let conversation = restored_conversation(Some(conversation_data_with_provider_cost(Some(3.2)))); + let totals = conversation.usage_totals(); + + assert_eq!(totals.cost_in_cents, Some(3.2)); + assert!(totals.has_usage); +} +#[test] +fn empty_task_restore_seeds_known_provider_cost_baseline() { + let conversation = AIConversation::new_restored_synthesizing_on_empty( + AIConversationId::new(), + vec![], + Some(conversation_data_with_provider_cost(Some(3.2))), + ) + .expect("empty-task restore should synthesize a root"); + + assert_eq!(conversation.usage_totals().cost_in_cents, Some(3.2)); + assert!(conversation.usage_totals().has_usage); +} + +#[test] +fn restored_legacy_conversation_keeps_provider_cost_unavailable_after_follow_up() { + App::test((), |mut app| async move { + initialize_custom_endpoint_usage_test_app(&mut app); + app.add_singleton_model(LLMPreferences::new); + + let mut conversation = + restored_conversation(Some(conversation_data_with_provider_cost(None))); + app.read(|ctx| { + conversation + .update_cost_and_usage_for_request( + None, + vec![stream_token_usage("legacy-model", 10, 2, 1.5)], + Some(credits_usage_metadata(1.0, 0.0)), + false, + ctx, + ) + .expect("follow-up usage should update"); + }); + + let totals = conversation.usage_totals(); + assert_eq!(totals.cost_in_cents, None); + assert!(totals.has_usage); + }); +} #[test] fn update_cost_and_usage_resolves_custom_endpoint_alias_for_footer_usage() { @@ -727,7 +798,11 @@ fn usage_totals_reads_gui_credits_and_accumulates_provider_cost() { let mut conversation = AIConversation::new(false, false); assert_eq!( conversation.usage_totals(), - ConversationUsageTotals::default() + ConversationUsageTotals { + credits_spent: 0.0, + cost_in_cents: Some(0.0), + has_usage: false, + } ); app.read(|ctx| { @@ -756,7 +831,14 @@ fn usage_totals_reads_gui_credits_and_accumulates_provider_cost() { let totals = conversation.usage_totals(); assert!((totals.credits_spent - 3.5).abs() < 1e-6); - assert!((totals.cost_in_cents - 2.7).abs() < 1e-6); + assert!( + (totals + .cost_in_cents + .expect("new conversation cost is known") + - 2.7) + .abs() + < 1e-6 + ); }); } diff --git a/app/src/ai/agent_conversations_model_tests.rs b/app/src/ai/agent_conversations_model_tests.rs index 7dfedb76a8e..a8c07dbeaa8 100644 --- a/app/src/ai/agent_conversations_model_tests.rs +++ b/app/src/ai/agent_conversations_model_tests.rs @@ -924,6 +924,7 @@ fn create_server_conversation_metadata( context_window_usage: 0.0, credits_spent: 0.0, platform_credits_spent: 0.0, + total_provider_cost_in_cents: None, credits_spent_for_last_block: None, token_usage: vec![], tool_usage_metadata: Default::default(), diff --git a/app/src/ai/agent_sdk/artifact_upload_tests.rs b/app/src/ai/agent_sdk/artifact_upload_tests.rs index 160de66e0c1..f0200bee7fe 100644 --- a/app/src/ai/agent_sdk/artifact_upload_tests.rs +++ b/app/src/ai/agent_sdk/artifact_upload_tests.rs @@ -39,6 +39,7 @@ fn create_conversation_metadata( context_window_usage: 0.0, credits_spent: 0.0, platform_credits_spent: 0.0, + total_provider_cost_in_cents: None, credits_spent_for_last_block: None, token_usage: vec![], tool_usage_metadata: Default::default(), diff --git a/app/src/ai/blocklist/history_model_tests.rs b/app/src/ai/blocklist/history_model_tests.rs index 03fe374d44c..853022c9421 100644 --- a/app/src/ai/blocklist/history_model_tests.rs +++ b/app/src/ai/blocklist/history_model_tests.rs @@ -1273,6 +1273,7 @@ fn create_server_metadata( context_window_usage: 0.0, credits_spent, platform_credits_spent: 0.0, + total_provider_cost_in_cents: None, credits_spent_for_last_block: None, token_usage: vec![], tool_usage_metadata: Default::default(), diff --git a/app/src/ai/blocklist/orchestration_event_streamer_tests.rs b/app/src/ai/blocklist/orchestration_event_streamer_tests.rs index 2d70ed7719b..a24327d3d43 100644 --- a/app/src/ai/blocklist/orchestration_event_streamer_tests.rs +++ b/app/src/ai/blocklist/orchestration_event_streamer_tests.rs @@ -237,6 +237,7 @@ fn make_server_metadata_with_harness( context_window_usage: 0.0, credits_spent: 0.0, platform_credits_spent: 0.0, + total_provider_cost_in_cents: None, credits_spent_for_last_block: None, token_usage: vec![], tool_usage_metadata: Default::default(), diff --git a/app/src/ai/conversation_details_panel_tests.rs b/app/src/ai/conversation_details_panel_tests.rs index 197df19bb7d..7b50031ec31 100644 --- a/app/src/ai/conversation_details_panel_tests.rs +++ b/app/src/ai/conversation_details_panel_tests.rs @@ -184,6 +184,7 @@ fn create_test_server_metadata( context_window_usage: 0.0, credits_spent: 0.0, platform_credits_spent: 0.0, + total_provider_cost_in_cents: None, credits_spent_for_last_block: None, token_usage: vec![], tool_usage_metadata: Default::default(), diff --git a/app/src/pane_group/mod_tests.rs b/app/src/pane_group/mod_tests.rs index c0355065328..8b4ea9cbc55 100644 --- a/app/src/pane_group/mod_tests.rs +++ b/app/src/pane_group/mod_tests.rs @@ -352,6 +352,7 @@ fn test_server_conversation_metadata( context_window_usage: 0.0, credits_spent: 0.0, platform_credits_spent: 0.0, + total_provider_cost_in_cents: None, credits_spent_for_last_block: None, token_usage: vec![], tool_usage_metadata: Default::default(), diff --git a/app/src/terminal/view/shared_session/cloud_conversation_continuation_tests.rs b/app/src/terminal/view/shared_session/cloud_conversation_continuation_tests.rs index 193003af7c9..e92ba2c121a 100644 --- a/app/src/terminal/view/shared_session/cloud_conversation_continuation_tests.rs +++ b/app/src/terminal/view/shared_session/cloud_conversation_continuation_tests.rs @@ -277,6 +277,7 @@ fn server_conversation_metadata( context_window_usage: 0.0, credits_spent: 0.0, platform_credits_spent: 0.0, + total_provider_cost_in_cents: None, credits_spent_for_last_block: None, token_usage: vec![], tool_usage_metadata: Default::default(), diff --git a/app/src/terminal/view/shared_session/view_impl_tests.rs b/app/src/terminal/view/shared_session/view_impl_tests.rs index 97b141b20a3..9b28946ad86 100644 --- a/app/src/terminal/view/shared_session/view_impl_tests.rs +++ b/app/src/terminal/view/shared_session/view_impl_tests.rs @@ -752,6 +752,7 @@ fn server_conversation_metadata( context_window_usage: 0.0, credits_spent: 0.0, platform_credits_spent: 0.0, + total_provider_cost_in_cents: None, credits_spent_for_last_block: None, token_usage: vec![], tool_usage_metadata: Default::default(), diff --git a/crates/graphql/src/api/ai.rs b/crates/graphql/src/api/ai.rs index 1e0df2be2aa..75c4a669a36 100644 --- a/crates/graphql/src/api/ai.rs +++ b/crates/graphql/src/api/ai.rs @@ -171,6 +171,7 @@ pub struct ConversationUsageMetadata { pub context_window_segments: Vec, pub credits_spent: f64, pub platform_credits_spent: f64, + pub total_provider_cost_in_cents: Option, pub summarized: bool, pub warp_token_usage: Vec, pub byok_token_usage: Vec, @@ -184,6 +185,7 @@ impl From<&ConversationUsageMetadata> for persistence::model::ConversationUsageM context_window_usage: gql.context_window_usage as f32, credits_spent: gql.credits_spent as f32, platform_credits_spent: gql.platform_credits_spent as f32, + total_provider_cost_in_cents: gql.total_provider_cost_in_cents.map(|cost| cost as f32), credits_spent_for_last_block: None, token_usage: convert_token_usage(&gql.warp_token_usage, &gql.byok_token_usage), tool_usage_metadata: (&gql.tool_usage_metadata).into(), diff --git a/crates/graphql/src/api/ai_tests.rs b/crates/graphql/src/api/ai_tests.rs index 08045724362..e3d632f6935 100644 --- a/crates/graphql/src/api/ai_tests.rs +++ b/crates/graphql/src/api/ai_tests.rs @@ -45,6 +45,7 @@ fn conversion_populates_token_usage_and_tool_usage_metadata() { }], credits_spent: 12.5, platform_credits_spent: 2.5, + total_provider_cost_in_cents: Some(3.2), summarized: true, warp_token_usage: vec![TokenUsage { model_id: "claude-4-7-opus-high".to_string(), @@ -77,6 +78,7 @@ fn conversion_populates_token_usage_and_tool_usage_metadata() { assert_eq!(converted.context_window_usage, 0.42); assert_eq!(converted.credits_spent, 12.5); assert_eq!(converted.platform_credits_spent, 2.5); + assert_eq!(converted.total_provider_cost_in_cents, Some(3.2)); assert_eq!(converted.credits_spent_for_last_block, None); // Token usage is sorted by model id, with warp and byok rows kept in @@ -126,6 +128,7 @@ fn conversion_merges_warp_and_byok_usage_for_same_model() { context_window_segments: vec![], credits_spent: 0.0, platform_credits_spent: 0.0, + total_provider_cost_in_cents: None, summarized: false, warp_token_usage: vec![TokenUsage { model_id: "claude-4-7-opus-high".to_string(), diff --git a/crates/graphql/src/api/queries/get_conversation_usage.rs b/crates/graphql/src/api/queries/get_conversation_usage.rs index c43fbf8c315..280927d26ae 100644 --- a/crates/graphql/src/api/queries/get_conversation_usage.rs +++ b/crates/graphql/src/api/queries/get_conversation_usage.rs @@ -26,6 +26,7 @@ query GetConversationUsage( contextWindowUsage creditsSpent platformCreditsSpent + totalProviderCostInCents summarized tokenUsage { modelId totalTokens } warpTokenUsage { modelId totalTokens tokenUsageByCategory { category tokens } } @@ -117,6 +118,7 @@ pub struct ConversationUsageMetadata { pub context_window_segments: Vec, pub credits_spent: f64, pub platform_credits_spent: f64, + pub total_provider_cost_in_cents: Option, pub summarized: bool, pub token_usage: Vec, pub warp_token_usage: Vec, @@ -194,6 +196,7 @@ impl From<&ConversationUsageMetadata> for persistence::model::ConversationUsageM context_window_usage: gql.context_window_usage as f32, credits_spent: gql.credits_spent as f32, platform_credits_spent: gql.platform_credits_spent as f32, + total_provider_cost_in_cents: gql.total_provider_cost_in_cents.map(|cost| cost as f32), credits_spent_for_last_block: None, token_usage: convert_token_usage(&gql.warp_token_usage, &gql.byok_token_usage), tool_usage_metadata: (&gql.tool_usage_metadata).into(), diff --git a/crates/persistence/src/model.rs b/crates/persistence/src/model.rs index 7ec7bfb7d0a..31a42727cda 100644 --- a/crates/persistence/src/model.rs +++ b/crates/persistence/src/model.rs @@ -1612,6 +1612,11 @@ pub struct ConversationUsageMetadata { pub credits_spent: f32, #[serde(default)] pub platform_credits_spent: f32, + /// Server-authoritative cumulative provider cost in US cents. `None` + /// means the server did not provide a historical cost (for example, a + /// legacy conversation); it must not be treated as numeric zero. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub total_provider_cost_in_cents: Option, #[serde(default)] pub credits_spent_for_last_block: Option, #[serde(default)] diff --git a/crates/persistence/src/model_tests.rs b/crates/persistence/src/model_tests.rs index 0ac1fc5617e..58903dc7f89 100644 --- a/crates/persistence/src/model_tests.rs +++ b/crates/persistence/src/model_tests.rs @@ -2,7 +2,10 @@ use std::collections::HashMap; use warp_multi_agent_api as api; -use super::{AgentConversation, AgentConversationData, AgentConversationSummary, ModelTokenUsage}; +use super::{ + AgentConversation, AgentConversationData, AgentConversationSummary, ConversationUsageMetadata, + ModelTokenUsage, +}; fn parentless_task(id: &str, message_count: usize) -> api::Task { api::Task { @@ -102,6 +105,36 @@ fn is_restorable_accepts_empty_and_single_task_conversations() { assert!(conversation_with_tasks(vec![parentless_task("root", 0)]).is_restorable()); } +#[test] +fn conversation_usage_metadata_defaults_missing_provider_cost_to_unknown() { + let metadata: ConversationUsageMetadata = serde_json::from_str( + r#"{"was_summarized":false,"context_window_usage":0.0,"credits_spent":0.0}"#, + ) + .unwrap(); + + assert_eq!(metadata.total_provider_cost_in_cents, None); + assert!( + !serde_json::to_string(&metadata) + .unwrap() + .contains("total_provider_cost_in_cents") + ); +} + +#[test] +fn conversation_usage_metadata_preserves_known_zero_provider_cost() { + let metadata: ConversationUsageMetadata = serde_json::from_str( + r#"{"was_summarized":false,"context_window_usage":0.0,"credits_spent":0.0,"total_provider_cost_in_cents":0.0}"#, + ) + .unwrap(); + + assert_eq!(metadata.total_provider_cost_in_cents, Some(0.0)); + assert!( + serde_json::to_string(&metadata) + .unwrap() + .contains("\"total_provider_cost_in_cents\":0.0") + ); +} + fn user_query_message(task_id: &str, query: &str, pwd: Option<&str>) -> api::Message { let context = pwd.map(|pwd| api::InputContext { directory: Some(api::input_context::Directory { diff --git a/crates/warp_graphql_schema/api/schema.graphql b/crates/warp_graphql_schema/api/schema.graphql index 1dff585bc2d..0293e26f128 100644 --- a/crates/warp_graphql_schema/api/schema.graphql +++ b/crates/warp_graphql_schema/api/schema.graphql @@ -855,6 +855,8 @@ type ConversationUsageMetadata { """The total number of platform credits spent so far in the conversation.""" platformCreditsSpent: Float! + """The cumulative provider cost in US cents, when available.""" + totalProviderCostInCents: Float summarized: Boolean! """ diff --git a/crates/warp_tui/src/terminal_session_view.rs b/crates/warp_tui/src/terminal_session_view.rs index 7b33b38da64..cd4a017112e 100644 --- a/crates/warp_tui/src/terminal_session_view.rs +++ b/crates/warp_tui/src/terminal_session_view.rs @@ -3347,6 +3347,20 @@ impl TuiTerminalSessionView { }); } + /// The selected conversation's accumulated usage totals, or `None` (entry + /// hidden) until any usage has been reported. + fn selected_conversation_usage_totals( + &self, + ctx: &AppContext, + ) -> Option { + let totals = self + .conversation_selection + .as_ref(ctx) + .selected_conversation(ctx)? + .usage_totals(); + totals.has_usage.then_some(totals) + } + /// The session's working directory. The cwd only arrives once shell /// metadata flows (warpified sessions); until then fall back to the /// process cwd the TUI's shell was spawned with. diff --git a/crates/warp_tui/src/terminal_session_view_tests.rs b/crates/warp_tui/src/terminal_session_view_tests.rs index 99ec2734e45..c1ba454f375 100644 --- a/crates/warp_tui/src/terminal_session_view_tests.rs +++ b/crates/warp_tui/src/terminal_session_view_tests.rs @@ -1855,7 +1855,8 @@ fn response_summary_visibility_is_independent_from_the_footer_usage_mode() { let totals = ConversationUsageTotals { credits_spent: 2.5, - cost_in_cents: 3.2, + cost_in_cents: Some(3.2), + has_usage: true, }; assert_eq!( @@ -3960,7 +3961,8 @@ fn footer_renders_agent_sections_left_aligned() { TuiUsageDisplayMode::default(), ConversationUsageTotals { credits_spent: 2.5, - cost_in_cents: 0.0, + cost_in_cents: Some(0.0), + has_usage: true, }, ctx, |_, _| {}, diff --git a/crates/warp_tui/src/usage.rs b/crates/warp_tui/src/usage.rs index bc943b2ce0d..8c9db78f614 100644 --- a/crates/warp_tui/src/usage.rs +++ b/crates/warp_tui/src/usage.rs @@ -66,7 +66,10 @@ impl UsageToggle { fn entry_text(mode: TuiUsageDisplayMode, totals: ConversationUsageTotals) -> String { match mode { TuiUsageDisplayMode::Credits => format_credits(totals.credits_spent), - TuiUsageDisplayMode::Cost => format_cost(totals.cost_in_cents), + TuiUsageDisplayMode::Cost => totals + .cost_in_cents + .map(format_cost) + .unwrap_or_else(|| "Cost unavailable".to_owned()), } } diff --git a/crates/warp_tui/src/usage_tests.rs b/crates/warp_tui/src/usage_tests.rs index 46a5d4137b0..47a08680d43 100644 --- a/crates/warp_tui/src/usage_tests.rs +++ b/crates/warp_tui/src/usage_tests.rs @@ -6,7 +6,8 @@ use super::*; fn totals(credits_spent: f32, cost_in_cents: f32) -> ConversationUsageTotals { ConversationUsageTotals { credits_spent, - cost_in_cents, + cost_in_cents: Some(cost_in_cents), + has_usage: true, } } @@ -41,3 +42,18 @@ fn entry_text_follows_the_persisted_display_mode() { "2.5 credits" ); } + +#[test] +fn cost_mode_explicitly_marks_unknown_historical_cost() { + assert_eq!( + entry_text( + TuiUsageDisplayMode::Cost, + ConversationUsageTotals { + credits_spent: 0.0, + cost_in_cents: None, + has_usage: true, + }, + ), + "Cost unavailable" + ); +} From 70e49db691fb6b0d79812f506344f23f7ae4706b Mon Sep 17 00:00:00 2001 From: Oz Date: Fri, 24 Jul 2026 03:01:09 +0000 Subject: [PATCH 04/10] fix: preserve restored provider cost before metadata move Co-Authored-By: Oz --- app/src/ai/agent/conversation.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/src/ai/agent/conversation.rs b/app/src/ai/agent/conversation.rs index 74de1d1650e..60f64d7922f 100644 --- a/app/src/ai/agent/conversation.rs +++ b/app/src/ai/agent/conversation.rs @@ -635,6 +635,7 @@ impl AIConversation { false, ) }; + let total_provider_cost_in_cents = conversation_usage_metadata.total_provider_cost_in_cents; Ok(Self { id, @@ -661,7 +662,7 @@ impl AIConversation { dismissed_suggestion_ids: Default::default(), total_request_cost: RequestCost::new(0.), total_token_usage_by_model: Default::default(), - total_provider_cost_in_cents: conversation_usage_metadata.total_provider_cost_in_cents, + total_provider_cost_in_cents, has_usage_metadata, optimistic_cli_subagent_subtask_id: None, fallback_display_title: None, From d6ae9f867bb6f35b6e909216e5945d8e0524cca7 Mon Sep 17 00:00:00 2001 From: Ian Hodge Date: Wed, 29 Jul 2026 16:07:39 -0400 Subject: [PATCH 05/10] fix: derive footer usage evidence from metadata contents; add APP-4952 regression test - Add the spec-mandated regression test for the ticket's exact failing sequence: a restored 3.2-cent server baseline plus a 1.2-cent follow-up must display 4.4 cents, covering both restore constructors. - Derive has_usage from the usage metadata's contents instead of its mere presence. Local persistence always writes a metadata blob, so restored zero-usage conversations previously surfaced a misleading footer entry ("Cost unavailable" / 0.0 credits) that used to be hidden. - Make set_server_metadata consistent: usage evidence comes from the snapshot's contents, and an absent provider-cost field never erases a known baseline. - Add view-level TUI tests: the footer usage entry stays hidden until a conversation reports usage, and unknown historical cost renders 'Cost unavailable' (never $0.00) even with zero credits. Co-Authored-By: Oz --- .../agent/api/convert_conversation_tests.rs | 68 +++++++++++++++ app/src/ai/agent/conversation.rs | 41 +++++++-- app/src/ai/agent/conversation_tests.rs | 82 ++++++++++++++++++ .../src/terminal_session_view_tests.rs | 83 +++++++++++++++++++ 4 files changed, 267 insertions(+), 7 deletions(-) diff --git a/app/src/ai/agent/api/convert_conversation_tests.rs b/app/src/ai/agent/api/convert_conversation_tests.rs index 533b0d22906..30382f34bef 100644 --- a/app/src/ai/agent/api/convert_conversation_tests.rs +++ b/app/src/ai/agent/api/convert_conversation_tests.rs @@ -111,6 +111,74 @@ fn test_convert_conversation_data_to_ai_conversation_sets_restored_run_id() { assert!(conversation.usage_totals().has_usage); } +/// A later server-metadata snapshot without the provider-cost field (legacy +/// server or conversation) must not erase a known baseline, and usage +/// evidence must be derived from the metadata's contents. +#[test] +#[allow(deprecated)] +fn set_server_metadata_keeps_known_baseline_when_cost_field_is_absent() { + let conversation_data = api::ConversationData { + tasks: vec![api::Task { + id: "root".to_string(), + messages: vec![], + dependencies: None, + description: String::new(), + summary: String::new(), + server_data: String::new(), + }], + ordered_message_ids: vec![], + }; + let mut conversation = convert_conversation_data_to_ai_conversation( + AIConversationId::new(), + &conversation_data, + test_server_metadata("server-token", None), + RestorationMode::Continue, + ) + .expect("conversation should restore"); + assert_eq!(conversation.usage_totals().cost_in_cents, Some(3.2)); + + let mut legacy_snapshot = test_server_metadata("server-token", None); + legacy_snapshot.usage.total_provider_cost_in_cents = None; + legacy_snapshot.usage.credits_spent = 2.0; + conversation.set_server_metadata(legacy_snapshot); + + let totals = conversation.usage_totals(); + assert_eq!(totals.cost_in_cents, Some(3.2)); + assert!(totals.has_usage); +} + +/// A server-metadata snapshot whose usage contents are all-default carries no +/// usage evidence, so the footer's usage entry stays hidden. +#[test] +#[allow(deprecated)] +fn set_server_metadata_with_zero_usage_keeps_footer_usage_hidden() { + let conversation_data = api::ConversationData { + tasks: vec![api::Task { + id: "root".to_string(), + messages: vec![], + dependencies: None, + description: String::new(), + summary: String::new(), + server_data: String::new(), + }], + ordered_message_ids: vec![], + }; + let mut zero_usage_metadata = test_server_metadata("server-token", None); + zero_usage_metadata.usage.total_provider_cost_in_cents = None; + + let conversation = convert_conversation_data_to_ai_conversation( + AIConversationId::new(), + &conversation_data, + zero_usage_metadata, + RestorationMode::Continue, + ) + .expect("conversation should restore"); + + let totals = conversation.usage_totals(); + assert!(!totals.has_usage); + assert_eq!(totals.cost_in_cents, None); +} + #[test] fn test_convert_tool_call_result_to_input_transfer_control_snapshot() { let task_id = crate::ai::agent::task::TaskId::new("task".to_string()); diff --git a/app/src/ai/agent/conversation.rs b/app/src/ai/agent/conversation.rs index 60f64d7922f..3e2cb5a70dc 100644 --- a/app/src/ai/agent/conversation.rs +++ b/app/src/ai/agent/conversation.rs @@ -187,12 +187,30 @@ pub struct ConversationUsageTotals { /// server did not provide a historical baseline; it must not be rendered /// as `$0.00` or as an incremental-only total. pub cost_in_cents: Option, - /// Whether the conversation has any usage metadata. This distinguishes a - /// new conversation with no usage (hide the footer entry) from a restored - /// legacy conversation whose historical cost is unknown. + /// Whether the conversation has reported any usage. Derived from the + /// contents of the usage metadata (not its mere presence), so a restored + /// conversation that never ran a request keeps the footer entry hidden, + /// while a restored legacy conversation with real usage but an unknown + /// historical cost still shows it. pub has_usage: bool, } +/// Whether persisted or server usage metadata carries evidence that the +/// conversation actually incurred usage. Metadata presence alone is not +/// enough: the local persistence path always writes a (possibly all-default) +/// metadata blob, and a restored conversation that never ran a request must +/// keep the footer's usage entry hidden. +fn usage_metadata_indicates_usage(metadata: &ConversationUsageMetadata) -> bool { + metadata.credits_spent != 0.0 + || metadata.platform_credits_spent != 0.0 + || metadata + .total_provider_cost_in_cents + .is_some_and(|cost| cost != 0.0) + || !metadata.token_usage.is_empty() + || metadata.context_window_usage != 0.0 + || metadata.was_summarized +} + // basic info for creating a dummy command block based on an exchange's inputs pub(crate) struct CommandBlockInfo { pub(crate) command: String, @@ -322,8 +340,9 @@ pub struct AIConversation { /// conversations start at a known zero; restored legacy conversations can /// remain `None` until a server snapshot is available. total_provider_cost_in_cents: Option, - /// True once usage metadata has been hydrated or a live response reports - /// usage, even when all numeric totals are zero. + /// True once hydrated usage metadata shows evidence of usage (see + /// [`usage_metadata_indicates_usage`]) or a live response reports usage + /// (even when its numeric totals are zero). has_usage_metadata: bool, /// Fallback title used when no task description or initial query exists. @@ -566,7 +585,10 @@ impl AIConversation { let server_conversation_token = data .server_conversation_token .map(ServerConversationToken::new); - let has_usage_metadata = data.conversation_usage_metadata.is_some(); + let has_usage_metadata = data + .conversation_usage_metadata + .as_ref() + .is_some_and(usage_metadata_indicates_usage); let conversation_usage_metadata = data.conversation_usage_metadata.unwrap_or_default(); let reverted_action_ids: HashSet = data .reverted_action_ids @@ -1104,12 +1126,17 @@ impl AIConversation { } pub fn set_server_metadata(&mut self, metadata: ServerAIConversationMetadata) { + // An absent field (legacy server or conversation) must not erase a + // known baseline. if let Some(total_provider_cost_in_cents) = metadata.usage.total_provider_cost_in_cents { self.total_provider_cost_in_cents = Some(total_provider_cost_in_cents); self.conversation_usage_metadata .total_provider_cost_in_cents = Some(total_provider_cost_in_cents); - self.has_usage_metadata = true; } + // Usage evidence is derived from the metadata's contents (not its + // presence) so a zero-usage conversation keeps the footer entry + // hidden. + self.has_usage_metadata |= usage_metadata_indicates_usage(&metadata.usage); self.server_metadata = Some(metadata); } diff --git a/app/src/ai/agent/conversation_tests.rs b/app/src/ai/agent/conversation_tests.rs index 68871d2f3a3..9748423cf29 100644 --- a/app/src/ai/agent/conversation_tests.rs +++ b/app/src/ai/agent/conversation_tests.rs @@ -641,6 +641,88 @@ fn empty_task_restore_seeds_known_provider_cost_baseline() { assert!(conversation.usage_totals().has_usage); } +/// APP-4952 regression: the ticket's confirmed failing sequence. A restored +/// conversation with a known 3.2¢ server baseline plus a 1.2¢ follow-up must +/// display 4.4¢ — never 0.0¢ (dropped baseline) or 1.2¢ (increment only). +/// Covers both the strict and the lenient restore constructor. +#[test] +fn restored_usage_totals_preserve_server_provider_cost_and_add_follow_up() { + App::test((), |mut app| async move { + initialize_custom_endpoint_usage_test_app(&mut app); + app.add_singleton_model(LLMPreferences::new); + + let strict_restore = + restored_conversation(Some(conversation_data_with_provider_cost(Some(3.2)))); + let lenient_restore = AIConversation::new_restored_synthesizing_on_empty( + AIConversationId::new(), + vec![], + Some(conversation_data_with_provider_cost(Some(3.2))), + ) + .expect("empty-task restore should synthesize a root"); + + for mut conversation in [strict_restore, lenient_restore] { + app.read(|ctx| { + conversation + .update_cost_and_usage_for_request( + None, + vec![stream_token_usage("model-a", 10, 2, 1.2)], + Some(credits_usage_metadata(1.0, 0.0)), + false, + ctx, + ) + .expect("follow-up usage should update"); + }); + + let totals = conversation.usage_totals(); + let cost = totals + .cost_in_cents + .expect("a restored known baseline stays known"); + assert!( + (cost - 4.4).abs() < 1e-6, + "3.2¢ baseline + 1.2¢ follow-up must total 4.4¢, got {cost}" + ); + assert!(totals.has_usage); + } + }); +} + +/// A restored conversation whose persisted metadata shows no usage evidence +/// must keep the footer's usage entry hidden — local persistence always +/// writes a metadata blob, so presence alone is not usage. +#[test] +fn restored_zero_usage_metadata_keeps_footer_usage_hidden() { + let conversation = restored_conversation(Some(conversation_data_with_provider_cost(None))); + + let totals = conversation.usage_totals(); + assert!(!totals.has_usage); + assert_eq!(totals.cost_in_cents, None); +} + +#[test] +fn restored_known_zero_cost_without_other_usage_keeps_footer_usage_hidden() { + let conversation = + restored_conversation(Some(conversation_data_with_provider_cost(Some(0.0)))); + + let totals = conversation.usage_totals(); + assert!(!totals.has_usage); + assert_eq!(totals.cost_in_cents, Some(0.0)); +} + +#[test] +fn restored_metadata_with_credits_marks_usage_even_without_provider_cost() { + let conversation = restored_conversation(Some(AgentConversationData { + conversation_usage_metadata: Some(ConversationUsageMetadata { + credits_spent: 2.5, + ..Default::default() + }), + ..conversation_data_with_provider_cost(None) + })); + + let totals = conversation.usage_totals(); + assert!(totals.has_usage); + assert_eq!(totals.cost_in_cents, None); +} + #[test] fn restored_legacy_conversation_keeps_provider_cost_unavailable_after_follow_up() { App::test((), |mut app| async move { diff --git a/crates/warp_tui/src/terminal_session_view_tests.rs b/crates/warp_tui/src/terminal_session_view_tests.rs index c1ba454f375..120f824e2c6 100644 --- a/crates/warp_tui/src/terminal_session_view_tests.rs +++ b/crates/warp_tui/src/terminal_session_view_tests.rs @@ -4006,6 +4006,89 @@ fn footer_renders_agent_sections_left_aligned() { }); } +/// The footer's usage entry is gated on `has_usage`: a freshly started +/// conversation that has not reported any usage yet must not surface totals. +#[test] +fn footer_usage_entry_is_hidden_until_the_conversation_reports_usage() { + App::test((), |mut app| async move { + let fixture = focus_test_fixture(&mut app); + let (view, _) = add_focus_test_session(&mut app, &fixture, true); + + view.update(&mut app, |view, ctx| { + view.conversation_selection.update(ctx, |selection, ctx| { + selection + .try_start_new_conversation(AgentViewEntryOrigin::Tui, ctx) + .expect("test conversation should start"); + }); + }); + + view.read(&app, |view, ctx| { + assert!( + view.conversation_selection + .as_ref(ctx) + .selected_conversation(ctx) + .is_some(), + "a conversation must be selected for the gate to be meaningful" + ); + assert!( + view.selected_conversation_usage_totals(ctx).is_none(), + "a conversation without usage must not surface footer totals" + ); + }); + }); +} + +/// A conversation with usage but an unknown historical cost (legacy restore) +/// still renders the usage entry — `Cost unavailable` in cost mode, never +/// `$0.00` — even when credits happen to be zero. +#[test] +fn footer_usage_entry_shows_unknown_cost_even_with_zero_credits() { + App::test((), |mut app| async move { + app.update(|ctx| { + ctx.add_singleton_model(|_| Appearance::mock()); + let builder = TuiUiBuilder::from_app(ctx); + let usage = UsageToggle::default().render_entry( + TuiUsageDisplayMode::Cost, + ConversationUsageTotals { + credits_spent: 0.0, + cost_in_cents: None, + has_usage: true, + }, + ctx, + |_, _| {}, + ); + let row = render_status_footer_row( + FooterSegments { + shell_mode: false, + model_label: Some( + TuiText::new("TestModel") + .with_style(builder.primary_text_style()) + .truncate() + .finish(), + ), + cwd: None, + branch: None, + usage: Some(usage), + diff_additions: 0, + diff_deletions: 0, + }, + &builder, + ) + .finish(); + let line = render_element(row, ctx, 80).to_lines().join("\n"); + + assert!( + line.contains("Cost unavailable"), + "unknown historical cost renders the stable fallback text, got: {line}" + ); + assert!( + !line.contains("$0.00"), + "unknown cost must never render as $0.00, got: {line}" + ); + }); + }); +} + #[test] fn footer_does_not_render_credit_actions() { App::test((), |mut app| async move { From 918ba3843c89567548f4dca0908864894981f4c8 Mon Sep 17 00:00:00 2001 From: Ian Hodge Date: Wed, 29 Jul 2026 16:21:24 -0400 Subject: [PATCH 06/10] feat: reconcile provider cost with server cumulative snapshots Bump warp_multi_agent_api to the proto rev that models StreamFinished's total_provider_cost_in_cents as google.protobuf.FloatValue, giving the Rust binding explicit presence (Option) so an absent field is distinguishable from a known zero. - StreamFinished reconciliation: when a response reports the server's cumulative provider cost, that snapshot is authoritative for the turn. It replaces local accumulation (the snapshot already includes the response's per-request costs, so nothing is counted twice) and recovers a restored legacy conversation's unknown baseline. Without the field (older server), per-request costs accumulate onto a known baseline as before. - set_server_metadata ordering guard: asynchronous GraphQL metadata snapshots may only seed or advance the displayed total, never regress it or re-add costs the stream already counted. - Shared-session hosts now forward the cumulative provider cost in the synthetic StreamFinished events they replay to viewers. - Tests: snapshot-supersedes-local (never 5.6 cents), unknown-baseline recovery via stream snapshot, stale-GraphQL-snapshot no-regression, and absent-field fallback to local accumulation (existing regression test). Co-Authored-By: Oz --- .../agent/api/convert_conversation_tests.rs | 41 ++++++++++ app/src/ai/agent/conversation.rs | 33 +++++++- app/src/ai/agent/conversation_tests.rs | 75 ++++++++++++++++++- .../ai/blocklist/controller/shared_session.rs | 1 + .../replay_agent_conversations.rs | 1 + 5 files changed, 145 insertions(+), 6 deletions(-) diff --git a/app/src/ai/agent/api/convert_conversation_tests.rs b/app/src/ai/agent/api/convert_conversation_tests.rs index 30382f34bef..3c048e417cc 100644 --- a/app/src/ai/agent/api/convert_conversation_tests.rs +++ b/app/src/ai/agent/api/convert_conversation_tests.rs @@ -147,6 +147,47 @@ fn set_server_metadata_keeps_known_baseline_when_cost_field_is_absent() { assert!(totals.has_usage); } +/// Asynchronous GraphQL metadata snapshots can be stale relative to live +/// stream accounting: a snapshot may seed or advance the known total but +/// never regress it. +#[test] +#[allow(deprecated)] +fn stale_server_metadata_snapshot_never_regresses_known_total() { + let conversation_data = api::ConversationData { + tasks: vec![api::Task { + id: "root".to_string(), + messages: vec![], + dependencies: None, + description: String::new(), + summary: String::new(), + server_data: String::new(), + }], + ordered_message_ids: vec![], + }; + let mut conversation = convert_conversation_data_to_ai_conversation( + AIConversationId::new(), + &conversation_data, + test_server_metadata("server-token", None), + RestorationMode::Continue, + ) + .expect("conversation should restore"); + assert_eq!(conversation.usage_totals().cost_in_cents, Some(3.2)); + + let mut newer_snapshot = test_server_metadata("server-token", None); + newer_snapshot.usage.total_provider_cost_in_cents = Some(4.4); + conversation.set_server_metadata(newer_snapshot); + assert_eq!(conversation.usage_totals().cost_in_cents, Some(4.4)); + + let mut stale_snapshot = test_server_metadata("server-token", None); + stale_snapshot.usage.total_provider_cost_in_cents = Some(3.2); + conversation.set_server_metadata(stale_snapshot); + assert_eq!( + conversation.usage_totals().cost_in_cents, + Some(4.4), + "a stale snapshot must never regress the displayed total" + ); +} + /// A server-metadata snapshot whose usage contents are all-default carries no /// usage evidence, so the footer's usage entry stays hidden. #[test] diff --git a/app/src/ai/agent/conversation.rs b/app/src/ai/agent/conversation.rs index 3e2cb5a70dc..fe6ab9a98c8 100644 --- a/app/src/ai/agent/conversation.rs +++ b/app/src/ai/agent/conversation.rs @@ -1127,8 +1127,15 @@ impl AIConversation { pub fn set_server_metadata(&mut self, metadata: ServerAIConversationMetadata) { // An absent field (legacy server or conversation) must not erase a - // known baseline. - if let Some(total_provider_cost_in_cents) = metadata.usage.total_provider_cost_in_cents { + // known baseline. Asynchronous metadata snapshots can also be stale + // relative to live `StreamFinished` accounting, so a snapshot may + // only seed or advance the displayed total — never regress it or + // re-add costs the stream already counted. + if let Some(total_provider_cost_in_cents) = metadata.usage.total_provider_cost_in_cents + && self + .total_provider_cost_in_cents + .is_none_or(|current| total_provider_cost_in_cents >= current) + { self.total_provider_cost_in_cents = Some(total_provider_cost_in_cents); self.conversation_usage_metadata .total_provider_cost_in_cents = Some(total_provider_cost_in_cents); @@ -2197,10 +2204,28 @@ impl AIConversation { ) -> Result<(), UpdateConversationError> { self.has_usage_metadata |= request_cost.is_some() || usage_metadata.is_some() || !token_usage.is_empty(); - for usage in token_usage.into_iter() { - if let Some(total_provider_cost_in_cents) = self.total_provider_cost_in_cents.as_mut() { + // Reconcile the cumulative provider cost. When the stream reports the + // server's cumulative total, that snapshot is authoritative for this + // turn: it already includes this response's per-request costs, so + // applying it *instead of* summing them guarantees the same response + // is never counted twice, and it recovers a restored legacy + // conversation's unknown baseline. Without the field (older server), + // per-request costs accumulate onto a known baseline; an unknown + // baseline stays unknown rather than presenting an increment as a + // total. + let stream_cumulative_provider_cost = usage_metadata + .as_ref() + .and_then(|metadata| metadata.total_provider_cost_in_cents); + if let Some(cumulative) = stream_cumulative_provider_cost { + self.total_provider_cost_in_cents = Some(cumulative); + } else if let Some(total_provider_cost_in_cents) = + self.total_provider_cost_in_cents.as_mut() + { + for usage in &token_usage { *total_provider_cost_in_cents += usage.cost_in_cents; } + } + for usage in token_usage.into_iter() { let entry = self .total_token_usage_by_model .entry(usage.model_id.clone()) diff --git a/app/src/ai/agent/conversation_tests.rs b/app/src/ai/agent/conversation_tests.rs index 9748423cf29..5ccf8ece468 100644 --- a/app/src/ai/agent/conversation_tests.rs +++ b/app/src/ai/agent/conversation_tests.rs @@ -300,6 +300,7 @@ fn custom_endpoint_usage_metadata( context_window_usage: 0.0, credits_spent: 0.0, platform_credits_spent: 0.0, + total_provider_cost_in_cents: None, summarized: false, token_usage: vec![], tool_usage_metadata: None, @@ -686,6 +687,74 @@ fn restored_usage_totals_preserve_server_provider_cost_and_add_follow_up() { }); } +/// Spec behavior #4: a `StreamFinished` carrying the server's cumulative +/// provider cost is authoritative for that turn. A 3.2¢ baseline plus a +/// response reporting a 1.2¢ per-request cost AND a 4.4¢ cumulative snapshot +/// must display 4.4¢ — never 5.6¢ (snapshot plus the already-included +/// per-request cost). +#[test] +fn stream_cumulative_snapshot_supersedes_local_accumulation_without_double_count() { + App::test((), |mut app| async move { + initialize_custom_endpoint_usage_test_app(&mut app); + app.add_singleton_model(LLMPreferences::new); + + let mut conversation = + restored_conversation(Some(conversation_data_with_provider_cost(Some(3.2)))); + let mut usage_metadata = credits_usage_metadata(1.0, 0.0); + usage_metadata.total_provider_cost_in_cents = Some(4.4); + app.read(|ctx| { + conversation + .update_cost_and_usage_for_request( + None, + vec![stream_token_usage("model-a", 10, 2, 1.2)], + Some(usage_metadata), + false, + ctx, + ) + .expect("usage should update"); + }); + + let totals = conversation.usage_totals(); + let cost = totals + .cost_in_cents + .expect("a cumulative snapshot makes the total known"); + assert!( + (cost - 4.4).abs() < 1e-6, + "the snapshot already includes the response's 1.2¢; expected 4.4¢ (never 5.6¢), got {cost}" + ); + }); +} + +/// A restored legacy conversation with an unknown baseline becomes known as +/// soon as a stream reports the server's cumulative provider cost. +#[test] +fn stream_cumulative_snapshot_recovers_unknown_legacy_baseline() { + App::test((), |mut app| async move { + initialize_custom_endpoint_usage_test_app(&mut app); + app.add_singleton_model(LLMPreferences::new); + + let mut conversation = + restored_conversation(Some(conversation_data_with_provider_cost(None))); + let mut usage_metadata = credits_usage_metadata(1.0, 0.0); + usage_metadata.total_provider_cost_in_cents = Some(4.4); + app.read(|ctx| { + conversation + .update_cost_and_usage_for_request( + None, + vec![stream_token_usage("model-a", 10, 2, 1.2)], + Some(usage_metadata), + false, + ctx, + ) + .expect("usage should update"); + }); + + let totals = conversation.usage_totals(); + assert_eq!(totals.cost_in_cents, Some(4.4)); + assert!(totals.has_usage); + }); +} + /// A restored conversation whose persisted metadata shows no usage evidence /// must keep the footer's usage entry hidden — local persistence always /// writes a metadata blob, so presence alone is not usage. @@ -700,8 +769,7 @@ fn restored_zero_usage_metadata_keeps_footer_usage_hidden() { #[test] fn restored_known_zero_cost_without_other_usage_keeps_footer_usage_hidden() { - let conversation = - restored_conversation(Some(conversation_data_with_provider_cost(Some(0.0)))); + let conversation = restored_conversation(Some(conversation_data_with_provider_cost(Some(0.0)))); let totals = conversation.usage_totals(); assert!(!totals.has_usage); @@ -860,6 +928,7 @@ fn credits_usage_metadata( context_window_usage: 0.0, credits_spent, platform_credits_spent, + total_provider_cost_in_cents: None, summarized: false, token_usage: vec![], tool_usage_metadata: None, @@ -952,6 +1021,7 @@ fn footer_model_token_usage_keeps_custom_endpoint_usage_distinct_from_same_label context_window_usage: 0.0, credits_spent: 0.0, platform_credits_spent: 0.0, + total_provider_cost_in_cents: None, summarized: false, #[allow(deprecated)] token_usage: vec![], @@ -1017,6 +1087,7 @@ fn footer_model_token_usage_preserves_unresolved_custom_endpoint_usage_with_fall context_window_usage: 0.0, credits_spent: 0.0, platform_credits_spent: 0.0, + total_provider_cost_in_cents: None, summarized: false, #[allow(deprecated)] token_usage: vec![], diff --git a/app/src/ai/blocklist/controller/shared_session.rs b/app/src/ai/blocklist/controller/shared_session.rs index da9ad21669f..a753f5bf4e4 100644 --- a/app/src/ai/blocklist/controller/shared_session.rs +++ b/app/src/ai/blocklist/controller/shared_session.rs @@ -518,6 +518,7 @@ impl BlocklistAIController { context_window_usage: conversation.context_window_usage(), credits_spent: conversation.inference_credits_spent(), platform_credits_spent: conversation.platform_credits_spent(), + total_provider_cost_in_cents: conversation.usage_totals().cost_in_cents, summarized: conversation.was_summarized(), total_input_tokens: 0, #[allow(deprecated)] diff --git a/app/src/terminal/shared_session/replay_agent_conversations.rs b/app/src/terminal/shared_session/replay_agent_conversations.rs index 30bf72f0cea..7035c2294d4 100644 --- a/app/src/terminal/shared_session/replay_agent_conversations.rs +++ b/app/src/terminal/shared_session/replay_agent_conversations.rs @@ -169,6 +169,7 @@ fn create_finished_event_from_conversation(conversation: &AIConversation) -> Res total_input_tokens: 0, credits_spent: conversation.inference_credits_spent(), platform_credits_spent: conversation.platform_credits_spent(), + total_provider_cost_in_cents: conversation.usage_totals().cost_in_cents, summarized: conversation.was_summarized(), #[allow(deprecated)] token_usage: conversation From 968a2a6260eec6a104ca87f86aaf0125373db7f2 Mon Sep 17 00:00:00 2001 From: Ian Hodge Date: Thu, 30 Jul 2026 14:01:35 -0400 Subject: [PATCH 07/10] refactor: scope APP-4952 provider cost to GraphQL restore The cumulative provider-cost stream contract is deferred to separate work. Keep APP-4952 focused on restoring the server-authoritative GraphQL baseline and accumulating the existing per-request live deltas. - Restore warp_multi_agent_api to the branch's pre-stream pin. - Remove StreamFinished cumulative-cost reconciliation and synthetic shared-session forwarding. - Remove the stream-snapshot tests while retaining the exact 3.2 + 1.2 restore regression, absent-field fallback, and stale GraphQL snapshot no-regression coverage. - Keep the monotonic GraphQL metadata guard so asynchronous snapshots may advance but never regress a locally accumulated total. - Align the checked-in APP-4952 spec with the GraphQL-only scope. Co-Authored-By: Oz --- .../specs/APP-4952-tui-cost-footer-restore.md | 21 +++--- app/src/ai/agent/conversation.rs | 26 ++----- app/src/ai/agent/conversation_tests.rs | 72 ------------------- .../ai/blocklist/controller/shared_session.rs | 1 - .../replay_agent_conversations.rs | 1 - 5 files changed, 15 insertions(+), 106 deletions(-) diff --git a/.agents/specs/APP-4952-tui-cost-footer-restore.md b/.agents/specs/APP-4952-tui-cost-footer-restore.md index 75fe8ee12b1..ea011a04b07 100644 --- a/.agents/specs/APP-4952-tui-cost-footer-restore.md +++ b/.agents/specs/APP-4952-tui-cost-footer-restore.md @@ -8,18 +8,18 @@ surface: app conversation restore/hydration plus crates/warp_tui cost footer ## Product *Summary:* When a user restores a non-empty Agent Mode conversation in the headless TUI, the credits⇄cost footer must show the conversation's cumulative provider cost before the next prompt. The server-provided nullable `total_provider_cost_in_cents` value is the authoritative historical baseline and must travel with the conversation across devices. If an older or legacy payload has no historical provider cost, the TUI must say `Cost unavailable` rather than imply that the total is `$0.00` or show only a post-restore increment. -*Key design choices:* Treat the server's optional cumulative cents value as the only historical-cost source; represent absent historical cost as an explicit unknown (`Option`, never numeric zero); add a live response delta only when it cannot be double-counted by a newer server cumulative snapshot; keep credits and new-conversation behavior unchanged. +*Key design choices:* Treat the server's optional GraphQL cumulative cents value as the only historical-cost source; represent absent historical cost as an explicit unknown (`Option`, never numeric zero); after restore, add each live response's existing per-request `TokenUsage.cost_in_cents` delta onto a known baseline; keep credits and new-conversation behavior unchanged. ### Behavior 1. A new conversation keeps current behavior: the footer remains hidden until usage exists, then cost mode displays the sum of live provider costs and credits mode displays the existing cumulative inference-plus-platform credits. 2. Restoring a conversation whose hydrated server usage metadata contains `total_provider_cost_in_cents = 3.2` displays `$0.03` in cost mode before any new prompt. The value is US cents and may be fractional. -3. A follow-up response adds exactly its newly reported provider cost to the restored total when no newer cumulative server value includes that response. A 3.2-cent baseline followed by a 1.2-cent response displays 4.4 cents (`$0.04` with the existing formatter), never only 1.2 cents and never 5.6 cents. -4. If a response/update supplies a newer server cumulative `total_provider_cost_in_cents`, that snapshot wins and the client must not add the same response's `TokenUsage.cost_in_cents` again. An absent field in a legacy response does not erase a known baseline. +3. A follow-up response adds exactly its newly reported provider cost to the restored total. A 3.2-cent baseline followed by a 1.2-cent response displays 4.4 cents (`$0.04` with the existing formatter), never only 1.2 cents. +4. If a later asynchronous GraphQL metadata refresh supplies a newer cumulative `total_provider_cost_in_cents`, that snapshot may advance the local total but must never regress it. An absent field in a legacy GraphQL response does not erase a known baseline. 5. Both `AIConversation::new_restored` (strict, non-empty task restore) and `AIConversation::new_restored_synthesizing_on_empty` (lenient local/child restore) seed the same cost state from `conversation_usage_metadata.total_provider_cost_in_cents`. 6. `convert_conversation_data_to_ai_conversation` passes the server usage metadata, including the optional provider-cost field, into the restore constructors for cloud hydration. It must not replace the server value with a client-derived total; existing continue/fork metadata semantics remain intact. 7. A restored payload that omits `total_provider_cost_in_cents` is an unknown historical baseline, not a known zero. In cost mode the selected conversation's footer renders the stable text `Cost unavailable` (or the exact approved equivalent) whenever the conversation has usage metadata, including when credits happen to be zero. It must not render `$0.00` or an incremental-only dollar total. Credits mode remains unchanged. 8. A conversation with an unknown baseline stays unavailable after a follow-up that reports only a per-request cost; the client must not mislead by presenting that increment as a cumulative total. If a later server snapshot supplies the cumulative field, cost mode switches to that known total. -9. Existing usage-update invalidation continues to redraw the selected TUI session so a restored baseline, a server snapshot, or a permitted live delta is visible without switching conversations or sending a second prompt. Shell mode still hides model and usage sections. +9. Existing usage-update invalidation continues to redraw the selected TUI session so a restored baseline or live delta is visible without switching conversations or sending a second prompt. Shell mode still hides model and usage sections. ## Technical ### Current state @@ -48,15 +48,15 @@ References below are pinned to the current PR branch commit `2c98d4012a75c8bd44b - *Use credits as a dollar fallback or treat missing cost as zero.* Rejected: credits and provider dollars have different units; either choice produces a misleading footer. - *Use a synthetic entry in `total_token_usage_by_model` for the restored amount.* Rejected: it fabricates a model row, contaminates token reporting, and risks counting the first live response twice. - *Represent unavailable cost with a numeric sentinel.* Rejected: zero is a valid cost and the existing footer would render it as `$0.00`. Chosen: `Option` (or an equivalent explicit availability type) plus a footer fallback. -- *Chosen server snapshot plus guarded live delta:* seed a known baseline from `total_provider_cost_in_cents`; while a response is in flight, add its per-request cost only if no cumulative server snapshot for that response was applied; when a cumulative snapshot arrives, replace the baseline and clear/reconcile the delta. With an unknown baseline, do not display a delta as a total. +- *Chosen GraphQL baseline plus live delta:* seed a known historical baseline from the GraphQL `total_provider_cost_in_cents`; add each completed live response's existing per-request cost onto that baseline. A later asynchronous GraphQL snapshot may advance the total but cannot regress it. With an unknown baseline, do not display a delta as a cumulative total. ### Proposed changes 1. Extend the client GraphQL query fragment/schema mapping and `persistence::model::ConversationUsageMetadata` with optional `total_provider_cost_in_cents`, using serde defaults/skip behavior for old local JSON. Regenerate client GraphQL artifacts according to the repository workflow. Keep the server field name/meaning exactly aligned with the parallel warp-server spec. 2. Add restore-aware provider-cost state to `AIConversation` without using a synthetic model row. Both `new_restored` and `new_restored_synthesizing_on_empty` must initialize it from the hydrated metadata; a new conversation starts with a known zero baseline once it has usage. Preserve the optional field through `usage_metadata()`, existing local persistence snapshots, forks, and cloud-token continuation. 3. Update `convert_conversation_data_to_ai_conversation` and its tests so cloud metadata's optional server total reaches the restore constructors for the same conversation. Do not add a second cloud-only cost calculation or silently reset the field for a restore. -4. Update `update_cost_and_usage_for_request` and `usage_totals()` so server cumulative totals take precedence, per-request token costs are added at most once for immediate known-baseline display, and `ConversationUsageTotals` exposes explicit cost availability (prefer `Option`). Preserve credits from inference plus platform metadata exactly as today. +4. Update `update_cost_and_usage_for_request` and `usage_totals()` so per-request token costs continue to be added once onto a known server baseline, and `ConversationUsageTotals` exposes explicit cost availability (prefer `Option`). Preserve credits from inference plus platform metadata exactly as today. Stream-level cumulative provider cost is explicitly out of scope for APP-4952. 5. Update TUI projection and rendering: selected-conversation usage must distinguish “no usage yet” from “usage exists but historical cost unknown”; cost mode formats known cents with the current dollar formatter and renders `Cost unavailable` for unknown cost; credits mode, click toggling, hover state, separators, shell-mode suppression, and new-conversation behavior remain unchanged. -6. Keep `ConversationUsageMetadataUpdated` invalidation wired to the selected session and add tests for baseline redraw and server-snapshot reconciliation. No GUI footer behavior is in scope. +6. Keep `ConversationUsageMetadataUpdated` invalidation wired to the selected session and add tests for baseline redraw and stale GraphQL snapshot rejection. No GUI footer behavior is in scope. ### Open questions resolved - The requester changed the design on 2026-07-23: server persistence/API is the source of truth, and this warp spec is coordinated with the parallel warp-server spec. The earlier client-only design on PR #14220 is superseded and must not be implemented. @@ -64,10 +64,11 @@ References below are pinned to the current PR branch commit `2c98d4012a75c8bd44b - Missing historical cost is an explicit unknown state. The implementor should use `Option` (or a semantically identical type) and the exact stable user-facing fallback may be chosen between `Cost unavailable` and an equivalent wording, but it must never be `$0.00` or an incremental-only total. - A restored conversation with an unknown baseline cannot become a trustworthy cumulative dollar display from a per-request cost alone. It remains unavailable until a server cumulative field arrives. - `RestorationMode::Continue` is the primary cloud-restore acceptance path. Existing fork metadata behavior is preserved; the field is passed through when the payload represents the forked conversation's usage and is not re-derived by the client. +- Stream-level cumulative provider cost is out of scope for APP-4952. This client change depends only on the GraphQL field from the coordinated server PR; upcoming work may add a separate streaming contract. - The prior focused probe established the failing sequence 3.2 cents → restored 0.0 → follow-up 1.2 cents. Its Cargo build was SIGKILLed by the sandbox resource limit, so implementation verification must run in a resource-capable environment. ### Risks / blast radius -The optional field crosses the server GraphQL contract, client GraphQL conversion, local serde snapshots, restore constructors, live usage updates, and TUI rendering. A stale generated schema or a null-to-zero conversion would recreate the bug. Guard with explicit `Some`/`None` tests, server-snapshot precedence/no-double-count coverage, legacy serde fixtures, and render-to-lines assertions for known, unknown, credits, shell, and new-conversation states. +The optional field crosses the server GraphQL contract, client GraphQL conversion, local serde snapshots, restore constructors, live usage updates, and TUI rendering. A stale generated schema or a null-to-zero conversion would recreate the bug. Guard with explicit `Some`/`None` tests, stale GraphQL snapshot rejection, legacy serde fixtures, and render-to-lines assertions for known, unknown, credits, shell, and new-conversation states. ### Validation & verification criteria All criteria below must pass before merge: @@ -75,9 +76,9 @@ All criteria below must pass before merge: 2. Add a named regression test in `app/src/ai/agent/conversation_tests.rs` (for example `restored_usage_totals_preserve_server_provider_cost_and_add_follow_up`) that covers both `AIConversation::new_restored` with non-empty tasks and `new_restored_synthesizing_on_empty` with an empty task list; it must fail before and pass after the change. 3. Add/extend `app/src/ai/agent/api/convert_conversation_tests.rs` to build `ServerAIConversationMetadata` with a known optional server total, run `convert_conversation_data_to_ai_conversation` in `RestorationMode::Continue`, and assert the resulting `usage_totals()` is the server total before any follow-up. Cover the absent/null field as `None`. 4. Add/extend `crates/persistence/src/model_tests.rs` and GraphQL conversion tests: a payload with `total_provider_cost_in_cents` round-trips exactly; an older payload that omits it deserializes to `None`; GraphQL null stays `None`; GraphQL 3.2 cents becomes client 3.2 cents without unit or precision loss beyond the existing `f32` representation; credits/token fields remain unchanged. -5. Test server-snapshot precedence and no double counting: when a response's metadata supplies a cumulative total that includes its token cost, `usage_totals()` equals that snapshot once; when metadata has no cumulative field and the baseline is known, the per-request delta is added once; when the baseline is unknown, a per-request delta does not render as a cumulative dollar total. +5. Test GraphQL snapshot ordering and live accumulation: a newer GraphQL snapshot may advance the known total; a stale snapshot cannot regress a known total; a per-request delta is added once onto a known baseline; when the baseline is unknown, a per-request delta does not render as a cumulative dollar total. 6. Update `crates/warp_tui/src/usage_tests.rs` and `crates/warp_tui/src/terminal_session_view_tests.rs` using the `tui-testing` render-to-lines harness: known cost renders expected dollars; unknown cost renders the stable unavailable indicator and never `$0.00`; credits mode remains unchanged; shell mode omits model/usage; a new conversation preserves current visibility and formatting. -7. Exercise the real `ConversationUsageMetadataUpdated` path for a selected conversation and assert the footer redraws from the restored baseline to baseline-plus-delta (or the newer server snapshot) without switching conversations. Ensure usage metadata with zero credits but unknown cost still renders the unavailable indicator rather than being hidden by a default-total comparison. +7. Exercise the real `ConversationUsageMetadataUpdated` path for a selected conversation and assert the footer redraws from the restored baseline to baseline-plus-delta without switching conversations. Ensure usage metadata with zero credits but unknown cost still renders the unavailable indicator rather than being hidden by a default-total comparison. 8. Run focused checks with a writable Cargo home and bounded build parallelism in a resource-capable environment: `CARGO_HOME=/tmp/cargo-home CARGO_BUILD_JOBS=2 cargo nextest run -p warp -E 'test(restored_usage_totals)' --no-fail-fast`, `CARGO_HOME=/tmp/cargo-home CARGO_BUILD_JOBS=2 cargo nextest run -p warp_tui --no-fail-fast`, and the relevant persistence/GraphQL tests. 9. Run the repository checks required by `warp/AGENTS.md`: `./script/format`; `cargo clippy --workspace --all-targets --all-features --tests -- -D warnings`; `cargo nextest run --no-fail-fast --workspace --exclude command-signatures-v2`; and `cargo test --doc`. If the environment still SIGKILLs Cargo, record the exact failure and leave verification explicitly outstanding. 10. Because this changes user-visible headless TUI output, perform live TUI verification per `tui-verify-change` in a real PTY (prefer `./script/run-tui`, or a bounded `cargo build -p warp_tui --bin warp-tui-oss` followed by tmux/capture-pane in a signed-in capable environment): restore a conversation with a known 3.2-cent server baseline, select cost mode, capture the footer before a prompt, submit one prompt, and capture baseline-plus-increment. Also exercise a legacy/cloud payload without the field and capture `Cost unavailable` with no `$0.00` placeholder. Attach a screenshot or short recording as verification proof; text-only capture is acceptable only when a real image/video cannot be produced and the limitation is recorded in the PR/task. This is TUI-specific proof via `tui-testing`/`tui-verify-change`, not the GUI `computer_use` harness. diff --git a/app/src/ai/agent/conversation.rs b/app/src/ai/agent/conversation.rs index fe6ab9a98c8..ccf05083f3c 100644 --- a/app/src/ai/agent/conversation.rs +++ b/app/src/ai/agent/conversation.rs @@ -1128,9 +1128,9 @@ impl AIConversation { pub fn set_server_metadata(&mut self, metadata: ServerAIConversationMetadata) { // An absent field (legacy server or conversation) must not erase a // known baseline. Asynchronous metadata snapshots can also be stale - // relative to live `StreamFinished` accounting, so a snapshot may + // relative to live per-request cost accounting, so a snapshot may // only seed or advance the displayed total — never regress it or - // re-add costs the stream already counted. + // re-add costs the client already counted. if let Some(total_provider_cost_in_cents) = metadata.usage.total_provider_cost_in_cents && self .total_provider_cost_in_cents @@ -2204,28 +2204,10 @@ impl AIConversation { ) -> Result<(), UpdateConversationError> { self.has_usage_metadata |= request_cost.is_some() || usage_metadata.is_some() || !token_usage.is_empty(); - // Reconcile the cumulative provider cost. When the stream reports the - // server's cumulative total, that snapshot is authoritative for this - // turn: it already includes this response's per-request costs, so - // applying it *instead of* summing them guarantees the same response - // is never counted twice, and it recovers a restored legacy - // conversation's unknown baseline. Without the field (older server), - // per-request costs accumulate onto a known baseline; an unknown - // baseline stays unknown rather than presenting an increment as a - // total. - let stream_cumulative_provider_cost = usage_metadata - .as_ref() - .and_then(|metadata| metadata.total_provider_cost_in_cents); - if let Some(cumulative) = stream_cumulative_provider_cost { - self.total_provider_cost_in_cents = Some(cumulative); - } else if let Some(total_provider_cost_in_cents) = - self.total_provider_cost_in_cents.as_mut() - { - for usage in &token_usage { + for usage in token_usage.into_iter() { + if let Some(total_provider_cost_in_cents) = self.total_provider_cost_in_cents.as_mut() { *total_provider_cost_in_cents += usage.cost_in_cents; } - } - for usage in token_usage.into_iter() { let entry = self .total_token_usage_by_model .entry(usage.model_id.clone()) diff --git a/app/src/ai/agent/conversation_tests.rs b/app/src/ai/agent/conversation_tests.rs index 5ccf8ece468..033736876ac 100644 --- a/app/src/ai/agent/conversation_tests.rs +++ b/app/src/ai/agent/conversation_tests.rs @@ -300,7 +300,6 @@ fn custom_endpoint_usage_metadata( context_window_usage: 0.0, credits_spent: 0.0, platform_credits_spent: 0.0, - total_provider_cost_in_cents: None, summarized: false, token_usage: vec![], tool_usage_metadata: None, @@ -687,74 +686,6 @@ fn restored_usage_totals_preserve_server_provider_cost_and_add_follow_up() { }); } -/// Spec behavior #4: a `StreamFinished` carrying the server's cumulative -/// provider cost is authoritative for that turn. A 3.2¢ baseline plus a -/// response reporting a 1.2¢ per-request cost AND a 4.4¢ cumulative snapshot -/// must display 4.4¢ — never 5.6¢ (snapshot plus the already-included -/// per-request cost). -#[test] -fn stream_cumulative_snapshot_supersedes_local_accumulation_without_double_count() { - App::test((), |mut app| async move { - initialize_custom_endpoint_usage_test_app(&mut app); - app.add_singleton_model(LLMPreferences::new); - - let mut conversation = - restored_conversation(Some(conversation_data_with_provider_cost(Some(3.2)))); - let mut usage_metadata = credits_usage_metadata(1.0, 0.0); - usage_metadata.total_provider_cost_in_cents = Some(4.4); - app.read(|ctx| { - conversation - .update_cost_and_usage_for_request( - None, - vec![stream_token_usage("model-a", 10, 2, 1.2)], - Some(usage_metadata), - false, - ctx, - ) - .expect("usage should update"); - }); - - let totals = conversation.usage_totals(); - let cost = totals - .cost_in_cents - .expect("a cumulative snapshot makes the total known"); - assert!( - (cost - 4.4).abs() < 1e-6, - "the snapshot already includes the response's 1.2¢; expected 4.4¢ (never 5.6¢), got {cost}" - ); - }); -} - -/// A restored legacy conversation with an unknown baseline becomes known as -/// soon as a stream reports the server's cumulative provider cost. -#[test] -fn stream_cumulative_snapshot_recovers_unknown_legacy_baseline() { - App::test((), |mut app| async move { - initialize_custom_endpoint_usage_test_app(&mut app); - app.add_singleton_model(LLMPreferences::new); - - let mut conversation = - restored_conversation(Some(conversation_data_with_provider_cost(None))); - let mut usage_metadata = credits_usage_metadata(1.0, 0.0); - usage_metadata.total_provider_cost_in_cents = Some(4.4); - app.read(|ctx| { - conversation - .update_cost_and_usage_for_request( - None, - vec![stream_token_usage("model-a", 10, 2, 1.2)], - Some(usage_metadata), - false, - ctx, - ) - .expect("usage should update"); - }); - - let totals = conversation.usage_totals(); - assert_eq!(totals.cost_in_cents, Some(4.4)); - assert!(totals.has_usage); - }); -} - /// A restored conversation whose persisted metadata shows no usage evidence /// must keep the footer's usage entry hidden — local persistence always /// writes a metadata blob, so presence alone is not usage. @@ -928,7 +859,6 @@ fn credits_usage_metadata( context_window_usage: 0.0, credits_spent, platform_credits_spent, - total_provider_cost_in_cents: None, summarized: false, token_usage: vec![], tool_usage_metadata: None, @@ -1021,7 +951,6 @@ fn footer_model_token_usage_keeps_custom_endpoint_usage_distinct_from_same_label context_window_usage: 0.0, credits_spent: 0.0, platform_credits_spent: 0.0, - total_provider_cost_in_cents: None, summarized: false, #[allow(deprecated)] token_usage: vec![], @@ -1087,7 +1016,6 @@ fn footer_model_token_usage_preserves_unresolved_custom_endpoint_usage_with_fall context_window_usage: 0.0, credits_spent: 0.0, platform_credits_spent: 0.0, - total_provider_cost_in_cents: None, summarized: false, #[allow(deprecated)] token_usage: vec![], diff --git a/app/src/ai/blocklist/controller/shared_session.rs b/app/src/ai/blocklist/controller/shared_session.rs index a753f5bf4e4..da9ad21669f 100644 --- a/app/src/ai/blocklist/controller/shared_session.rs +++ b/app/src/ai/blocklist/controller/shared_session.rs @@ -518,7 +518,6 @@ impl BlocklistAIController { context_window_usage: conversation.context_window_usage(), credits_spent: conversation.inference_credits_spent(), platform_credits_spent: conversation.platform_credits_spent(), - total_provider_cost_in_cents: conversation.usage_totals().cost_in_cents, summarized: conversation.was_summarized(), total_input_tokens: 0, #[allow(deprecated)] diff --git a/app/src/terminal/shared_session/replay_agent_conversations.rs b/app/src/terminal/shared_session/replay_agent_conversations.rs index 7035c2294d4..30bf72f0cea 100644 --- a/app/src/terminal/shared_session/replay_agent_conversations.rs +++ b/app/src/terminal/shared_session/replay_agent_conversations.rs @@ -169,7 +169,6 @@ fn create_finished_event_from_conversation(conversation: &AIConversation) -> Res total_input_tokens: 0, credits_spent: conversation.inference_credits_spent(), platform_credits_spent: conversation.platform_credits_spent(), - total_provider_cost_in_cents: conversation.usage_totals().cost_in_cents, summarized: conversation.was_summarized(), #[allow(deprecated)] token_usage: conversation From 5b79f4d3379fdf532f8b468b3d984d4d20bbe44a Mon Sep 17 00:00:00 2001 From: Ian Hodge Date: Thu, 30 Jul 2026 15:36:57 -0400 Subject: [PATCH 08/10] fix: adapt APP-4952 usage-totals gate to master's statusline refactor Master moved selected_conversation_usage_totals into terminal_session_view/statusline.rs during the footer refactor; port the has_usage gate there, drop the duplicate in terminal_session_view.rs, and convert the zero-credits view test to the FooterSegments::ordered API. Co-Authored-By: Oz --- crates/warp_tui/src/terminal_session_view.rs | 14 ---- .../src/terminal_session_view/statusline.rs | 4 +- .../src/terminal_session_view_tests.rs | 21 +++--- specs/tui-kevin-yang-zero-state/PRODUCT.md | 65 +++++++++++++++++++ 4 files changed, 76 insertions(+), 28 deletions(-) create mode 100644 specs/tui-kevin-yang-zero-state/PRODUCT.md diff --git a/crates/warp_tui/src/terminal_session_view.rs b/crates/warp_tui/src/terminal_session_view.rs index cd4a017112e..7b33b38da64 100644 --- a/crates/warp_tui/src/terminal_session_view.rs +++ b/crates/warp_tui/src/terminal_session_view.rs @@ -3347,20 +3347,6 @@ impl TuiTerminalSessionView { }); } - /// The selected conversation's accumulated usage totals, or `None` (entry - /// hidden) until any usage has been reported. - fn selected_conversation_usage_totals( - &self, - ctx: &AppContext, - ) -> Option { - let totals = self - .conversation_selection - .as_ref(ctx) - .selected_conversation(ctx)? - .usage_totals(); - totals.has_usage.then_some(totals) - } - /// The session's working directory. The cwd only arrives once shell /// metadata flows (warpified sessions); until then fall back to the /// process cwd the TUI's shell was spawned with. diff --git a/crates/warp_tui/src/terminal_session_view/statusline.rs b/crates/warp_tui/src/terminal_session_view/statusline.rs index 2f458fc9c52..3ed826b799f 100644 --- a/crates/warp_tui/src/terminal_session_view/statusline.rs +++ b/crates/warp_tui/src/terminal_session_view/statusline.rs @@ -748,7 +748,7 @@ impl TuiTerminalSessionView { /// The selected conversation's accumulated usage totals, or `None` (entry /// hidden) until any usage has been reported. - fn selected_conversation_usage_totals( + pub(super) fn selected_conversation_usage_totals( &self, ctx: &AppContext, ) -> Option { @@ -757,6 +757,6 @@ impl TuiTerminalSessionView { .as_ref(ctx) .selected_conversation(ctx)? .usage_totals(); - (totals != ConversationUsageTotals::default()).then_some(totals) + totals.has_usage.then_some(totals) } } diff --git a/crates/warp_tui/src/terminal_session_view_tests.rs b/crates/warp_tui/src/terminal_session_view_tests.rs index 120f824e2c6..327ab0e1d92 100644 --- a/crates/warp_tui/src/terminal_session_view_tests.rs +++ b/crates/warp_tui/src/terminal_session_view_tests.rs @@ -4059,18 +4059,15 @@ fn footer_usage_entry_shows_unknown_cost_even_with_zero_credits() { ); let row = render_status_footer_row( FooterSegments { - shell_mode: false, - model_label: Some( - TuiText::new("TestModel") - .with_style(builder.primary_text_style()) - .truncate() - .finish(), - ), - cwd: None, - branch: None, - usage: Some(usage), - diff_additions: 0, - diff_deletions: 0, + ordered: vec![ + FooterSegment::Model( + TuiText::new("TestModel") + .with_style(builder.primary_text_style()) + .truncate() + .finish(), + ), + FooterSegment::CreditUsage(usage), + ], }, &builder, ) diff --git a/specs/tui-kevin-yang-zero-state/PRODUCT.md b/specs/tui-kevin-yang-zero-state/PRODUCT.md new file mode 100644 index 00000000000..6b9984298c0 --- /dev/null +++ b/specs/tui-kevin-yang-zero-state/PRODUCT.md @@ -0,0 +1,65 @@ +# TUI Zero State: Kevin Yang Face Animation + +## Summary + +Replace the right-panel starfield animation in the TUI zero state with an animated ASCII art portrait of Kevin Yang — rendered entirely in terminal characters, animating continuously while the zero state is visible, and dismissing cleanly when the user submits their first command. + +## Figma + +Figma: none provided. Reference image: photo of Kevin Yang (young man in suit and glasses, ivy background) supplied by the user. + +## Behavior + +### Portrait rendering + +1. The right-side animation panel of the TUI zero state displays a grayscale ASCII art portrait of Kevin Yang, generated from the reference photo. The portrait fills the available panel dimensions, scaling proportionally to fit within the terminal cell grid (respecting the ~2:1 cell height-to-width aspect ratio). + +2. The portrait is rendered using a ramp of ASCII/Unicode characters ordered from light to dark (e.g. ` `, `.`, `:`, `-`, `=`, `+`, `*`, `#`, `@`, `█`) to approximate luminance regions from the source image. Dark regions (hair, suit jacket, glasses frames) map to denser characters; light regions (face, shirt, background ivy) map to sparser characters. + +3. The portrait is statically faithful to the source image's composition: the face is centred in the panel, the glasses are visible as distinct character bands, the suit collar and tie are present in the lower third, and the ivy background is represented in the surrounding region. + +4. On terminals narrower than the minimum animation panel width (defined as the same minimum used by the starfield today), the portrait panel renders nothing — same zero-width fallback as the current starfield. + +5. On terminals taller or wider than the portrait's natural cell count, the portrait is centred within the panel with blank cells on all sides; it does not stretch beyond its source aspect ratio. + +### Animation + +6. The portrait animates continuously while the zero state is visible. The animation does not make the face move; instead it applies one or more of the following layered effects that run at ~30 fps (matching the current starfield repaint cadence): + + a. **Scanline shimmer**: a horizontal band of slightly brightened characters (~2–4 rows tall) sweeps slowly downward from the top of the portrait to the bottom, then repeats. The shimmer modifies the characters in the band one brightness step brighter than their base value for the duration of the pass. + + b. **Glyph flicker**: a small percentage (~3–5%) of portrait cells independently swap to an adjacent character in the brightness ramp each frame, producing a subtle "static" or "live signal" texture across the entire face. Cells reset toward their base luminance value after flickering. + + c. **Accent highlight**: a small number of cells (~1–2%) in the lighter luminance regions (face, shirt) are momentarily coloured with the terminal's accent colour (the same colour used for starred stars in the starfield) before fading back to white/default. This matches the visual language of the existing starfield glow effect. + +7. The animation effects are additive and independent — scanline shimmer, glyph flicker, and accent highlights all run simultaneously. + +8. The animation runs for as long as the zero state is visible. When the user submits a command and the zero state dismisses, all animation stops cleanly with no trailing artefacts. + +9. When the zero state reappears (transcript empties), the animation restarts from its initial state (scanline at top, flicker seed reset). + +### Responsive behaviour + +10. When the terminal is resized while the zero state is visible, the portrait re-renders at the new cell dimensions within one repaint frame. The animation continues uninterrupted through the resize — there is no flash or jump. + +11. Portrait cell dimensions are recalculated on each resize. The same source image data is always used regardless of terminal size; the mapping from image pixels to character cells is recomputed from scratch to avoid accumulated scaling artefacts. + +12. If the panel is resized below the minimum animation dimensions, the portrait disappears immediately (no partial render). When the panel grows above minimum again, the portrait reappears at the next repaint. + +### Coexistence with the left column + +13. The portrait occupies only the right-side animation panel. The left text column (title, version, changelog, project context, MCP status) is unaffected — layout, content, and update behaviour are identical to the current zero state. + +14. The maximum panel width cap (`MAX_ANIMATION_COLS`) that limits the starfield today applies equally to the portrait panel. On very wide terminals, the portrait centres within that capped width. + +### Accessibility and focus + +15. The animation panel is purely decorative and not focusable. Screen readers and accessibility tooling receive no additional content from the animation region — the portrait characters are not announced. + +16. If the user's terminal does not support colour (e.g. `$TERM` reports `dumb` or colour capability is absent), accent highlight effects are suppressed and the portrait renders in plain character art without colour. Scanline shimmer and glyph flicker are still applied via character substitution only. + +### Transition from the starfield + +17. The starfield animation is fully replaced by the Kevin Yang portrait. There is no mode-switch, setting, or feature flag to toggle between the two. The starfield code is removed. + +18. The new animation must not regress any existing zero-state behaviour: changelog loading, MCP status updates, project context discovery, and autoupdate status all continue to trigger re-renders of the left column independently of the portrait animation on the right. From c9acf3e63725d8c46d384a88e15d2be654b12f57 Mon Sep 17 00:00:00 2001 From: Ian Hodge Date: Thu, 30 Jul 2026 15:37:16 -0400 Subject: [PATCH 09/10] chore: drop unrelated local spec accidentally included in previous commit Co-Authored-By: Oz --- specs/tui-kevin-yang-zero-state/PRODUCT.md | 65 ---------------------- 1 file changed, 65 deletions(-) delete mode 100644 specs/tui-kevin-yang-zero-state/PRODUCT.md diff --git a/specs/tui-kevin-yang-zero-state/PRODUCT.md b/specs/tui-kevin-yang-zero-state/PRODUCT.md deleted file mode 100644 index 6b9984298c0..00000000000 --- a/specs/tui-kevin-yang-zero-state/PRODUCT.md +++ /dev/null @@ -1,65 +0,0 @@ -# TUI Zero State: Kevin Yang Face Animation - -## Summary - -Replace the right-panel starfield animation in the TUI zero state with an animated ASCII art portrait of Kevin Yang — rendered entirely in terminal characters, animating continuously while the zero state is visible, and dismissing cleanly when the user submits their first command. - -## Figma - -Figma: none provided. Reference image: photo of Kevin Yang (young man in suit and glasses, ivy background) supplied by the user. - -## Behavior - -### Portrait rendering - -1. The right-side animation panel of the TUI zero state displays a grayscale ASCII art portrait of Kevin Yang, generated from the reference photo. The portrait fills the available panel dimensions, scaling proportionally to fit within the terminal cell grid (respecting the ~2:1 cell height-to-width aspect ratio). - -2. The portrait is rendered using a ramp of ASCII/Unicode characters ordered from light to dark (e.g. ` `, `.`, `:`, `-`, `=`, `+`, `*`, `#`, `@`, `█`) to approximate luminance regions from the source image. Dark regions (hair, suit jacket, glasses frames) map to denser characters; light regions (face, shirt, background ivy) map to sparser characters. - -3. The portrait is statically faithful to the source image's composition: the face is centred in the panel, the glasses are visible as distinct character bands, the suit collar and tie are present in the lower third, and the ivy background is represented in the surrounding region. - -4. On terminals narrower than the minimum animation panel width (defined as the same minimum used by the starfield today), the portrait panel renders nothing — same zero-width fallback as the current starfield. - -5. On terminals taller or wider than the portrait's natural cell count, the portrait is centred within the panel with blank cells on all sides; it does not stretch beyond its source aspect ratio. - -### Animation - -6. The portrait animates continuously while the zero state is visible. The animation does not make the face move; instead it applies one or more of the following layered effects that run at ~30 fps (matching the current starfield repaint cadence): - - a. **Scanline shimmer**: a horizontal band of slightly brightened characters (~2–4 rows tall) sweeps slowly downward from the top of the portrait to the bottom, then repeats. The shimmer modifies the characters in the band one brightness step brighter than their base value for the duration of the pass. - - b. **Glyph flicker**: a small percentage (~3–5%) of portrait cells independently swap to an adjacent character in the brightness ramp each frame, producing a subtle "static" or "live signal" texture across the entire face. Cells reset toward their base luminance value after flickering. - - c. **Accent highlight**: a small number of cells (~1–2%) in the lighter luminance regions (face, shirt) are momentarily coloured with the terminal's accent colour (the same colour used for starred stars in the starfield) before fading back to white/default. This matches the visual language of the existing starfield glow effect. - -7. The animation effects are additive and independent — scanline shimmer, glyph flicker, and accent highlights all run simultaneously. - -8. The animation runs for as long as the zero state is visible. When the user submits a command and the zero state dismisses, all animation stops cleanly with no trailing artefacts. - -9. When the zero state reappears (transcript empties), the animation restarts from its initial state (scanline at top, flicker seed reset). - -### Responsive behaviour - -10. When the terminal is resized while the zero state is visible, the portrait re-renders at the new cell dimensions within one repaint frame. The animation continues uninterrupted through the resize — there is no flash or jump. - -11. Portrait cell dimensions are recalculated on each resize. The same source image data is always used regardless of terminal size; the mapping from image pixels to character cells is recomputed from scratch to avoid accumulated scaling artefacts. - -12. If the panel is resized below the minimum animation dimensions, the portrait disappears immediately (no partial render). When the panel grows above minimum again, the portrait reappears at the next repaint. - -### Coexistence with the left column - -13. The portrait occupies only the right-side animation panel. The left text column (title, version, changelog, project context, MCP status) is unaffected — layout, content, and update behaviour are identical to the current zero state. - -14. The maximum panel width cap (`MAX_ANIMATION_COLS`) that limits the starfield today applies equally to the portrait panel. On very wide terminals, the portrait centres within that capped width. - -### Accessibility and focus - -15. The animation panel is purely decorative and not focusable. Screen readers and accessibility tooling receive no additional content from the animation region — the portrait characters are not announced. - -16. If the user's terminal does not support colour (e.g. `$TERM` reports `dumb` or colour capability is absent), accent highlight effects are suppressed and the portrait renders in plain character art without colour. Scanline shimmer and glyph flicker are still applied via character substitution only. - -### Transition from the starfield - -17. The starfield animation is fully replaced by the Kevin Yang portrait. There is no mode-switch, setting, or feature flag to toggle between the two. The starfield code is removed. - -18. The new animation must not regress any existing zero-state behaviour: changelog loading, MCP status updates, project context discovery, and autoupdate status all continue to trigger re-renders of the left column independently of the portrait animation on the right. From 01c39191cd76c70d3914ca17a6e06b80cfad809d Mon Sep 17 00:00:00 2001 From: Ian Hodge Date: Thu, 30 Jul 2026 16:02:33 -0400 Subject: [PATCH 10/10] fix: count a known-zero provider cost as footer usage evidence Some(0.0) is only recorded once a turn has completed accounting, so a restored or server snapshot with a cumulative zero must surface the footer as a truthful $0.00 instead of being hidden as though no usage occurred. None remains the only unknown/hidden state. Co-Authored-By: Oz --- app/src/ai/agent/conversation.rs | 7 ++++--- app/src/ai/agent/conversation_tests.rs | 8 ++++++-- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/app/src/ai/agent/conversation.rs b/app/src/ai/agent/conversation.rs index ccf05083f3c..f98a9bf41a9 100644 --- a/app/src/ai/agent/conversation.rs +++ b/app/src/ai/agent/conversation.rs @@ -201,11 +201,12 @@ pub struct ConversationUsageTotals { /// metadata blob, and a restored conversation that never ran a request must /// keep the footer's usage entry hidden. fn usage_metadata_indicates_usage(metadata: &ConversationUsageMetadata) -> bool { + // A present provider cost counts even at 0.0: the server only records a + // cost once a turn has completed accounting, so `Some(0.0)` is a known + // zero baseline (rendered as $0.00), unlike `None` (unknown). metadata.credits_spent != 0.0 || metadata.platform_credits_spent != 0.0 - || metadata - .total_provider_cost_in_cents - .is_some_and(|cost| cost != 0.0) + || metadata.total_provider_cost_in_cents.is_some() || !metadata.token_usage.is_empty() || metadata.context_window_usage != 0.0 || metadata.was_summarized diff --git a/app/src/ai/agent/conversation_tests.rs b/app/src/ai/agent/conversation_tests.rs index 033736876ac..b4a44ade227 100644 --- a/app/src/ai/agent/conversation_tests.rs +++ b/app/src/ai/agent/conversation_tests.rs @@ -698,12 +698,16 @@ fn restored_zero_usage_metadata_keeps_footer_usage_hidden() { assert_eq!(totals.cost_in_cents, None); } +/// A present provider cost is affirmative evidence even at 0.0: the server +/// only records a cost once a turn completed accounting, so a restored +/// known-zero baseline must surface the footer as a truthful $0.00 rather +/// than staying hidden or reading as unknown. #[test] -fn restored_known_zero_cost_without_other_usage_keeps_footer_usage_hidden() { +fn restored_known_zero_cost_marks_usage_with_known_zero_baseline() { let conversation = restored_conversation(Some(conversation_data_with_provider_cost(Some(0.0)))); let totals = conversation.usage_totals(); - assert!(!totals.has_usage); + assert!(totals.has_usage); assert_eq!(totals.cost_in_cents, Some(0.0)); }