Skip to content

Commit 907250c

Browse files
authored
fix(agent): persist Codex prompt token usage (#3506)
1 parent bdb4d5c commit 907250c

7 files changed

Lines changed: 162 additions & 78 deletions

File tree

packages/agent/src/adapters/codex-app-server/codex-app-server-agent.test.ts

Lines changed: 61 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1961,9 +1961,23 @@ describe("CodexAppServerAgent", () => {
19611961
} as unknown as PromptRequest);
19621962

19631963
// The single turn/completed resolves both the original and the folded prompt.
1964+
stub.emit("thread/tokenUsage/updated", {
1965+
tokenUsage: {
1966+
last: {
1967+
totalTokens: 45,
1968+
inputTokens: 30,
1969+
cachedInputTokens: 5,
1970+
outputTokens: 10,
1971+
},
1972+
},
1973+
});
19641974
stub.emit("turn/completed", { turn: { status: "completed" } });
1965-
expect((await first).stopReason).toBe("end_turn");
1966-
expect((await second).stopReason).toBe("end_turn");
1975+
const [firstResult, secondResult] = await Promise.all([first, second]);
1976+
expect(firstResult).toMatchObject({
1977+
stopReason: "end_turn",
1978+
usage: { totalTokens: 45 },
1979+
});
1980+
expect(secondResult).toEqual({ stopReason: "end_turn" });
19671981

19681982
const steer = stub.requests.find((r) => r.method === "turn/steer");
19691983
expect(steer?.params).toMatchObject({
@@ -2126,7 +2140,19 @@ describe("CodexAppServerAgent", () => {
21262140
},
21272141
});
21282142
stub.emit("turn/completed", { turn: { status: "completed" } });
2129-
await done;
2143+
const result = await done;
2144+
2145+
expect(result).toEqual({
2146+
stopReason: "end_turn",
2147+
usage: {
2148+
inputTokens: 60,
2149+
outputTokens: 30,
2150+
cachedReadTokens: 10,
2151+
cachedWriteTokens: 0,
2152+
thoughtTokens: 5,
2153+
totalTokens: 100,
2154+
},
2155+
});
21302156

21312157
const turnComplete = extNotifications.find(
21322158
(n) => n.method === "_posthog/turn_complete",
@@ -2818,6 +2844,16 @@ describe("CodexAppServerAgent", () => {
28182844
text: "The implementation plan is ready.",
28192845
},
28202846
});
2847+
stub.emit("thread/tokenUsage/updated", {
2848+
tokenUsage: {
2849+
last: {
2850+
totalTokens: 30,
2851+
inputTokens: 20,
2852+
outputTokens: 10,
2853+
reasoningOutputTokens: 2,
2854+
},
2855+
},
2856+
});
28212857
stub.emit("turn/completed", {
28222858
turn: { id: "turn_1", status: "completed" },
28232859
});
@@ -2826,10 +2862,31 @@ describe("CodexAppServerAgent", () => {
28262862
await waitUntil(
28272863
() => stub.requests.filter((r) => r.method === "turn/start").length >= 2,
28282864
);
2865+
stub.emit("thread/tokenUsage/updated", {
2866+
tokenUsage: {
2867+
last: {
2868+
totalTokens: 50,
2869+
inputTokens: 35,
2870+
cachedInputTokens: 5,
2871+
outputTokens: 10,
2872+
reasoningOutputTokens: 3,
2873+
},
2874+
},
2875+
});
28292876
stub.emit("turn/completed", {
28302877
turn: { id: "turn_2", status: "completed" },
28312878
});
2832-
expect((await done).stopReason).toBe("end_turn");
2879+
expect(await done).toEqual({
2880+
stopReason: "end_turn",
2881+
usage: {
2882+
inputTokens: 55,
2883+
outputTokens: 20,
2884+
cachedReadTokens: 5,
2885+
cachedWriteTokens: 0,
2886+
thoughtTokens: 5,
2887+
totalTokens: 80,
2888+
},
2889+
});
28332890

28342891
// The approval renders as the plan-approval UI (switch_mode + the plan text).
28352892
expect(permissionRequests).toHaveLength(1);

packages/agent/src/adapters/codex-app-server/codex-app-server-agent.ts

Lines changed: 52 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,6 @@ import {
5252
} from "./app-server-client";
5353
import { handleServerRequest } from "./approvals";
5454
import {
55-
type AccumulatedUsage,
5655
buildSdkSessionParams,
5756
buildTurnCompleteParams,
5857
buildUsageBreakdownParams,
@@ -162,6 +161,31 @@ function parseGoalCommand(prompt: PromptRequest["prompt"]): GoalCommand | null {
162161
}
163162
}
164163

164+
function mergePromptUsage(
165+
left: PromptResponse["usage"],
166+
right: PromptResponse["usage"],
167+
): PromptResponse["usage"] {
168+
if (!left) return right;
169+
if (!right) return left;
170+
return {
171+
inputTokens: left.inputTokens + right.inputTokens,
172+
outputTokens: left.outputTokens + right.outputTokens,
173+
cachedReadTokens:
174+
(left.cachedReadTokens ?? 0) + (right.cachedReadTokens ?? 0),
175+
cachedWriteTokens:
176+
(left.cachedWriteTokens ?? 0) + (right.cachedWriteTokens ?? 0),
177+
thoughtTokens: (left.thoughtTokens ?? 0) + (right.thoughtTokens ?? 0),
178+
totalTokens: left.totalTokens + right.totalTokens,
179+
};
180+
}
181+
182+
function mergePromptResponses(
183+
left: PromptResponse,
184+
right: PromptResponse,
185+
): PromptResponse {
186+
return { ...right, usage: mergePromptUsage(left.usage, right.usage) };
187+
}
188+
165189
// The native app-server owns its config; BaseAcpAgent only calls dispose() on this.
166190
class NoopSettingsManager implements BaseSettingsManager {
167191
constructor(private cwd: string) {}
@@ -219,7 +243,7 @@ export class CodexAppServerAgent extends BaseAcpAgent {
219243
/** The in-flight turn's <proposed_plan>, streamed or completed (drives the implement handoff). */
220244
private planProposal?: { itemId: string; text: string };
221245
/** Idle signal deferred while the plan handoff keeps this prompt busy. */
222-
private deferredTurnComplete?: { usage: AccumulatedUsage };
246+
private deferredTurnComplete?: { usage: PromptResponse["usage"] };
223247
/** Settles the pending plan-approval race on cancel/close/preempting prompt. */
224248
private planHandoffCancel?: () => void;
225249
private readonly mcp = new McpManager();
@@ -727,15 +751,16 @@ export class CodexAppServerAgent extends BaseAcpAgent {
727751
return undefined;
728752
});
729753
this.turns.onSteered(steerRes?.turnId);
730-
return { stopReason: await this.turns.awaitCompletion() };
754+
const response = await this.turns.awaitCompletion();
755+
return { stopReason: response.stopReason };
731756
}
732757
if (this.turns.isPending) {
733758
// A turn is pending but has no turnId yet, so we can't steer; fail fast.
734759
throw new Error("prompt() called while a turn is already in progress");
735760
}
736761

737-
const stopReason = await this.runTurn(input);
738-
return { stopReason: await this.maybeOfferPlanImplementation(stopReason) };
762+
const response = await this.runTurn(input);
763+
return this.maybeOfferPlanImplementation(response);
739764
}
740765

741766
private async handleGoalCommand(command: GoalCommand): Promise<void> {
@@ -850,7 +875,7 @@ export class CodexAppServerAgent extends BaseAcpAgent {
850875
}
851876

852877
/** Start one codex turn and await its completion. */
853-
private async runTurn(input: CodexUserInput[]): Promise<StopReason> {
878+
private async runTurn(input: CodexUserInput[]): Promise<PromptResponse> {
854879
this.lastAgentMessage = "";
855880
this.resetUsage();
856881
this.planProposal = undefined;
@@ -895,12 +920,12 @@ export class CodexAppServerAgent extends BaseAcpAgent {
895920
* back into another plan turn, whose revised plan prompts again.
896921
*/
897922
private async maybeOfferPlanImplementation(
898-
stopReason: StopReason,
899-
): Promise<StopReason> {
900-
let reason = stopReason;
923+
response: PromptResponse,
924+
): Promise<PromptResponse> {
925+
let result = response;
901926
try {
902927
while (
903-
reason === "end_turn" &&
928+
result.stopReason === "end_turn" &&
904929
this.config.mode === "plan" &&
905930
this.planProposal &&
906931
!this.session.cancelled
@@ -911,7 +936,7 @@ export class CodexAppServerAgent extends BaseAcpAgent {
911936
// Re-check after the await: a cancel that raced the response wins, so a
912937
// late accept can never start implementation on a cancelled prompt.
913938
if (this.session.cancelled) {
914-
reason = "cancelled";
939+
result = { ...result, stopReason: "cancelled" };
915940
break;
916941
}
917942
// A picker change while approval was open owns the mode. Never let a
@@ -921,19 +946,25 @@ export class CodexAppServerAgent extends BaseAcpAgent {
921946
this.config.setOption("mode", outcome.mode);
922947
this.emitCurrentMode(outcome.mode);
923948
this.emitConfigOptions();
924-
reason = await this.runFollowUpTurn(IMPLEMENT_PLAN_MESSAGE);
949+
result = mergePromptResponses(
950+
result,
951+
await this.runFollowUpTurn(IMPLEMENT_PLAN_MESSAGE),
952+
);
925953
break;
926954
}
927955
if (outcome.kind === "feedback") {
928-
reason = await this.runFollowUpTurn(outcome.feedback);
956+
result = mergePromptResponses(
957+
result,
958+
await this.runFollowUpTurn(outcome.feedback),
959+
);
929960
continue;
930961
}
931962
break;
932963
}
933964
} finally {
934-
await this.flushDeferredTurnComplete(reason);
965+
await this.flushDeferredTurnComplete(result.stopReason);
935966
}
936-
return reason;
967+
return result;
937968
}
938969

939970
/**
@@ -949,7 +980,7 @@ export class CodexAppServerAgent extends BaseAcpAgent {
949980
}
950981

951982
/** Run an adapter-initiated turn, echoed as a user message like a host prompt. */
952-
private async runFollowUpTurn(text: string): Promise<StopReason> {
983+
private async runFollowUpTurn(text: string): Promise<PromptResponse> {
953984
this.broadcastUserInput([{ type: "text", text }]);
954985
return this.runTurn(toCodexInput([{ type: "text", text }]));
955986
}
@@ -1448,7 +1479,10 @@ export class CodexAppServerAgent extends BaseAcpAgent {
14481479
await this.emitTurnCompleteSignal(reason, usage);
14491480
await this.emitUsageBreakdown(contextUsed);
14501481
}
1451-
pending.resolve(reason);
1482+
pending.resolve({
1483+
stopReason: reason,
1484+
...(usage ? { usage } : {}),
1485+
});
14521486
}
14531487

14541488
/** Whether maybeOfferPlanImplementation will run for a turn that ended this way. */
@@ -1464,7 +1498,7 @@ export class CodexAppServerAgent extends BaseAcpAgent {
14641498
/** Emit the cloud idle signal `_posthog/turn_complete` (only with a taskRunId). */
14651499
private async emitTurnCompleteSignal(
14661500
reason: StopReason,
1467-
usage: AccumulatedUsage,
1501+
usage: PromptResponse["usage"],
14681502
): Promise<void> {
14691503
if (!this.sessionId || !this.taskRunId) return;
14701504
await this.client

packages/agent/src/adapters/codex-app-server/ext-notifications.ts

Lines changed: 16 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -50,13 +50,12 @@ export interface TurnCompleteParams {
5050
usage: TurnCompleteUsage;
5151
}
5252

53-
/** The four component counts the caller accumulates; total is computed here. */
54-
export interface AccumulatedUsage {
55-
inputTokens: number;
56-
outputTokens: number;
57-
cachedReadTokens: number;
58-
cachedWriteTokens: number;
59-
}
53+
type TurnCompleteUsageInput = {
54+
inputTokens?: number | null;
55+
outputTokens?: number | null;
56+
cachedReadTokens?: number | null;
57+
cachedWriteTokens?: number | null;
58+
};
6059

6160
/**
6261
* `_posthog/turn_complete` — fired when a prompt turn finishes. `totalTokens` is the
@@ -65,21 +64,22 @@ export interface AccumulatedUsage {
6564
export function buildTurnCompleteParams(
6665
sessionId: string,
6766
stopReason: StopReason,
68-
usage: AccumulatedUsage,
67+
usage?: TurnCompleteUsageInput | null,
6968
): TurnCompleteParams {
69+
const inputTokens = usage?.inputTokens ?? 0;
70+
const outputTokens = usage?.outputTokens ?? 0;
71+
const cachedReadTokens = usage?.cachedReadTokens ?? 0;
72+
const cachedWriteTokens = usage?.cachedWriteTokens ?? 0;
7073
return {
7174
sessionId,
7275
stopReason,
7376
usage: {
74-
inputTokens: usage.inputTokens,
75-
outputTokens: usage.outputTokens,
76-
cachedReadTokens: usage.cachedReadTokens,
77-
cachedWriteTokens: usage.cachedWriteTokens,
77+
inputTokens,
78+
outputTokens,
79+
cachedReadTokens,
80+
cachedWriteTokens,
7881
totalTokens:
79-
usage.inputTokens +
80-
usage.outputTokens +
81-
usage.cachedReadTokens +
82-
usage.cachedWriteTokens,
82+
inputTokens + outputTokens + cachedReadTokens + cachedWriteTokens,
8383
},
8484
};
8585
}

packages/agent/src/adapters/codex-app-server/turn-controller.test.ts

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,8 @@ describe("TurnController", () => {
1818
expect(turns.activeTurnId).toBeUndefined();
1919
expect(turns.claim()).toBeUndefined();
2020

21-
pending?.resolve("end_turn");
22-
await expect(completion).resolves.toBe("end_turn");
21+
pending?.resolve({ stopReason: "end_turn" });
22+
await expect(completion).resolves.toEqual({ stopReason: "end_turn" });
2323
});
2424

2525
it("finishPrompt for an older turn does not wipe a newer turn's pending state", async () => {
@@ -36,15 +36,15 @@ describe("TurnController", () => {
3636
turns.onStarted("turn-b");
3737

3838
// Turn A's prompt() resolves and its finally runs; it must not clear turn B.
39-
claimedA?.resolve("end_turn");
40-
await expect(a.completion).resolves.toBe("end_turn");
39+
claimedA?.resolve({ stopReason: "end_turn" });
40+
await expect(a.completion).resolves.toEqual({ stopReason: "end_turn" });
4141
turns.finishPrompt(a.turn);
4242

4343
expect(turns.isRunning).toBe(true);
4444
const claimedB = turns.claim();
4545
expect(claimedB).toBeDefined();
46-
claimedB?.resolve("end_turn");
47-
await expect(b.completion).resolves.toBe("end_turn");
46+
claimedB?.resolve({ stopReason: "end_turn" });
47+
await expect(b.completion).resolves.toEqual({ stopReason: "end_turn" });
4848
});
4949

5050
it("finishPrompt with the current turn token clears the pending slot", () => {
@@ -84,7 +84,7 @@ describe("TurnController", () => {
8484
turns.markInterrupted();
8585

8686
turns.close("cancelled");
87-
await expect(completion).resolves.toBe("cancelled");
87+
await expect(completion).resolves.toEqual({ stopReason: "cancelled" });
8888
expect(turns.isPending).toBe(false);
8989
expect(turns.activeTurnId).toBeUndefined();
9090
expect(turns.shouldDropCompletion("turn-1")).toBe(false);

packages/agent/src/adapters/codex-app-server/turn-controller.ts

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
1-
import type { StopReason } from "@agentclientprotocol/sdk";
1+
import type { PromptResponse, StopReason } from "@agentclientprotocol/sdk";
22

33
interface PendingTurn {
4-
resolve: (reason: StopReason) => void;
4+
resolve: (response: PromptResponse) => void;
55
reject: (err: Error) => void;
66
}
77

@@ -13,13 +13,13 @@ interface PendingTurn {
1313
export class TurnController {
1414
private turnId?: string;
1515
private pending?: PendingTurn;
16-
private completion?: Promise<StopReason>;
16+
private completion?: Promise<PromptResponse>;
1717
private generation = 0;
1818
private readonly cancelled = new Set<string>();
1919

20-
begin(): { completion: Promise<StopReason>; turn: number } {
20+
begin(): { completion: Promise<PromptResponse>; turn: number } {
2121
const turn = ++this.generation;
22-
this.completion = new Promise<StopReason>((resolve, reject) => {
22+
this.completion = new Promise<PromptResponse>((resolve, reject) => {
2323
this.pending = { resolve, reject };
2424
});
2525
return { completion: this.completion, turn };
@@ -49,8 +49,8 @@ export class TurnController {
4949
}
5050

5151
/** Await the in-flight turn's completion (the steer path reuses the original). */
52-
awaitCompletion(): Promise<StopReason> {
53-
return this.completion ?? Promise.resolve("end_turn");
52+
awaitCompletion(): Promise<PromptResponse> {
53+
return this.completion ?? Promise.resolve({ stopReason: "end_turn" });
5454
}
5555

5656
/** Atomically claim the pending turn (clears the slot + turnId synchronously), or undefined if already claimed. */
@@ -96,7 +96,7 @@ export class TurnController {
9696
/** Resolve and clear everything on session close. */
9797
close(reason: StopReason): void {
9898
this.turnId = undefined;
99-
this.pending?.resolve(reason);
99+
this.pending?.resolve({ stopReason: reason });
100100
this.pending = undefined;
101101
this.completion = undefined;
102102
this.cancelled.clear();

0 commit comments

Comments
 (0)