Skip to content

Commit b6cf5d1

Browse files
fazxesclaude
andcommitted
feat(usage): persist token usage across sessions and reloads
- Add TokenUsage struct to Rust Message type for backend persistence - Add TokenUsageDto to conversation commands for frontend serialization - Update protocol schemas with backwards-compatible message parsing - Add restoreSessionUsage action to tool-store for session switching - Sync currentSessionId with React state on mount - Save usage when persisting messages on agent:complete - Restore cumulative usage when loading conversations from sidebar - Include usage field in conversation:loaded message mapping 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent a038f56 commit b6cf5d1

7 files changed

Lines changed: 306 additions & 21 deletions

File tree

‎apps/agent/src/hooks/use-chat-messages.ts‎

Lines changed: 142 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import type { ChatMessage, ImageAttachment } from '@/components/chat';
44
import type { ExtensionMessage, Model, ReactElementContext, ThinkingMode } from '@/types/protocol';
55

66
import { useTauri } from '@/hooks/use-tauri';
7-
import { conversationAddMessage } from '@/lib/backend';
7+
import { conversationAddMessage, conversationLoad } from '@/lib/backend';
88
import { computeSimpleDiff, getLanguageFromPath } from '@/lib/diff-utils';
99
import { useFileStore } from '@/stores/file-store';
1010
import { useFileViewerStore } from '@/stores/file-viewer-store';
@@ -111,6 +111,7 @@ export function useChatMessages(options: UseChatMessagesOptions = {}): UseChatMe
111111
clearPermissions,
112112
addUsage,
113113
switchSession,
114+
restoreSessionUsage,
114115
} = useToolStore();
115116
const { queueMessage: storeQueueMessage } = useQueuedMessageStore();
116117

@@ -139,6 +140,81 @@ export function useChatMessages(options: UseChatMessagesOptions = {}): UseChatMe
139140
}
140141
}, [sessionId]);
141142

143+
// Sync tool store's currentSessionId when React sessionId changes
144+
// This is critical for usage tracking - without this, usage accumulated before the first
145+
// switchSession call (e.g., from localStorage restore or system:init) won't be cached
146+
// when switching conversations, causing token counts to reset to 0.
147+
useEffect(() => {
148+
if (sessionId) {
149+
const toolState = useToolStore.getState();
150+
if (toolState.currentSessionId !== sessionId) {
151+
switchSession(sessionId);
152+
}
153+
}
154+
}, [sessionId, switchSession]);
155+
156+
// Restore usage from backend on initial mount (when sessionId comes from localStorage)
157+
// This ensures token counts persist across window reloads
158+
const hasRestoredUsage = useRef(false);
159+
const initialSessionId = useRef(sessionId); // Capture initial sessionId
160+
useEffect(() => {
161+
const sid = initialSessionId.current;
162+
if (!sid || hasRestoredUsage.current) return;
163+
164+
// Only run once on mount with initial sessionId
165+
hasRestoredUsage.current = true;
166+
167+
// Load conversation from backend to get persisted usage data
168+
void (async (): Promise<void> => {
169+
try {
170+
const conversation = await conversationLoad(sid);
171+
if (!conversation?.messages) return;
172+
173+
// Calculate cumulative usage from all persisted messages
174+
const processedMessageIds: string[] = [];
175+
const cumulativeUsage = conversation.messages.reduce(
176+
(acc, m) => {
177+
if (m.usage) {
178+
processedMessageIds.push(m.id);
179+
return {
180+
inputTokens: acc.inputTokens + m.usage.inputTokens,
181+
outputTokens: acc.outputTokens + m.usage.outputTokens,
182+
cacheReadInputTokens:
183+
acc.cacheReadInputTokens + (m.usage.cacheReadInputTokens ?? 0),
184+
cacheCreationInputTokens:
185+
acc.cacheCreationInputTokens + (m.usage.cacheCreationInputTokens ?? 0),
186+
totalCostUsd: acc.totalCostUsd + (m.usage.totalCostUsd ?? 0),
187+
};
188+
}
189+
return acc;
190+
},
191+
{
192+
inputTokens: 0,
193+
outputTokens: 0,
194+
cacheReadInputTokens: 0,
195+
cacheCreationInputTokens: 0,
196+
totalCostUsd: 0,
197+
}
198+
);
199+
200+
// Restore usage if we found any
201+
if (processedMessageIds.length > 0) {
202+
// Use getState() to avoid stale closures
203+
const { restoreSessionUsage: restore, switchSession: sw } = useToolStore.getState();
204+
restore(sid, cumulativeUsage, processedMessageIds);
205+
// Also update current session if it matches
206+
const toolState = useToolStore.getState();
207+
if (toolState.currentSessionId === sid) {
208+
// Re-trigger switch to apply the restored usage
209+
sw(sid);
210+
}
211+
}
212+
} catch {
213+
// Ignore errors - conversation may not exist yet
214+
}
215+
})();
216+
}, []); // Empty deps - only run on mount, uses refs for values
217+
142218
// Track if we have pending animations - only run interval when needed
143219
const hasAnimatingMessage = messages.some((m) => m.displayedContent.length < m.content.length);
144220

