mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
fix(codex): make status context freshness truthful (#107813)
* fix(codex): project absolute thread tokenUsage into attemptUsage (#107324) * fix(codex): keep thread totalUsage off per-attempt fields * fix(codex): make status context freshness truthful Co-authored-by: wuqingxuan <wu.qingxuan@xydigit.com> * fix(codex): preserve usage through timeout recovery Co-authored-by: wuqingxuan <wu.qingxuan@xydigit.com> --------- Co-authored-by: Peter Steinberger <peter@steipete.me> Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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) +
|
||||
|
||||
@@ -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<typeof normalizeUsage> {
|
||||
// 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<typeof normalizeUsage> {
|
||||
// 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<typeof normalizeUsage> {
|
||||
// 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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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<typeof normalizeCodexTokenUsage>;
|
||||
private tokenUsage: ReturnType<typeof normalizeCodexThreadTokenUsage>;
|
||||
private responseUsage: ReturnType<typeof normalizeCodexResponseTokenUsage>;
|
||||
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<void> {
|
||||
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,
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -251,6 +251,37 @@ async function assertCodexHarnessSessionSelection(params: {
|
||||
expect(row?.thinkingLevel).toBe(CODEX_HARNESS_THINKING);
|
||||
}
|
||||
|
||||
async function readCodexHarnessSessionUsageFreshness(params: {
|
||||
client: GatewayClient;
|
||||
sessionKey: string;
|
||||
}): Promise<boolean> {
|
||||
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 });
|
||||
|
||||
Reference in New Issue
Block a user