Skip to content

Commit 2272b14

Browse files
committed
fix: preserve opencode token usage in live status parsing
1 parent 40f1b9d commit 2272b14

File tree

2 files changed

+158
-28
lines changed

2 files changed

+158
-28
lines changed

packages/agents/test/session-inspector.test.ts

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -289,6 +289,90 @@ describe("session inspector", () => {
289289
expect(text).not.toContain("cost 0");
290290
});
291291

292+
it("hydrates OpenCode token usage from nested message info shape", () => {
293+
const startedAt = Date.now();
294+
const state = buildSessionMessageState([
295+
{
296+
timestamp: startedAt,
297+
type: "message.updated",
298+
data: {
299+
payload: {
300+
type: "message.updated",
301+
properties: {
302+
message: {
303+
info: {
304+
title: "Refactor live status parser",
305+
modelId: "gpt-5.3-codex",
306+
agentName: "build",
307+
usage: {
308+
total_tokens: 1024,
309+
input_tokens: 200,
310+
output_tokens: 120,
311+
reasoning_tokens: 4,
312+
cache_tokens: {
313+
input_tokens: 700,
314+
output_tokens: 0,
315+
},
316+
},
317+
},
318+
},
319+
},
320+
},
321+
},
322+
},
323+
]);
324+
325+
expect(state.sessionTitle).toBeUndefined();
326+
expect(state.model).toBe("gpt-5.3-codex");
327+
expect(state.agent).toBe("build");
328+
expect(state.tokenUsage?.total).toBe(1024);
329+
expect(state.tokenUsage?.cacheRead).toBe(700);
330+
});
331+
332+
it("does not clear token usage when later message.updated has empty usage object", () => {
333+
const startedAt = Date.now();
334+
const state = buildSessionMessageState([
335+
{
336+
timestamp: startedAt,
337+
type: "message.updated",
338+
data: {
339+
payload: {
340+
type: "message.updated",
341+
properties: {
342+
info: {
343+
modelID: "gpt-5.3-codex",
344+
tokens: {
345+
total: 2048,
346+
input: 500,
347+
output: 200,
348+
reasoning: 10,
349+
cache: { read: 1338, write: 0 },
350+
},
351+
},
352+
},
353+
},
354+
},
355+
},
356+
{
357+
timestamp: startedAt + 1,
358+
type: "message.updated",
359+
data: {
360+
payload: {
361+
type: "message.updated",
362+
properties: {
363+
info: {
364+
tokens: {},
365+
},
366+
},
367+
},
368+
},
369+
},
370+
]);
371+
372+
expect(state.tokenUsage?.total).toBe(2048);
373+
expect(state.tokenUsage?.input).toBe(500);
374+
});
375+
292376
it("parses todo.updated aliases from wrapped payload events", () => {
293377
const now = Date.now();
294378
const state = buildSessionMessageState([

packages/utils/session-inspector.ts

Lines changed: 74 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -81,42 +81,88 @@ function applySessionUpdatedEvent(state: SessionMessageState, eventProps: Record
8181
state.sessionTitle = sessionTitle;
8282
}
8383

84+
function asRecord(value: unknown): Record<string, unknown> | undefined {
85+
return value && typeof value === "object" && !Array.isArray(value)
86+
? value as Record<string, unknown>
87+
: undefined;
88+
}
89+
90+
function asNumber(value: unknown): number | undefined {
91+
const parsed = Number(value);
92+
return Number.isFinite(parsed) ? parsed : undefined;
93+
}
94+
95+
function extractMessageInfo(eventProps: Record<string, unknown>): Record<string, unknown> | undefined {
96+
const direct = asRecord(eventProps.info);
97+
if (direct) return direct;
98+
99+
const message = asRecord(eventProps.message);
100+
if (!message) return undefined;
101+
102+
const nestedInfo = asRecord(message.info);
103+
if (nestedInfo) return nestedInfo;
104+
return message;
105+
}
106+
84107
function applyMessageUpdatedEvent(state: SessionMessageState, eventProps: Record<string, unknown>): void {
85-
const info = eventProps.info as
86-
| {
87-
modelID?: unknown;
88-
agent?: unknown;
89-
tokens?: {
90-
total?: unknown;
91-
input?: unknown;
92-
output?: unknown;
93-
reasoning?: unknown;
94-
cache?: { read?: unknown; write?: unknown };
95-
};
96-
cost?: unknown;
97-
}
98-
| undefined;
99-
if (typeof info?.modelID === "string" && info.modelID.trim()) {
100-
state.model = info.modelID;
108+
const info = extractMessageInfo(eventProps);
109+
if (!info) return;
110+
111+
const modelCandidate =
112+
(typeof info.modelID === "string" ? info.modelID : undefined)
113+
?? (typeof info.modelId === "string" ? info.modelId : undefined)
114+
?? (typeof info.model === "string" ? info.model : undefined);
115+
if (typeof modelCandidate === "string" && modelCandidate.trim()) {
116+
state.model = modelCandidate;
101117
}
102118

103-
if (typeof info?.agent === "string" && info.agent.trim()) {
104-
state.agent = info.agent;
119+
const agentCandidate =
120+
(typeof info.agent === "string" ? info.agent : undefined)
121+
?? (typeof info.agentName === "string" ? info.agentName : undefined)
122+
?? (typeof info.assistant === "string" ? info.assistant : undefined);
123+
if (typeof agentCandidate === "string" && agentCandidate.trim()) {
124+
state.agent = agentCandidate;
105125
}
106126

107-
const tokens = info?.tokens;
127+
const tokenContainer =
128+
asRecord(info.tokens)
129+
?? asRecord(info.tokenUsage)
130+
?? asRecord(info.usage);
131+
const cacheContainer =
132+
asRecord(tokenContainer?.cache)
133+
?? asRecord(tokenContainer?.cache_tokens)
134+
?? asRecord(tokenContainer?.cacheTokens);
135+
136+
const tokens = tokenContainer;
108137
if (tokens && typeof tokens === "object") {
109-
const input = Number(tokens.input ?? 0) || 0;
110-
const output = Number(tokens.output ?? 0) || 0;
111-
const reasoning = Number(tokens.reasoning ?? 0) || 0;
112-
const cacheRead = Number(tokens.cache?.read ?? 0) || 0;
113-
const cacheWrite = Number(tokens.cache?.write ?? 0) || 0;
114-
const reportedTotal = Number(tokens.total);
115-
const total = Number.isFinite(reportedTotal)
138+
const hasTokenSignal = [
139+
tokens.input,
140+
tokens.input_tokens,
141+
tokens.output,
142+
tokens.output_tokens,
143+
tokens.reasoning,
144+
tokens.reasoning_tokens,
145+
tokens.total,
146+
tokens.total_tokens,
147+
cacheContainer?.read,
148+
cacheContainer?.write,
149+
cacheContainer?.input_tokens,
150+
cacheContainer?.output_tokens,
151+
].some((value) => value !== undefined && value !== null);
152+
if (!hasTokenSignal) {
153+
return;
154+
}
155+
156+
const input = asNumber(tokens.input ?? tokens.input_tokens) ?? 0;
157+
const output = asNumber(tokens.output ?? tokens.output_tokens) ?? 0;
158+
const reasoning = asNumber(tokens.reasoning ?? tokens.reasoning_tokens) ?? 0;
159+
const cacheRead = asNumber(cacheContainer?.read ?? cacheContainer?.input_tokens) ?? 0;
160+
const cacheWrite = asNumber(cacheContainer?.write ?? cacheContainer?.output_tokens) ?? 0;
161+
const reportedTotal = asNumber(tokens.total ?? tokens.total_tokens);
162+
const total = typeof reportedTotal === "number" && Number.isFinite(reportedTotal)
116163
? reportedTotal
117164
: input + output + reasoning + cacheRead + cacheWrite;
118-
const parsedCost = Number(info?.cost);
119-
const cost = Number.isFinite(parsedCost) ? parsedCost : undefined;
165+
const cost = asNumber(info.cost ?? tokens.cost);
120166
state.tokenUsage = {
121167
input,
122168
output,

0 commit comments

Comments
 (0)