diff --git a/CHANGELOG.md b/CHANGELOG.md index febdcac7b6a0..86eb7393bbaa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -60,6 +60,7 @@ Docs: https://docs.openclaw.ai - **1Password authorization handoff:** persist nonce-bound pending approvals in shared plugin state so hook and tool execution across broker instances remain single-use and fail closed. - **Control UI chat transcripts:** preserve loaded history across session and pane returns, bound automatic backscroll loading, virtualize long transcripts, retain hidden native run boundaries, and keep prepends, streaming, and responsive layouts from flickering or jumping. Thanks @shakkernerd. - **Codex dynamic tool outcomes:** use the shared tool-result failure contract for arbitrary lifecycle metadata, preventing successful Skill Workshop results from being displayed and persisted as failed calls. Fixes #107684. Thanks @shakkernerd. +- **Codex `/status` context freshness:** consume exact per-response usage from Codex app servers that emit `rawResponse/completed`; when exact usage is unavailable or omitted, keep context unknown instead of reusing cumulative lifetime totals. (#107813) Thanks @wuqxuan. - **Nested resource ignores:** honor slash-free patterns and escaped literal exclamation marks in nested ignore files during skill and resource discovery. Thanks @moguangyu5-design. - **Proxy bypass precedence:** honor blank lower-case `no_proxy` values shadowing upper-case `NO_PROXY` consistently with Undici, and reuse the canonical matcher for Telegram fallback selection. - **Tokenjuice exec compaction:** avoid retaining raw command output inside compacted middleware metadata, preventing large successful compactions from failing the middleware details-size guard. diff --git a/extensions/codex/src/app-server/event-projector-assistant-message.ts b/extensions/codex/src/app-server/event-projector-assistant-message.ts index be78b78dc6b0..860d29917a18 100644 --- a/extensions/codex/src/app-server/event-projector-assistant-message.ts +++ b/extensions/codex/src/app-server/event-projector-assistant-message.ts @@ -13,6 +13,7 @@ export type AssistantMessageOptions = { cacheRead?: number; cacheWrite?: number; total?: number; + contextUsage?: Usage["contextUsage"]; } | undefined; aborted: boolean; @@ -46,6 +47,9 @@ export function createAssistantMessage( output: options.tokenUsage.output ?? 0, cacheRead: options.tokenUsage.cacheRead ?? 0, cacheWrite: options.tokenUsage.cacheWrite ?? 0, + ...(options.tokenUsage.contextUsage + ? { contextUsage: options.tokenUsage.contextUsage } + : {}), totalTokens: options.tokenUsage.total ?? (options.tokenUsage.input ?? 0) + diff --git a/extensions/codex/src/app-server/event-projector-usage.ts b/extensions/codex/src/app-server/event-projector-usage.ts index 62174eccc594..1a03344438a1 100644 --- a/extensions/codex/src/app-server/event-projector-usage.ts +++ b/extensions/codex/src/app-server/event-projector-usage.ts @@ -1,20 +1,77 @@ import { normalizeUsage } from "openclaw/plugin-sdk/agent-harness-runtime"; -import { readNumber } from "./event-projector-values.js"; +import { readNonNegativeInteger, readNumber } from "./event-projector-values.js"; import type { JsonObject } from "./protocol.js"; -export function normalizeCodexTokenUsage(record: JsonObject): ReturnType { - // v2 TokenUsageBreakdown. inputTokens includes cached input; OpenClaw usage - // tracks uncached input and cache reads separately. +function readTokenCount(record: JsonObject, key: string): number | undefined { + const value = readNonNegativeInteger(record, key); + return value !== undefined && Number.isSafeInteger(value) ? value : undefined; +} + +export function normalizeCodexThreadTokenUsage( + record: JsonObject, +): ReturnType { + // Thread usage preserves per-response accounting on older app servers, but + // its `last` snapshot is not guaranteed to describe the final response. const inputTokens = readNumber(record, "inputTokens"); const cacheRead = readNumber(record, "cachedInputTokens"); const input = inputTokens !== undefined && cacheRead !== undefined ? Math.max(0, inputTokens - cacheRead) : inputTokens; - return normalizeUsage({ + const usage = normalizeUsage({ input, output: readNumber(record, "outputTokens"), cacheRead, total: readNumber(record, "totalTokens"), }); + return usage ? { ...usage, contextUsage: { state: "unavailable" } } : undefined; +} + +export function normalizeCodexResponseTokenUsage( + record: JsonObject, +): ReturnType { + // v2 TokenUsageBreakdown. inputTokens includes cached input; OpenClaw usage + // tracks uncached input and cache reads separately. + const totalTokens = readTokenCount(record, "totalTokens"); + const inputTokens = readTokenCount(record, "inputTokens"); + const cacheRead = readTokenCount(record, "cachedInputTokens"); + const output = readTokenCount(record, "outputTokens"); + const reasoningOutput = readTokenCount(record, "reasoningOutputTokens"); + const rawCacheWrite = record.cacheWriteInputTokens; + const cacheWrite = + rawCacheWrite === undefined ? 0 : readTokenCount(record, "cacheWriteInputTokens"); + if ( + totalTokens === undefined || + inputTokens === undefined || + cacheRead === undefined || + cacheWrite === undefined || + output === undefined || + reasoningOutput === undefined || + cacheRead + cacheWrite > inputTokens || + totalTokens !== inputTokens + output + ) { + return undefined; + } + + const usage = normalizeUsage({ + input: inputTokens - cacheRead - cacheWrite, + output, + cacheRead, + cacheWrite, + total: totalTokens, + }); + if (!usage) { + return undefined; + } + + // `rawResponse/completed` is exact for one provider response. The projector + // replaces this snapshot on every response so the final one owns freshness. + return { + ...usage, + contextUsage: { + state: "available", + promptTokens: inputTokens, + totalTokens, + }, + }; } diff --git a/extensions/codex/src/app-server/event-projector.test.ts b/extensions/codex/src/app-server/event-projector.test.ts index 50b794f78d72..2a902fbb0a28 100644 --- a/extensions/codex/src/app-server/event-projector.test.ts +++ b/extensions/codex/src/app-server/event-projector.test.ts @@ -164,12 +164,21 @@ function requireArray(value: unknown, label: string): unknown[] { function expectUsageFields( usage: unknown, - expected: { input: number; output: number; cacheRead: number; total: number }, + expected: { + input: number; + output: number; + cacheRead: number; + cacheWrite?: number; + total: number; + }, ) { const record = requireRecord(usage, "usage"); expect(record.input).toBe(expected.input); expect(record.output).toBe(expected.output); expect(record.cacheRead).toBe(expected.cacheRead); + if (expected.cacheWrite !== undefined) { + expect(record.cacheWrite).toBe(expected.cacheWrite); + } expect(record.total ?? record.totalTokens).toBe(expected.total); } @@ -313,20 +322,15 @@ describe("CodexAppServerEventProjector", () => { await projector.handleNotification(agentMessageDelta("hel")); await projector.handleNotification(agentMessageDelta("lo")); await projector.handleNotification( - forCurrentTurn("thread/tokenUsage/updated", { - tokenUsage: { - total: { - totalTokens: 900_000, - inputTokens: 700_000, - cachedInputTokens: 100_000, - outputTokens: 100_000, - }, - last: { - totalTokens: 12, - inputTokens: 5, - cachedInputTokens: 2, - outputTokens: 7, - }, + forCurrentTurn("rawResponse/completed", { + responseId: "response-1", + usage: { + totalTokens: 12, + inputTokens: 5, + cachedInputTokens: 2, + cacheWriteInputTokens: 1, + outputTokens: 7, + reasoningOutputTokens: 3, }, }), ); @@ -345,13 +349,30 @@ describe("CodexAppServerEventProjector", () => { expect(result.messagesSnapshot.map((message) => message.role)).toEqual(["user", "assistant"]); expect(result.lastAssistant?.content).toEqual([{ type: "text", text: "hello" }]); expect(result.currentAttemptAssistant?.content).toEqual([{ type: "text", text: "hello" }]); - expectUsageFields(result.attemptUsage, { input: 3, output: 7, cacheRead: 2, total: 12 }); - expectUsageFields(result.lastAssistant?.usage, { - input: 3, + expectUsageFields(result.attemptUsage, { + input: 2, output: 7, cacheRead: 2, + cacheWrite: 1, total: 12, }); + expect(result.attemptUsage?.contextUsage).toEqual({ + state: "available", + promptTokens: 5, + totalTokens: 12, + }); + expectUsageFields(result.lastAssistant?.usage, { + input: 2, + output: 7, + cacheRead: 2, + cacheWrite: 1, + total: 12, + }); + expect(result.lastAssistant?.usage.contextUsage).toEqual({ + state: "available", + promptTokens: 5, + totalTokens: 12, + }); expect(result.replayMetadata.replaySafe).toBe(true); }); @@ -604,10 +625,22 @@ describe("CodexAppServerEventProjector", () => { }); }); - it("does not treat cumulative-only token usage as fresh context usage", async () => { + it("ignores cumulative thread usage after exact response usage", async () => { const projector = await createProjector(); await projector.handleNotification(agentMessageDelta("done")); + await projector.handleNotification( + forCurrentTurn("rawResponse/completed", { + responseId: "response-1", + usage: { + totalTokens: 12, + inputTokens: 5, + cachedInputTokens: 2, + outputTokens: 7, + reasoningOutputTokens: 0, + }, + }), + ); await projector.handleNotification( forCurrentTurn("thread/tokenUsage/updated", { tokenUsage: { @@ -624,12 +657,228 @@ describe("CodexAppServerEventProjector", () => { const result = projector.buildResult(buildEmptyToolTelemetry()); expect(result.assistantTexts).toEqual(["done"]); - expect(result.attemptUsage).toBeUndefined(); + expectUsageFields(result.attemptUsage, { input: 3, output: 7, cacheRead: 2, total: 12 }); + expect(result.attemptUsage?.contextUsage).toEqual({ + state: "available", + promptTokens: 5, + totalTokens: 12, + }); + }); + + it("keeps cumulative-only thread usage unknown", async () => { + const projector = await createProjector(); + + await projector.handleNotification(agentMessageDelta("done")); + await projector.handleNotification( + forCurrentTurn("thread/tokenUsage/updated", { + tokenUsage: { + total: { + totalTokens: 1_000_000, + inputTokens: 999_000, + cachedInputTokens: 500, + outputTokens: 500, + }, + last: { + totalTokens: 12, + inputTokens: 5, + cachedInputTokens: 2, + outputTokens: 7, + }, + }, + }), + ); + + const result = projector.buildResult(buildEmptyToolTelemetry()); + + expect(result.assistantTexts).toEqual(["done"]); + expectUsageFields(result.attemptUsage, { input: 3, output: 7, cacheRead: 2, total: 12 }); + expect(result.attemptUsage?.contextUsage).toEqual({ state: "unavailable" }); expectUsageFields(result.lastAssistant?.usage, { - input: 0, - output: 0, - cacheRead: 0, - total: 0, + input: 3, + output: 7, + cacheRead: 2, + total: 12, + }); + expect(result.lastAssistant?.usage.contextUsage).toEqual({ state: "unavailable" }); + }); + + it.each([ + ["incomplete", { totalTokens: 12 }], + [ + "incoherent total", + { + totalTokens: 6, + inputTokens: 5, + cachedInputTokens: 2, + outputTokens: 7, + reasoningOutputTokens: 0, + }, + ], + [ + "impossible cache counts", + { + totalTokens: 12, + inputTokens: 5, + cachedInputTokens: 4, + cacheWriteInputTokens: 2, + outputTokens: 7, + reasoningOutputTokens: 0, + }, + ], + ])("keeps %s response usage unknown", async (_label, usage) => { + const projector = await createProjector(); + + await projector.handleNotification(agentMessageDelta("done")); + await projector.handleNotification( + forCurrentTurn("rawResponse/completed", { responseId: "response-1", usage }), + ); + + const result = projector.buildResult(buildEmptyToolTelemetry()); + + expect(result.assistantTexts).toEqual(["done"]); + expect(result.attemptUsage).toBeUndefined(); + expect(result.lastAssistant?.usage.contextUsage).toBeUndefined(); + }); + + it("clears prior response usage when the final response omits usage", async () => { + const projector = await createProjector(); + + await projector.handleNotification(agentMessageDelta("done")); + await projector.handleNotification( + forCurrentTurn("rawResponse/completed", { + responseId: "response-1", + usage: { + totalTokens: 12, + inputTokens: 5, + cachedInputTokens: 2, + outputTokens: 7, + reasoningOutputTokens: 0, + }, + }), + ); + await projector.handleNotification( + forCurrentTurn("thread/tokenUsage/updated", { + tokenUsage: { + last: { + totalTokens: 12, + inputTokens: 5, + cachedInputTokens: 2, + outputTokens: 7, + }, + }, + }), + ); + await projector.handleNotification( + forCurrentTurn("rawResponse/completed", { responseId: "response-2", usage: null }), + ); + + const result = projector.buildResult(buildEmptyToolTelemetry()); + + expectUsageFields(result.attemptUsage, { input: 3, output: 7, cacheRead: 2, total: 12 }); + expect(result.attemptUsage?.contextUsage).toEqual({ state: "unavailable" }); + expect(result.lastAssistant?.usage.contextUsage).toEqual({ state: "unavailable" }); + }); + + it.each(["failed", "interrupted"])( + "invalidates exact response usage when the turn ends %s", + async (status) => { + const projector = await createProjector(); + + await projector.handleNotification( + forCurrentTurn("rawResponse/completed", { + responseId: "response-1", + usage: { + totalTokens: 12, + inputTokens: 5, + cachedInputTokens: 2, + outputTokens: 7, + reasoningOutputTokens: 0, + }, + }), + ); + await projector.handleNotification(turnWithStatus(status)); + + expect(projector.buildResult(buildEmptyToolTelemetry()).attemptUsage).toBeUndefined(); + }, + ); + + it("invalidates exact response usage on retryable errors and explicit aborts", async () => { + const projector = await createProjector(); + const exactUsage = { + totalTokens: 12, + inputTokens: 5, + cachedInputTokens: 2, + outputTokens: 7, + reasoningOutputTokens: 0, + }; + + await projector.handleNotification( + forCurrentTurn("rawResponse/completed", { + responseId: "response-1", + usage: exactUsage, + }), + ); + await projector.handleNotification( + forCurrentTurn("error", { error: { message: "retry" }, willRetry: true }), + ); + expect(projector.buildResult(buildEmptyToolTelemetry()).attemptUsage).toBeUndefined(); + + await projector.handleNotification( + forCurrentTurn("rawResponse/completed", { + responseId: "response-2", + usage: exactUsage, + }), + ); + projector.markAborted(); + expect(projector.buildResult(buildEmptyToolTelemetry()).attemptUsage).toBeUndefined(); + }); + + it("restores exact response usage after recovering a completed assistant timeout", async () => { + const projector = await createProjector(); + + await projector.handleNotification( + forCurrentTurn("item/completed", { + item: { type: "agentMessage", id: "msg-1", text: "done" }, + }), + ); + await projector.handleNotification( + forCurrentTurn("thread/tokenUsage/updated", { + tokenUsage: { + last: { + totalTokens: 12, + inputTokens: 5, + cachedInputTokens: 2, + outputTokens: 7, + }, + }, + }), + ); + await projector.handleNotification( + forCurrentTurn("rawResponse/completed", { + responseId: "response-1", + usage: { + totalTokens: 12, + inputTokens: 5, + cachedInputTokens: 2, + outputTokens: 7, + reasoningOutputTokens: 0, + }, + }), + ); + + projector.markTimedOut(); + const timedOut = projector.buildResult(buildEmptyToolTelemetry()); + expect(timedOut.aborted).toBe(true); + expect(timedOut.attemptUsage?.contextUsage).toEqual({ state: "unavailable" }); + + expect(projector.recoverCompletedTerminalAssistantAfterTurnWatchTimeout()).toBe(true); + const recovered = projector.buildResult(buildEmptyToolTelemetry()); + expect(recovered.aborted).toBe(false); + expect(recovered.promptError).toBeNull(); + expect(recovered.attemptUsage?.contextUsage).toEqual({ + state: "available", + promptTokens: 5, + totalTokens: 12, }); }); diff --git a/extensions/codex/src/app-server/event-projector.ts b/extensions/codex/src/app-server/event-projector.ts index b3bb01d522cb..f5696806f156 100644 --- a/extensions/codex/src/app-server/event-projector.ts +++ b/extensions/codex/src/app-server/event-projector.ts @@ -25,7 +25,10 @@ import { CodexNativeToolLifecycleProjector } from "./event-projector-native-tool import { CodexReasoningProjection } from "./event-projector-reasoning.js"; import { CodexToolProgressProjection } from "./event-projector-tool-progress.js"; import { CodexToolTranscriptProjection } from "./event-projector-tool-transcript.js"; -import { normalizeCodexTokenUsage } from "./event-projector-usage.js"; +import { + normalizeCodexResponseTokenUsage, + normalizeCodexThreadTokenUsage, +} from "./event-projector-usage.js"; import { readCodexErrorNotificationMessage, readItem, @@ -97,7 +100,8 @@ export class CodexAppServerEventProjector { private promptErrorSource: EmbeddedRunAttemptResult["promptErrorSource"] = null; private synthesizedMissingToolResultError: string | null = null; private aborted = false; - private tokenUsage: ReturnType; + private tokenUsage: ReturnType; + private responseUsage: ReturnType; private completedCompactionCount = 0; constructor( @@ -264,10 +268,14 @@ export class CodexAppServerEventProjector { case "turn/completed": await this.handleTurnCompleted(params); break; + case "rawResponse/completed": + this.handleRawResponseCompleted(params); + break; case "rawResponseItem/completed": await this.handleRawResponseItemCompleted(params); break; case "error": + this.responseUsage = undefined; if (params.willRetry === true) { break; } @@ -289,6 +297,10 @@ export class CodexAppServerEventProjector { const assistantTexts = this.assistantProjection.collectAssistantTexts(); const reasoningText = this.reasoningProjection.reasoningText(); const planText = this.reasoningProjection.planText(); + // A terminal timeout must not publish exact usage, but the timeout watcher + // can still recover a completed assistant. Keep the snapshot masked until + // recovery clears the abort instead of destroying it in markTimedOut(). + const projectedUsage = this.aborted ? this.tokenUsage : (this.responseUsage ?? this.tokenUsage); const hasAssistantItemText = this.assistantProjection.hasAssistantItemTextForSynthesis(); const legacyFailClosed = !this.completedTurn || this.completedTurn.status !== "completed" || hasAssistantItemText; @@ -306,7 +318,7 @@ export class CodexAppServerEventProjector { this.promptErrorSource = this.promptErrorSource ?? "prompt"; } const assistantMessageOptions = { - tokenUsage: this.tokenUsage, + tokenUsage: projectedUsage, aborted: this.aborted, promptError: this.promptError, }; @@ -403,7 +415,7 @@ export class CodexAppServerEventProjector { toolAudioAsVoice: toolTelemetry.toolAudioAsVoice, successfulCronAdds: toolTelemetry.successfulCronAdds, cloudCodeAssistFormatError: false, - attemptUsage: this.tokenUsage, + attemptUsage: projectedUsage, replayMetadata: { hadPotentialSideEffects, replaySafe: !hadPotentialSideEffects, @@ -448,6 +460,7 @@ export class CodexAppServerEventProjector { markAborted(): void { this.aborted = true; + this.responseUsage = undefined; } isCompacting(): boolean { @@ -558,24 +571,33 @@ export class CodexAppServerEventProjector { } private handleTokenUsage(params: JsonObject): void { - // v2 ThreadTokenUsageUpdatedNotification: tokenUsage = {total, last, modelContextWindow}. const tokenUsage = isJsonObject(params.tokenUsage) ? params.tokenUsage : undefined; const last = tokenUsage && isJsonObject(tokenUsage.last) ? tokenUsage.last : undefined; if (!last) { return; } - const usage = normalizeCodexTokenUsage(last); + const usage = normalizeCodexThreadTokenUsage(last); if (usage) { this.tokenUsage = usage; } } + private handleRawResponseCompleted(params: JsonObject): void { + const usage = isJsonObject(params.usage) ? params.usage : undefined; + // Every provider completion replaces the prior response snapshot. A final + // response with missing or malformed usage must leave freshness unknown. + this.responseUsage = usage ? normalizeCodexResponseTokenUsage(usage) : undefined; + } + private async handleTurnCompleted(params: JsonObject): Promise { const turn = readCodexTurn(params.turn); if (!turn || turn.id !== this.turnId) { return; } this.completedTurn = turn; + if (turn.status !== "completed") { + this.responseUsage = undefined; + } if (turn.status === "failed") { const usageLimitMessage = formatCodexUsageLimitErrorMessage({ message: turn.error?.message, diff --git a/src/agents/embedded-agent-runner/run/helpers.test.ts b/src/agents/embedded-agent-runner/run/helpers.test.ts index 84a0f1adbd9a..0f03fdae71f3 100644 --- a/src/agents/embedded-agent-runner/run/helpers.test.ts +++ b/src/agents/embedded-agent-runner/run/helpers.test.ts @@ -252,6 +252,29 @@ describe("buildUsageAgentMetaFields", () => { expect(fields.lastCallUsage).toEqual(latestCallUsage); expect(fields.promptTokens).toBe(148_874); }); + + it("does not derive a prompt override from unavailable context usage", () => { + const usageAccumulator = createUsageAccumulator(); + const latestCallUsage = { + input: 12, + output: 15_104, + cacheRead: 819_661, + cacheWrite: 93_130, + contextUsage: { state: "unavailable" }, + total: 927_907, + } satisfies NormalizedUsage; + mergeUsageIntoAccumulator(usageAccumulator, latestCallUsage); + + const fields = buildUsageAgentMetaFields({ + usageAccumulator, + lastAssistantUsage: latestCallUsage, + lastRunPromptUsage: latestCallUsage, + lastTurnTotal: latestCallUsage.total, + }); + + expect(fields.lastCallUsage).toEqual(latestCallUsage); + expect(fields.promptTokens).toBeUndefined(); + }); }); describe("buildErrorAgentMeta", () => { diff --git a/src/gateway/gateway-codex-harness.live.test.ts b/src/gateway/gateway-codex-harness.live.test.ts index 98c7645ee08b..24b283445702 100644 --- a/src/gateway/gateway-codex-harness.live.test.ts +++ b/src/gateway/gateway-codex-harness.live.test.ts @@ -251,6 +251,37 @@ async function assertCodexHarnessSessionSelection(params: { expect(row?.thinkingLevel).toBe(CODEX_HARNESS_THINKING); } +async function readCodexHarnessSessionUsageFreshness(params: { + client: GatewayClient; + sessionKey: string; +}): Promise { + const result: { + sessions?: Array<{ + key?: string; + inputTokens?: number; + outputTokens?: number; + cacheRead?: number; + cacheWrite?: number; + totalTokens?: number; + totalTokensFresh?: boolean; + }>; + } = await params.client.request("sessions.list", { + includeGlobal: true, + limit: 200, + }); + const row = result.sessions?.find((entry) => entry.key === params.sessionKey); + expect(row, `expected sessions.list row for ${params.sessionKey}`).toBeDefined(); + const fresh = row?.totalTokensFresh === true; + if (fresh) { + expect(row?.totalTokens).toBeTypeOf("number"); + expect(row?.totalTokens).toBeGreaterThan(0); + } else { + expect(row?.totalTokensFresh).toBe(false); + } + logCodexLiveStep("session-usage", row); + return fresh; +} + async function assertCodexHarnessTranscriptModelIdentity(params: { client: GatewayClient; modelKey: string; @@ -1305,6 +1336,27 @@ describeLive("gateway live (Codex harness)", () => { modelKey, sessionKey, }); + const sessionUsageFresh = await readCodexHarnessSessionUsageFreshness({ + client: activeClient, + sessionKey, + }); + const openClawStatusText = await requestCodexCommandText({ + client: activeClient, + events: gatewayEvents, + sessionKey, + command: "/status", + expectedText: "Context:", + isExpectedText: (text) => + text.split("\n").some((line) => { + if (!line.includes("Context:")) { + return false; + } + const reportsUnknown = line.includes("Context: ?/"); + return sessionUsageFresh ? !reportsUnknown : reportsUnknown; + }), + predicateOnly: true, + }); + logCodexLiveStep("openclaw-status-command", { statusText: openClawStatusText }); if (CODEX_HARNESS_CODE_MODE_ONLY) { logCodexLiveStep("code-mode-only-tool-probe:start", { sessionKey });