@@ -267,13 +343,31 @@ export function useChatMessages(options: UseChatMessagesOptions = {}): UseChatMe
267343
: {}),
268344
};
269345

270-
// Persist assistant message to backend
346+
// Persist assistant message to backend (including usage for token tracking)
347+
// Build usage object conditionally to satisfy exactOptionalPropertyTypes
348+
const usageDto = message.usage
349+
? {
350+
inputTokens: message.usage.input_tokens,
351+
outputTokens: message.usage.output_tokens,
352+
...(message.usage.cache_read_input_tokens !== undefined
353+
? { cacheReadInputTokens: message.usage.cache_read_input_tokens }
354+
: {}),
355+
...(message.usage.cache_creation_input_tokens !== undefined
356+
? { cacheCreationInputTokens: message.usage.cache_creation_input_tokens }
357+
: {}),
358+
...(message.total_cost_usd !== undefined
359+
? { totalCostUsd: message.total_cost_usd }
360+
: {}),
361+
}
362+
: undefined;
363+
271364
void conversationAddMessage(message.session_id, {
272365
id: completedMsg.id,
273366
role: 'assistant',
274367
content: completedMsg.content,
275368
...(completedMsg.thinking ? { thinking: completedMsg.thinking } : {}),
276369
createdAt: Date.now(),
370+
...(usageDto ? { usage: usageDto } : {}),
277371
});
278372

279373
return [...prev.slice(0, -1), completedMsg];
@@ -376,14 +470,19 @@ export function useChatMessages(options: UseChatMessagesOptions = {}): UseChatMe
376470
}
377471

378472
case 'conversation:loaded': {
379-
// Prepare new messages
473+
// Prepare new messages (filter out system messages as they're not displayed)
380474
const cachedMessages = messagesCache.current.get(message.session_id);
381-
const backendMessages = message.messages.map((m) => ({
382-
id: m.id,
383-
role: m.role,
384-
content: m.content,
385-
displayedContent: m.content,
386-
}));
475+
const backendMessages = message.messages
476+
.filter(
477+
(m): m is typeof m & { role: 'user' | 'assistant' } =>
478+
m.role === 'user' || m.role === 'assistant'
479+
)
480+
.map((m) => ({
481+
id: m.id,
482+
role: m.role,
483+
content: m.content,
484+
displayedContent: m.content,
485+
}));
387486

388487
// Determine which messages to use (prefer cache if it has more messages)
389488
let newMessages: typeof backendMessages;
@@ -395,6 +494,39 @@ export function useChatMessages(options: UseChatMessagesOptions = {}): UseChatMe
395494
newMessages = backendMessages;
396495
}
397496

