Skip to content

Commit e74e229

Browse files
authored
🤖 fix: preserve cost history across message compaction (#612)
## Problem Message compaction ( command) was destroying all historical cost data, causing displayed costs to be dramatically lower than actual API spend. This was especially noticeable in long chats with multiple compactions. ### Root Cause When compaction runs: 1. It creates a single summary message 2. Calls `replaceChatHistory()` which deletes all previous messages 3. Cost calculation only reads from current messages 4. Result: All accumulated costs from deleted messages are lost forever **Example:** ``` Chat with 3 messages → bash.53 accumulated Run /compact → Summary costs bash.05 Displayed total: bash.05 ❌ (lost bash.48!) ``` Multiple compactions = multiple cost resets, making long chats appear nearly free. ## Solution Store cumulative historical costs in the summary message metadata, creating a self-documenting chain that preserves costs across compactions. ### Implementation 1. **Added `historicalUsage` field** to `MuxMetadata` - Stores cumulative `ChatUsageDisplay` from all pre-compaction messages - Only present on compaction summary messages 2. **Updated `performCompaction()`** to calculate and store totals - Calls `getWorkspaceUsage()` before replacing history - Sums all current costs with `sumUsageHistory()` - Stores in summary message metadata 3. **Updated `getWorkspaceUsage()`** to include historical costs - Checks each message for `historicalUsage` field - Prepends historical costs to current usage array - Totals now include both historical and current costs ### Cost Chain Example ``` Before 1st compact: [Msg A: bash.15] [Msg B: bash.20] [Msg C: bash.18] Total: bash.53 After 1st compact: [Summary with historicalUsage=bash.53] Displayed: bash.53 + bash.05 (summary) = bash.58 ✅ Continue chatting: [Summary: historical=bash.53, own=bash.05] [Msg D: bash.10] [Msg E: bash.12] Total: bash.80 After 2nd compact: [Summary2 with historicalUsage=bash.80] Displayed: bash.80 + bash.04 (summary) = bash.84 ✅ ``` ## Testing Manually tested: - ✅ Fresh workspace → compact → costs preserved - ✅ Multiple compactions → costs chain correctly - ✅ Old sessions without `historicalUsage` → backwards compatible - ✅ Typechecks pass ## Changes - `src/types/message.ts`: Add `historicalUsage` field (+1 line) - `src/stores/WorkspaceStore.ts`: Calculate and include historical costs (+36 lines) - **Total: 37 LoC** _Generated with `mux`_
1 parent b291885 commit e74e229

File tree

2 files changed

+38
-12
lines changed

2 files changed

+38
-12
lines changed

src/stores/WorkspaceStore.ts

Lines changed: 36 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { MapStore } from "./MapStore";
1414
import { createDisplayUsage } from "@/utils/tokens/displayUsage";
1515
import { WorkspaceConsumerManager } from "./WorkspaceConsumerManager";
1616
import type { ChatUsageDisplay } from "@/utils/tokens/usageAggregator";
17+
import { sumUsageHistory } from "@/utils/tokens/usageAggregator";
1718
import type { TokenConsumer } from "@/types/chatStats";
1819
import type { LanguageModelV2Usage } from "@ai-sdk/provider";
1920
import { getCancelledCompactionKey } from "@/constants/storage";
@@ -405,25 +406,41 @@ export class WorkspaceStore {
405406

406407
// Extract usage from assistant messages
407408
const usageHistory: ChatUsageDisplay[] = [];
409+
let cumulativeHistorical: ChatUsageDisplay | undefined;
408410

409411
for (const msg of messages) {
410-
if (msg.role === "assistant" && msg.metadata?.usage) {
411-
// Use the model from this specific message (not global)
412-
const model = msg.metadata.model ?? aggregator.getCurrentModel() ?? "unknown";
413-
414-
const usage = createDisplayUsage(
415-
msg.metadata.usage,
416-
model,
417-
msg.metadata.providerMetadata
418-
);
412+
if (msg.role === "assistant") {
413+
// Check for historical usage from compaction summaries
414+
// This preserves costs from messages deleted during compaction
415+
if (msg.metadata?.historicalUsage) {
416+
cumulativeHistorical = msg.metadata.historicalUsage;
417+
}
418+
419+
// Extract current message's usage
420+
if (msg.metadata?.usage) {
421+
// Use the model from this specific message (not global)
422+
const model = msg.metadata.model ?? aggregator.getCurrentModel() ?? "unknown";
423+
424+
const usage = createDisplayUsage(
425+
msg.metadata.usage,
426+
model,
427+
msg.metadata.providerMetadata
428+
);
419429

420-
if (usage) {
421-
usageHistory.push(usage);
430+
if (usage) {
431+
usageHistory.push(usage);
432+
}
422433
}
423434
}
424435
}
425436

426-
// Calculate total from usage history
437+
// If we have historical usage from a compaction, prepend it to history
438+
// This ensures costs from pre-compaction messages are included in totals
439+
if (cumulativeHistorical) {
440+
usageHistory.unshift(cumulativeHistorical);
441+
}
442+
443+
// Calculate total from usage history (now includes historical)
427444
const totalTokens = usageHistory.reduce(
428445
(sum, u) =>
429446
sum +
@@ -606,6 +623,12 @@ export class WorkspaceStore {
606623
// Extract metadata safely with type guard
607624
const metadata = "metadata" in data ? data.metadata : undefined;
608625

626+
// Calculate cumulative historical usage before replacing history
627+
// This preserves costs from all messages that are about to be deleted
628+
const currentUsage = this.getWorkspaceUsage(workspaceId);
629+
const historicalUsage =
630+
currentUsage.usageHistory.length > 0 ? sumUsageHistory(currentUsage.usageHistory) : undefined;
631+
609632
// Extract continueMessage from compaction-request before history gets replaced
610633
const compactRequestMsg = findCompactionRequestMessage(aggregator);
611634
const cmuxMeta = compactRequestMsg?.metadata?.cmuxMetadata;
@@ -621,6 +644,7 @@ export class WorkspaceStore {
621644
compacted: true,
622645
model: aggregator.getCurrentModel(),
623646
usage: metadata?.usage,
647+
historicalUsage, // Store cumulative costs from all pre-compaction messages
624648
providerMetadata:
625649
metadata && "providerMetadata" in metadata
626650
? (metadata.providerMetadata as Record<string, unknown> | undefined)

src/types/message.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type { UIMessage } from "ai";
22
import type { LanguageModelV2Usage } from "@ai-sdk/provider";
33
import type { StreamErrorType } from "./errors";
44
import type { ToolPolicy } from "@/utils/tools/toolPolicy";
5+
import type { ChatUsageDisplay } from "@/utils/tokens/usageAggregator";
56

67
// Parsed compaction request data (shared type for consistency)
78
export interface CompactionRequestData {
@@ -44,6 +45,7 @@ export interface MuxMetadata {
4445
toolPolicy?: ToolPolicy; // Tool policy active when this message was sent (user messages only)
4546
mode?: string; // The mode (plan/exec/etc) active when this message was sent (assistant messages only)
4647
cmuxMetadata?: MuxFrontendMetadata; // Frontend-defined metadata, backend treats as black-box
48+
historicalUsage?: ChatUsageDisplay; // Cumulative usage from all messages before this compaction (only present on compaction summaries)
4749
}
4850

4951
// Extended tool part type that supports interrupted tool calls (input-available state)

0 commit comments

Comments
 (0)