Skip to content

Commit 9a94194

Browse files
Cyrus Forbessteipete
authored andcommitted
fix: avoid cumulative codex usage as context (#64669) (thanks @cyrusaf)
1 parent 4e2541e commit 9a94194

3 files changed

Lines changed: 156 additions & 10 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ Docs: https://docs.openclaw.ai
1111
- Agents/channels: route cross-agent subagent spawns through the target agent's bound channel account while preserving peer and workspace/role-scoped bindings, so child sessions no longer inherit the caller's account in shared rooms, workspaces, or multi-account setups. (#67508) Thanks @lukeboyett and @gumadeiras.
1212
- Telegram/callbacks: treat permanent callback edit errors as completed updates so stale command pagination buttons no longer wedge the update watermark and block newer Telegram updates. (#68588) Thanks @Lucenx9.
1313
- Browser/CDP: allow the selected remote CDP profile host for CDP health and control checks without widening browser navigation SSRF policy, so WSL-to-Windows Chrome endpoints no longer appear offline under strict defaults. Fixes #68108. (#68207) Thanks @Mlightsnow.
14+
- Codex: stop cumulative app-server token totals from being treated as fresh context usage, so session status no longer reports inflated context percentages after long Codex threads. (#64669) Thanks @cyrusaf.
1415

1516
## 2026.4.18
1617

extensions/codex/src/app-server/event-projector.test.ts

Lines changed: 96 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,13 @@ describe("CodexAppServerEventProjector", () => {
5050
turnId: "turn-1",
5151
tokenUsage: {
5252
total: {
53-
totalTokens: 12,
53+
totalTokens: 900_000,
54+
inputTokens: 700_000,
55+
cachedInputTokens: 100_000,
56+
outputTokens: 100_000,
57+
},
58+
last: {
59+
totalTokens: 14,
5460
inputTokens: 5,
5561
cachedInputTokens: 2,
5662
outputTokens: 7,
@@ -83,10 +89,98 @@ describe("CodexAppServerEventProjector", () => {
8389
expect(result.assistantTexts).toEqual(["hello"]);
8490
expect(result.messagesSnapshot.map((message) => message.role)).toEqual(["user", "assistant"]);
8591
expect(result.lastAssistant?.content).toEqual([{ type: "text", text: "hello" }]);
86-
expect(result.attemptUsage).toMatchObject({ input: 5, output: 7, cacheRead: 2, total: 12 });
92+
expect(result.attemptUsage).toMatchObject({ input: 5, output: 7, cacheRead: 2, total: 14 });
93+
expect(result.lastAssistant?.usage).toMatchObject({
94+
input: 5,
95+
output: 7,
96+
cacheRead: 2,
97+
totalTokens: 14,
98+
});
8799
expect(result.replayMetadata.replaySafe).toBe(true);
88100
});
89101

102+
it("does not treat cumulative-only token usage as fresh context usage", async () => {
103+
const params = createParams();
104+
const projector = new CodexAppServerEventProjector(params, "thread-1", "turn-1");
105+
106+
await projector.handleNotification({
107+
method: "item/agentMessage/delta",
108+
params: { threadId: "thread-1", turnId: "turn-1", itemId: "msg-1", delta: "done" },
109+
});
110+
await projector.handleNotification({
111+
method: "thread/tokenUsage/updated",
112+
params: {
113+
threadId: "thread-1",
114+
turnId: "turn-1",
115+
tokenUsage: {
116+
total: {
117+
totalTokens: 1_000_000,
118+
inputTokens: 999_000,
119+
cachedInputTokens: 500,
120+
outputTokens: 500,
121+
},
122+
},
123+
},
124+
});
125+
126+
const result = projector.buildResult({
127+
didSendViaMessagingTool: false,
128+
messagingToolSentTexts: [],
129+
messagingToolSentMediaUrls: [],
130+
messagingToolSentTargets: [],
131+
});
132+
133+
expect(result.assistantTexts).toEqual(["done"]);
134+
expect(result.attemptUsage).toBeUndefined();
135+
expect(result.lastAssistant?.usage).toMatchObject({
136+
input: 0,
137+
output: 0,
138+
cacheRead: 0,
139+
totalTokens: 0,
140+
});
141+
});
142+
143+
it("normalizes snake_case current token usage fields", async () => {
144+
const params = createParams();
145+
const projector = new CodexAppServerEventProjector(params, "thread-1", "turn-1");
146+
147+
await projector.handleNotification({
148+
method: "item/agentMessage/delta",
149+
params: { threadId: "thread-1", turnId: "turn-1", itemId: "msg-1", delta: "done" },
150+
});
151+
await projector.handleNotification({
152+
method: "thread/tokenUsage/updated",
153+
params: {
154+
threadId: "thread-1",
155+
turnId: "turn-1",
156+
tokenUsage: {
157+
total: { total_tokens: 1_000_000 },
158+
last_token_usage: {
159+
total_tokens: 20,
160+
input_tokens: 8,
161+
cached_input_tokens: 3,
162+
output_tokens: 9,
163+
},
164+
},
165+
},
166+
});
167+
168+
const result = projector.buildResult({
169+
didSendViaMessagingTool: false,
170+
messagingToolSentTexts: [],
171+
messagingToolSentMediaUrls: [],
172+
messagingToolSentTargets: [],
173+
});
174+
175+
expect(result.attemptUsage).toMatchObject({ input: 8, output: 9, cacheRead: 3, total: 20 });
176+
expect(result.lastAssistant?.usage).toMatchObject({
177+
input: 8,
178+
output: 9,
179+
cacheRead: 3,
180+
totalTokens: 20,
181+
});
182+
});
183+
90184
it("keeps intermediate agentMessage items out of the final visible reply", async () => {
91185
const onAssistantMessageStart = vi.fn();
92186
const onPartialReply = vi.fn();

extensions/codex/src/app-server/event-projector.ts

Lines changed: 59 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,15 @@ const ZERO_USAGE: Usage = {
4242
},
4343
};
4444

45+
const CURRENT_TOKEN_USAGE_KEYS = [
46+
"last",
47+
"current",
48+
"lastCall",
49+
"lastCallUsage",
50+
"lastTokenUsage",
51+
"last_token_usage",
52+
] as const;
53+
4554
export class CodexAppServerEventProjector {
4655
private readonly assistantTextByItem = new Map<string, string>();
4756
private readonly assistantItemOrder: string[] = [];
@@ -327,16 +336,16 @@ export class CodexAppServerEventProjector {
327336

328337
private handleTokenUsage(params: JsonObject): void {
329338
const tokenUsage = isJsonObject(params.tokenUsage) ? params.tokenUsage : undefined;
330-
const total = tokenUsage && isJsonObject(tokenUsage.total) ? tokenUsage.total : undefined;
331-
if (!total) {
339+
const current =
340+
(tokenUsage ? readFirstJsonObject(tokenUsage, CURRENT_TOKEN_USAGE_KEYS) : undefined) ??
341+
readFirstJsonObject(params, CURRENT_TOKEN_USAGE_KEYS);
342+
if (!current) {
332343
return;
333344
}
334-
this.tokenUsage = normalizeUsage({
335-
input: readNumber(total, "inputTokens"),
336-
output: readNumber(total, "outputTokens"),
337-
cacheRead: readNumber(total, "cachedInputTokens"),
338-
total: readNumber(total, "totalTokens"),
339-
});
345+
const usage = normalizeCodexTokenUsage(current);
346+
if (usage) {
347+
this.tokenUsage = usage;
348+
}
340349
}
341350

342351
private async handleTurnCompleted(params: JsonObject): Promise<void> {
@@ -524,6 +533,48 @@ function readNumber(record: JsonObject, key: string): number | undefined {
524533
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
525534
}
526535

536+
function readFirstJsonObject(record: JsonObject, keys: readonly string[]): JsonObject | undefined {
537+
for (const key of keys) {
538+
const value = record[key];
539+
if (isJsonObject(value)) {
540+
return value;
541+
}
542+
}
543+
return undefined;
544+
}
545+
546+
function readNumberAlias(record: JsonObject, keys: readonly string[]): number | undefined {
547+
for (const key of keys) {
548+
const value = readNumber(record, key);
549+
if (value !== undefined) {
550+
return value;
551+
}
552+
}
553+
return undefined;
554+
}
555+
556+
function normalizeCodexTokenUsage(record: JsonObject): NormalizedUsage | undefined {
557+
return normalizeUsage({
558+
input: readNumberAlias(record, ["inputTokens", "input_tokens", "input", "promptTokens"]),
559+
output: readNumberAlias(record, ["outputTokens", "output_tokens", "output"]),
560+
cacheRead: readNumberAlias(record, [
561+
"cachedInputTokens",
562+
"cached_input_tokens",
563+
"cacheRead",
564+
"cache_read",
565+
"cache_read_input_tokens",
566+
"cached_tokens",
567+
]),
568+
cacheWrite: readNumberAlias(record, [
569+
"cacheWrite",
570+
"cache_write",
571+
"cacheCreationInputTokens",
572+
"cache_creation_input_tokens",
573+
]),
574+
total: readNumberAlias(record, ["totalTokens", "total_tokens", "total"]),
575+
});
576+
}
577+
527578
function splitPlanText(text: string): string[] {
528579
return text
529580
.split(/\r?\n/)

0 commit comments

Comments
 (0)