497+
// Calculate cumulative usage from persisted messages (for token tracking persistence)
498+
const processedMessageIds: string[] = [];
499+
const cumulativeUsage = message.messages.reduce(
500+
(acc, m) => {
501+
if (m.usage) {
502+
processedMessageIds.push(m.id);
503+
return {
504+
inputTokens: acc.inputTokens + m.usage.inputTokens,
505+
outputTokens: acc.outputTokens + m.usage.outputTokens,
506+
cacheReadInputTokens:
507+
acc.cacheReadInputTokens + (m.usage.cacheReadInputTokens ?? 0),
508+
cacheCreationInputTokens:
509+
acc.cacheCreationInputTokens + (m.usage.cacheCreationInputTokens ?? 0),
510+
totalCostUsd: acc.totalCostUsd + (m.usage.totalCostUsd ?? 0),
511+
};
512+
}
513+
return acc;
514+
},
515+
{
516+
inputTokens: 0,
517+
outputTokens: 0,
518+
cacheReadInputTokens: 0,
519+
cacheCreationInputTokens: 0,
520+
totalCostUsd: 0,
521+
}
522+
);
523+
524+
// Restore usage from persisted data BEFORE switching session
525+
// This ensures the session cache is pre-populated with correct usage
526+
if (processedMessageIds.length > 0) {
527+
restoreSessionUsage(message.session_id, cumulativeUsage, processedMessageIds);
528+
}
529+
398530
// Set all state atomically - React 18 batches these updates
399531
setMessages(newMessages);
400532
setSessionId(message.session_id);
@@ -607,6 +739,7 @@ export function useChatMessages(options: UseChatMessagesOptions = {}): UseChatMe
607739
addPermissionRequest,
608740
addUsage,
609741
switchSession,
742+
restoreSessionUsage,
610743
onSessionCreated,
611744
]
612745
);

‎apps/agent/src/hooks/use-tauri.ts‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -609,7 +609,11 @@ async function handleTauriMessage(message: WebviewMessage): Promise<void> {
609609
id: m.id,
610610
role: m.role,
611611
content: m.content,
612-
timestamp: m.createdAt, // Schema expects 'timestamp', not 'created_at'
612+
createdAt: m.createdAt,
613+
// Include all optional fields for backwards compatibility and usage tracking
614+
...(m.thinking ? { thinking: m.thinking } : {}),
615+
...(m.toolUses && m.toolUses.length > 0 ? { toolUses: m.toolUses } : {}),
616+
...(m.usage ? { usage: m.usage } : {}),
613617
})),
614618
},
615619
'*'

‎apps/agent/src/lib/backend.ts‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -815,6 +815,7 @@ export interface ConversationMessageDto {
815815
thinking?: string;
816816
createdAt: number;
817817
toolUses?: ToolUseDto[];
818+
usage?: TokenUsageDto;
818819
}
819820

820821
export interface ToolUseDto {
@@ -825,6 +826,14 @@ export interface ToolUseDto {
825826
success: boolean;
826827
}
827828

829+
export interface TokenUsageDto {
830+
inputTokens: number;
831+
outputTokens: number;
832+
cacheReadInputTokens?: number;
833+
cacheCreationInputTokens?: number;
834+
totalCostUsd?: number;
835+
}
836+
828837
export interface ConversationDto {
829838
sessionId: string;
830839
title: string;

‎apps/agent/src/stores/tool-store.ts‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,7 @@ export interface ToolState {
118118
) => void;
119119
resetUsage: () => void;
120120
switchSession: (newSessionId: string) => void;
121+
restoreSessionUsage: (sessionId: string, usage: UsageData, processedIds?: string[]) => void;
121122

122123
// Computed values
123124
getContextPercentage: () => number;
@@ -296,6 +297,28 @@ export const useToolStore = create<ToolState>()(
296297
});
297298
},
298299

300+
restoreSessionUsage: (sessionId: string, usage: UsageData, processedIds?: string[]) => {
301+
set((state) => {
302+
// Pre-populate the session cache with usage from persisted data
303+
// This is called when loading a conversation from disk
304+
const existingCache = state.sessionCache[sessionId];
305+
state.sessionCache[sessionId] = {
306+
usage: { ...usage },
307+
processedIds: processedIds ?? existingCache?.processedIds ?? [],
308+
activeTools: existingCache?.activeTools ?? {},
309+
completedTools: existingCache?.completedTools ?? [],
310+
};
311+
312+
// If this is the current session, also update the active usage
313+
if (state.currentSessionId === sessionId) {
314+
state.sessionUsage = { ...usage };
315+
if (processedIds) {
316+
state.processedMessageIds = new Set(processedIds);
317+
}
318+
}
319+
});
320+
},
321+
299322
getContextPercentage: () => {
300323
const state = get();
301324
const maxTokens = MODEL_CONTEXT_WINDOWS[state.model];

‎apps/agent/src/types/protocol.ts‎

Lines changed: 50 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1316,22 +1316,62 @@ export const ConversationLoadingSchema = z
13161316
})
13171317
.strict();
13181318

