fix(agents): harden compaction usage accounting when usage is missing

This commit is contained in:
Stephen Parker 2026-03-04 20:50:52 +00:00 committed by Josh Lehman
parent 48b3c4a043
commit 6bb2d9d79b
No known key found for this signature in database
GPG Key ID: D141B425AC7F876B
2 changed files with 107 additions and 3 deletions

View File

@ -330,6 +330,55 @@ describe("sanitizeSessionHistory", () => {
expect(assistants[1]?.usage).toBeDefined();
});
it("adds a zeroed assistant usage snapshot when usage is missing", async () => {
vi.mocked(helpers.isGoogleModelApi).mockReturnValue(false);
const messages = castAgentMessages([
{ role: "user", content: "question" },
{
role: "assistant",
content: [{ type: "text", text: "answer without usage" }],
},
]);
const result = await sanitizeOpenAIHistory(messages);
const assistant = result.find((message) => message.role === "assistant") as
| (AgentMessage & { usage?: unknown })
| undefined;
expect(assistant?.usage).toEqual(makeZeroUsageSnapshot());
});
it("normalizes mixed partial assistant usage fields to numeric totals", async () => {
vi.mocked(helpers.isGoogleModelApi).mockReturnValue(false);
const messages = castAgentMessages([
{ role: "user", content: "question" },
{
role: "assistant",
content: [{ type: "text", text: "answer with partial usage" }],
usage: {
output: 3,
cache_read_input_tokens: 9,
},
},
]);
const result = await sanitizeOpenAIHistory(messages);
const assistant = result.find((message) => message.role === "assistant") as
| (AgentMessage & { usage?: unknown })
| undefined;
expect(assistant?.usage).toEqual({
...makeZeroUsageSnapshot(),
input: 0,
output: 3,
cacheRead: 9,
cacheWrite: 0,
totalTokens: 12,
});
});
it("drops stale usage when compaction summary appears before kept assistant messages", async () => {
vi.mocked(helpers.isGoogleModelApi).mockReturnValue(false);

View File

@ -25,7 +25,7 @@ import {
} from "../session-transcript-repair.js";
import type { TranscriptPolicy } from "../transcript-policy.js";
import { resolveTranscriptPolicy } from "../transcript-policy.js";
import { makeZeroUsageSnapshot } from "../usage.js";
import { makeZeroUsageSnapshot, normalizeUsage, type UsageLike } from "../usage.js";
import { log } from "./logger.js";
import { dropThinkingBlocks } from "./thinking.js";
import { describeUnknownError } from "./utils.js";
@ -200,6 +200,60 @@ function stripStaleAssistantUsageBeforeLatestCompaction(messages: AgentMessage[]
return touched ? out : messages;
}
function normalizeAssistantUsageSnapshot(usage: unknown) {
const normalized = normalizeUsage((usage ?? undefined) as UsageLike | undefined);
if (!normalized) {
return makeZeroUsageSnapshot();
}
const input = normalized.input ?? 0;
const output = normalized.output ?? 0;
const cacheRead = normalized.cacheRead ?? 0;
const cacheWrite = normalized.cacheWrite ?? 0;
const totalTokens = normalized.total ?? input + output + cacheRead + cacheWrite;
return {
...makeZeroUsageSnapshot(),
input,
output,
cacheRead,
cacheWrite,
totalTokens,
};
}
function ensureAssistantUsageSnapshots(messages: AgentMessage[]): AgentMessage[] {
if (messages.length === 0) {
return messages;
}
let touched = false;
const out = [...messages];
for (let i = 0; i < out.length; i += 1) {
const message = out[i] as (AgentMessage & { role?: unknown; usage?: unknown }) | undefined;
if (!message || message.role !== "assistant") {
continue;
}
const normalizedUsage = normalizeAssistantUsageSnapshot(message.usage);
if (
message.usage &&
typeof message.usage === "object" &&
(message.usage as { input?: unknown }).input === normalizedUsage.input &&
(message.usage as { output?: unknown }).output === normalizedUsage.output &&
(message.usage as { cacheRead?: unknown }).cacheRead === normalizedUsage.cacheRead &&
(message.usage as { cacheWrite?: unknown }).cacheWrite === normalizedUsage.cacheWrite &&
(message.usage as { totalTokens?: unknown }).totalTokens === normalizedUsage.totalTokens
) {
continue;
}
out[i] = {
...(message as unknown as Record<string, unknown>),
usage: normalizedUsage,
} as AgentMessage;
touched = true;
}
return touched ? out : messages;
}
export function findUnsupportedSchemaKeywords(schema: unknown, path: string): string[] {
if (!schema || typeof schema !== "object") {
return [];
@ -449,8 +503,9 @@ export async function sanitizeSessionHistory(params: {
? sanitizeToolUseResultPairing(sanitizedToolCalls)
: sanitizedToolCalls;
const sanitizedToolResults = stripToolResultDetails(repairedTools);
const sanitizedCompactionUsage =
stripStaleAssistantUsageBeforeLatestCompaction(sanitizedToolResults);
const sanitizedCompactionUsage = ensureAssistantUsageSnapshots(
stripStaleAssistantUsageBeforeLatestCompaction(sanitizedToolResults),
);
const isOpenAIResponsesApi =
params.modelApi === "openai-responses" || params.modelApi === "openai-codex-responses";