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..ea011a04b07 --- /dev/null +++ b/.agents/specs/APP-4952-tui-cost-footer-restore.md @@ -0,0 +1,84 @@ +--- +ticket: APP-4952 +repository: warpdotdev/warp +estimate: M +surface: app conversation restore/hydration plus crates/warp_tui cost footer +--- +# 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 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 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. 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 or live delta is visible without switching conversations or sending a second prompt. Shell mode still hides model and usage sections. + +## Technical +### Current state +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 +- *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 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 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 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. +- 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. +- 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, 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: +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 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 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/api/convert_conversation_tests.rs b/app/src/ai/agent/api/convert_conversation_tests.rs index 8680dcd8fc0..3c048e417cc 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,117 @@ 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); +} + +/// 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); +} + +/// 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] +#[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] diff --git a/app/src/ai/agent/conversation.rs b/app/src/ai/agent/conversation.rs index 33356d19d79..f98a9bf41a9 100644 --- a/app/src/ai/agent/conversation.rs +++ b/app/src/ai/agent/conversation.rs @@ -183,11 +183,33 @@ 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 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 { + // 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() + || !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 @@ -315,6 +337,14 @@ 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 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. fallback_display_title: Option, @@ -404,6 +434,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 +569,7 @@ impl AIConversation { let ( server_conversation_token, forked_from_server_conversation_token, + has_usage_metadata, conversation_usage_metadata, reverted_action_ids, artifacts, @@ -553,6 +586,10 @@ impl AIConversation { let server_conversation_token = data .server_conversation_token .map(ServerConversationToken::new); + 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 @@ -588,6 +625,7 @@ impl AIConversation { ( server_conversation_token, forked_from_server_conversation_token, + has_usage_metadata, conversation_usage_metadata, reverted_action_ids, artifacts, @@ -605,6 +643,7 @@ impl AIConversation { ( None, None, + false, ConversationUsageMetadata::default(), HashSet::new(), Vec::new(), @@ -619,6 +658,7 @@ impl AIConversation { false, ) }; + let total_provider_cost_in_cents = conversation_usage_metadata.total_provider_cost_in_cents; Ok(Self { id, @@ -645,6 +685,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, + has_usage_metadata, optimistic_cli_subagent_subtask_id: None, fallback_display_title: None, artifacts, @@ -1085,6 +1127,24 @@ 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 per-request cost accounting, so a snapshot may + // only seed or advance the displayed total — never regress it or + // 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 + .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); + } + // 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); } @@ -2143,7 +2203,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 +2273,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 +3751,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..b4a44ade227 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,137 @@ 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); +} + +/// 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); +} + +/// 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_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_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 { + 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 +883,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 +916,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/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 99ec2734e45..327ab0e1d92 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, |_, _| {}, @@ -4004,6 +4006,86 @@ 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 { + ordered: vec![ + FooterSegment::Model( + TuiText::new("TestModel") + .with_style(builder.primary_text_style()) + .truncate() + .finish(), + ), + FooterSegment::CreditUsage(usage), + ], + }, + &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 { 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" + ); +}