1319+
// Tool use schema for persisted messages (matches Rust ToolUseDto)
1320+
const PersistedToolUseSchema = z
1321+
.object({
1322+
id: z.string(),
1323+
name: z.string(),
1324+
input: z.record(z.string(), z.unknown()),
1325+
output: z.string().optional(),
1326+
success: z.boolean().default(true),
1327+
})
1328+
.strict();
1329+
1330+
// Token usage schema for persisted messages (matches Rust TokenUsageDto)
1331+
const PersistedTokenUsageSchema = z
1332+
.object({
1333+
inputTokens: z.number(),
1334+
outputTokens: z.number(),
1335+
cacheReadInputTokens: z.number().optional(),
1336+
cacheCreationInputTokens: z.number().optional(),
1337+
totalCostUsd: z.number().optional(),
1338+
})
1339+
.strict();
1340+
1341+
// Message schema for conversation:loaded (matches Rust MessageDto)
1342+
// Uses transform to handle backwards compatibility with old data that may have
1343+
// 'timestamp' instead of 'createdAt', or missing fields
1344+
const PersistedMessageSchema = z
1345+
.object({
1346+
id: z.string(),
1347+
role: z.enum(['user', 'assistant', 'system']),
1348+
content: z.string(),
1349+
thinking: z.string().optional(),
1350+
// Support both old 'timestamp' and new 'createdAt' field names
1351+
createdAt: z.number().optional(),
1352+
timestamp: z.number().optional(),
1353+
toolUses: z.array(PersistedToolUseSchema).optional(),
1354+
usage: PersistedTokenUsageSchema.optional(),
1355+
})
1356+
.strip() // Remove extra fields from old data instead of rejecting
1357+
.transform((msg) => ({
1358+
id: msg.id,
1359+
role: msg.role,
1360+
content: msg.content,
1361+
thinking: msg.thinking,
1362+
// Prefer createdAt, fall back to timestamp, default to 0
1363+
createdAt: msg.createdAt ?? msg.timestamp ?? 0,
1364+
toolUses: msg.toolUses ?? [],
1365+
usage: msg.usage,
1366+
}));
1367+
13191368
export const ConversationLoadedSchema = z
13201369
.object({
13211370
type: z.literal('conversation:loaded'),
13221371
uuid: UUIDSchema,
13231372
session_id: SessionIdSchema,
13241373
title: z.string(),
1325-
messages: z.array(
1326-
z
1327-
.object({
1328-
id: z.string(),
1329-
role: z.enum(['user', 'assistant']),
1330-
content: z.string(),
1331-
timestamp: z.number(),
1332-
})
1333-
.strict()
1334-
),
1374+
messages: z.array(PersistedMessageSchema),
13351375
})
13361376
.strict();
13371377

‎crates/common/conversations/src/lib.rs‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,9 @@ pub struct Message {
5656
/// Tool uses in this message
5757
#[serde(default, skip_serializing_if = "Vec::is_empty")]
5858
pub tool_uses: Vec<ToolUse>,
59+
/// Token usage for this message (assistant messages only)
60+
#[serde(default, skip_serializing_if = "Option::is_none")]
61+
pub usage: Option<TokenUsage>,
5962
}
6063

6164
/// A tool use within a message
@@ -76,6 +79,27 @@ pub struct ToolUse {
7679
pub success: bool,
7780
}
7881

82+
/// Token usage statistics for a message
83+
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
84+
#[serde(rename_all = "camelCase")]
85+
pub struct TokenUsage {
86+
/// Input tokens consumed
87+
#[serde(default)]
88+
pub input_tokens: u32,
89+
/// Output tokens generated
90+
#[serde(default)]
91+
pub output_tokens: u32,
92+
/// Tokens read from cache
93+
#[serde(default, skip_serializing_if = "Option::is_none")]
94+
pub cache_read_input_tokens: Option<u32>,
95+
/// Tokens used to create cache
96+
#[serde(default, skip_serializing_if = "Option::is_none")]
97+
pub cache_creation_input_tokens: Option<u32>,
98+
/// Total cost in USD
99+
#[serde(default, skip_serializing_if = "Option::is_none")]
100+
pub total_cost_usd: Option<f64>,
101+
}
102+
79103
// ============================================
80104
// Conversation Types
81105
// ============================================
@@ -641,6 +665,7 @@ mod tests {
641665
thinking: None,
642666
created_at: current_timestamp(),
643667
tool_uses: Vec::new(),
668+
usage: None,
644669
}
645670
}
646671

0 commit comments

Comments
 (0)