fix(sessions): track context window provenance (#124303)

* fix(sessions): track context window provenance

* fix(sessions): complete context provenance coverage

* fix(sessions): honor authored context window caps

* fix(agents): distinguish context source helpers

* fix(sessions): clear context provenance on model invalidation

* test(codex): preserve local operator authority provenance

* fix(context): clamp effective caps to native windows

* fix(cron): use the runtime model facade

* fix(status): honor session context provenance

* test(status): use public context token inputs

* test(status): split session row cases

* test(status): classify split cases as test support

* test(status): declare runtime context provenance

* fix(sessions): preserve context ownership through finalization

* fix(sessions): apply context provenance to listings

* fix(cron): preserve projected context ownership

* fix(sessions): scope locked context ownership

* fix(status): share session context projection
This commit is contained in:
Jason (Json)
2026-08-18 05:03:01 -06:00
committed by GitHub
parent 70df0311b8
commit 7c65bbcee3
67 changed files with 2327 additions and 354 deletions
@@ -45,6 +45,7 @@ type CodexAttemptResultInput = {
aborted: boolean;
tokenUsage: EmbeddedRunAttemptResult["attemptUsage"];
contextTokens: number | undefined;
contextTokensSource: EmbeddedRunAttemptResult["contextTokensSource"];
completedCompactionCount: number;
activeItemCount: number;
completedItemCount: number;
@@ -217,6 +218,7 @@ export function buildCodexAttemptResult(
acceptedSessionSpawns: input.toolTelemetry.acceptedSessionSpawns,
cloudCodeAssistFormatError: false,
contextTokens: input.contextTokens,
contextTokensSource: input.contextTokensSource,
attemptUsage: projectedUsage,
...(input.completedCompactionCount > 0
? { compactionCount: input.completedCompactionCount }
@@ -84,6 +84,7 @@ export class CodexAppServerEventProjector {
private aborted = false;
private tokenUsage: ReturnType<typeof normalizeCodexThreadTokenUsage>;
private contextTokens: number | undefined;
private contextTokensSource: "runtime" | "runtime-configured" | "resolved" | undefined;
private readonly responseCompletions = new CodexResponseCompletionProjection();
private completedCompactionCount = 0;
private lastTranscriptTimestamp = 0;
@@ -95,6 +96,7 @@ export class CodexAppServerEventProjector {
private readonly options: CodexAppServerEventProjectorOptions = {},
) {
this.contextTokens = options.initialContextTokens;
this.contextTokensSource = options.initialContextTokens === undefined ? undefined : "resolved";
this.diagnostics = new CodexProjectionDiagnostics(threadId, turnId);
this.nativeToolLifecycleProjector = new CodexNativeToolLifecycleProjector(
params,
@@ -276,7 +278,16 @@ export class CodexAppServerEventProjector {
this.tokenUsage,
(usage) => (this.tokenUsage = usage),
(data) => {
this.contextTokens = data.modelContextWindow ?? this.contextTokens;
if (data.modelContextWindow !== undefined) {
this.contextTokens = data.modelContextWindow;
// Codex reports the effective thread window. When OpenClaw supplied an
// authored cap, retain that fact so removing the cap cannot make the
// constrained observation look like uncapped native telemetry.
this.contextTokensSource =
this.params.authoredContextTokenCap === undefined
? "runtime"
: "runtime-configured";
}
this.emitAgentEvent({ stream: "codex_app_server.usage", data });
},
);
@@ -339,6 +350,7 @@ export class CodexAppServerEventProjector {
aborted: this.aborted,
tokenUsage: this.tokenUsage,
contextTokens: this.contextTokens,
contextTokensSource: this.contextTokensSource,
completedCompactionCount: this.completedCompactionCount,
activeItemCount: this.activeItemIds.size,
completedItemCount: this.completedItemIds.size,
@@ -27,6 +27,7 @@ describe("CodexAppServerEventProjector usage projection", () => {
expect(projector.buildResult(buildEmptyToolTelemetry())).toMatchObject({
contextTokens: 1_050_000,
contextTokensSource: "resolved",
});
});
@@ -69,6 +70,23 @@ describe("CodexAppServerEventProjector usage projection", () => {
});
expect(projector.buildResult(buildEmptyToolTelemetry())).toMatchObject({
contextTokens: 875_900,
contextTokensSource: "runtime",
});
});
it("marks native telemetry constrained by an authored context cap", async () => {
const params = await createParams();
const projector = await createProjector({ ...params, authoredContextTokenCap: 272_000 });
await projector.handleNotification(
forCurrentTurn("thread/tokenUsage/updated", {
tokenUsage: { modelContextWindow: 272_000 },
}),
);
expect(projector.buildResult(buildEmptyToolTelemetry())).toMatchObject({
contextTokens: 272_000,
contextTokensSource: "runtime-configured",
});
});
+33 -15
View File
@@ -4,30 +4,28 @@ import { applyAcpRuntimeOverlay, type AgentRuntimeMetadata } from "./acp-runtime
import { isDefaultAgentRuntimeId } from "./agent-runtime-id.js";
import { resolveAgentHarnessPolicy } from "./harness/policy.js";
import { resolveDefaultModelForAgent } from "./model-selection.js";
import { resolvePersistedSessionRuntimeId } from "./session-runtime-compat.js";
import {
resolvePersistedSessionRuntimeId,
resolveSessionRuntimeOverrideForProvider,
} from "./session-runtime-compat.js";
/** Resolves the runtime id/source that should be reported for a model-backed agent session. */
export function resolveModelAgentRuntimeMetadata(params: {
type ModelAgentRuntimeMetadataParams = {
cfg: OpenClawConfig;
agentId: string;
provider?: string;
model?: string;
sessionKey?: string;
sessionEntry?: Parameters<typeof resolvePersistedSessionRuntimeId>[0];
/**
* True when the loaded session entry has persisted ACP metadata. ACP-shaped
* keys without this marker can be bridge sessions that use the configured
* model/runtime.
*/
/** True only when persisted ACP metadata owns the session. */
acpRuntime?: boolean;
/**
* The ACP backend identifier stored on the session entry (`entry.acp.backend`).
* When provided for an ACP-keyed session, the overlay reports this value as the
* runtime id instead of the generic fallback "acpx", so sessions backed by a
* non-default registered ACP backend are classified correctly.
*/
/** Persisted ACP backend id, falling back to acpx when absent. */
acpBackend?: string;
}): AgentRuntimeMetadata {
};
/** Resolves the runtime id/source that should be reported for a model-backed agent session. */
export function resolveModelAgentRuntimeMetadata(
params: ModelAgentRuntimeMetadataParams,
): AgentRuntimeMetadata {
const persistedRuntimeId = resolvePersistedSessionRuntimeId(params.sessionEntry);
if (persistedRuntimeId && !isDefaultAgentRuntimeId(persistedRuntimeId)) {
return applyAcpRuntimeOverlay(
@@ -54,3 +52,23 @@ export function resolveModelAgentRuntimeMetadata(params: {
};
return applyAcpRuntimeOverlay(meta, params.sessionKey, params.acpRuntime, params.acpBackend);
}
/** Resolves the runtime selected for the next turn, excluding historical producer metadata. */
export function resolveCurrentSessionAgentRuntimeMetadata(
params: ModelAgentRuntimeMetadataParams,
): AgentRuntimeMetadata {
const { sessionEntry, ...configuredParams } = params;
const configuredRuntime = resolveModelAgentRuntimeMetadata(configuredParams);
const sessionRuntime = resolveSessionRuntimeOverrideForProvider({
provider: params.provider,
entry: sessionEntry,
cfg: params.cfg,
});
if (params.acpRuntime || !sessionRuntime) {
return configuredRuntime;
}
return {
id: sessionRuntime,
source: sessionEntry?.modelSelectionLocked === true ? "session" : "session-key",
};
}
+4 -1
View File
@@ -254,7 +254,10 @@ export async function runPreparedCliAgent(
const sessionBindingDisabled = context.preparedBackend.backend.sessionMode === "none";
const preparedContextAgentMeta =
isClaudeCliBackend(params.provider) && context.contextWindowInfo
? { contextTokens: context.contextWindowInfo.tokens }
? {
contextTokens: context.contextWindowInfo.tokens,
contextTokensSource: "resolved" as const,
}
: {};
const isolatedCompletion = params.isolatedCompletion === true;
const controlOperation = params.controlOperation !== undefined;
+10
View File
@@ -490,6 +490,7 @@ describe("updateSessionStoreAfterAgentRun", () => {
[sessionKey]: {
sessionId,
updatedAt: 1,
agentHarnessId: "openclaw",
},
};
await seedSessionStore(storePath, sessionStore);
@@ -502,6 +503,7 @@ describe("updateSessionStoreAfterAgentRun", () => {
provider: "openai",
model: "gpt-5.5",
contextTokens: 400_000,
contextTokensSource: "runtime",
},
},
};
@@ -518,7 +520,11 @@ describe("updateSessionStoreAfterAgentRun", () => {
});
expect(sessionStore[sessionKey]?.contextTokens).toBe(400_000);
expect(sessionStore[sessionKey]?.contextTokensSource).toBe("runtime");
expect(sessionStore[sessionKey]?.agentHarnessId).toBeUndefined();
expect(loadPersistedSessionEntry(storePath, sessionKey)?.contextTokens).toBe(400_000);
expect(loadPersistedSessionEntry(storePath, sessionKey)?.contextTokensSource).toBe("runtime");
expect(loadPersistedSessionEntry(storePath, sessionKey)?.agentHarnessId).toBeUndefined();
});
});
@@ -566,7 +572,11 @@ describe("updateSessionStoreAfterAgentRun", () => {
});
expect(sessionStore[sessionKey]?.contextTokens).toBe(272_000);
expect(sessionStore[sessionKey]?.contextTokensSource).toBe("resolved");
expect(loadPersistedSessionEntry(storePath, sessionKey)?.contextTokens).toBe(272_000);
expect(loadPersistedSessionEntry(storePath, sessionKey)?.contextTokensSource).toBe(
"resolved",
);
});
});
+3 -5
View File
@@ -117,6 +117,7 @@ export async function updateSessionStoreAfterAgentRun(params: {
fallbackContextTokens: DEFAULT_CONTEXT_TOKENS,
allowAsyncLoad: false,
}) ?? DEFAULT_CONTEXT_TOKENS);
const contextTokensSource = result.meta.agentMeta?.contextTokensSource ?? "resolved";
const preserveUserFacingRunState = params.preserveUserFacingSessionModelState === true;
const preserveRuntimeModel = params.preserveRuntimeModel === true || preserveUserFacingRunState;
@@ -137,6 +138,7 @@ export async function updateSessionStoreAfterAgentRun(params: {
? {}
: {
contextTokens,
contextTokensSource,
}),
};
if (entry.sessionId !== sessionId) {
@@ -175,11 +177,7 @@ export async function updateSessionStoreAfterAgentRun(params: {
}
if (!preserveUserFacingRunState) {
if (!preserveRuntimeModel) {
if (agentHarnessId) {
next.agentHarnessId = agentHarnessId;
} else if (result.meta.executionTrace?.runner === "cli") {
next.agentHarnessId = undefined;
}
next.agentHarnessId = agentHarnessId;
}
if (!preserveRuntimeModel && isCliProvider(providerUsed, cfg)) {
const cliSessionBinding = result.meta.agentMeta?.cliSessionBinding;
+9 -6
View File
@@ -224,10 +224,17 @@ export function resolveContextTokensForModelFromCache(
claudeCli1M: effectiveContext1M === true,
});
const configuredContextTokens = readAuthoredModelContextTokens(configuredModel);
const configuredContextWindow =
typeof configuredModel?.contextWindow === "number" && configuredModel.contextWindow > 0
? configuredModel.contextWindow
: undefined;
// Fixed provider contracts deliberately ignore materialized catalog windows.
// Other runtimes must still keep an authored effective cap below its native window.
const configuredTokenLimit = fixedContextWindow ?? configuredContextWindow;
if (configuredContextTokens !== undefined) {
return fixedContextWindow === undefined
return configuredTokenLimit === undefined
? configuredContextTokens
: Math.min(configuredContextTokens, fixedContextWindow);
: Math.min(configuredContextTokens, configuredTokenLimit);
}
if (fixedContextWindow !== undefined) {
return fixedContextWindow;
@@ -252,10 +259,6 @@ export function resolveContextTokensForModelFromCache(
providerWindow,
modelContextWindow,
);
const configuredContextWindow =
typeof configuredModel?.contextWindow === "number" && configuredModel.contextWindow > 0
? configuredModel.contextWindow
: undefined;
if (discoveredCap !== undefined) {
return configuredContextWindow === undefined
? discoveredCap
+27
View File
@@ -658,6 +658,33 @@ describe("lookupContextTokens", () => {
).toBe(1_000_000);
});
it("bounds an authored effective cap by a smaller authored context window", async () => {
mockDiscoveryDeps([]);
const resolveContextTokensForModel = await importResolveContextTokensForModel();
expect(
resolveContextTokensForModel({
cfg: {
models: {
providers: {
openai: {
models: [
{
id: "gpt-5.6-sol",
contextWindow: 128_000,
contextTokens: 1_000_000,
},
],
},
},
},
} as never,
provider: "openai",
model: "gpt-5.6-sol",
}),
).toBe(128_000);
});
it("resolveContextTokensForModel honors configured overrides when provider keys use mixed case", async () => {
mockDiscoveryDeps([{ id: "openrouter/anthropic/claude-sonnet-4-5", contextWindow: 1_048_576 }]);
@@ -272,6 +272,7 @@ export function buildErrorAgentMeta(params: {
provider: params.provider,
model: params.model,
...(params.contextTokens ? { contextTokens: params.contextTokens } : {}),
...(params.contextTokens ? { contextTokensSource: "resolved" as const } : {}),
...(usageMeta.usage ? { usage: usageMeta.usage } : {}),
...(usageMeta.lastCallUsage ? { lastCallUsage: usageMeta.lastCallUsage } : {}),
...(usageMeta.promptTokens ? { promptTokens: usageMeta.promptTokens } : {}),
@@ -187,6 +187,42 @@ describe("prepareTerminalWithSettledTurnFinalization", () => {
});
});
it("preserves the settled runtime context window through isolated finalization", async () => {
const attempt = {
...settledFailedAttempt(),
agentHarnessId: "codex",
contextTokens: 1_000_000,
contextTokensSource: "runtime" as const,
};
const input = finalizationInput(attempt);
input.terminalBase.outerContextTokenMeta = { contextTokens: 272_000 };
input.finalization.preparedAttempt.agentHarnessId = "codex";
const finalAssistant = buildEmbeddedRunnerAssistant({
content: [{ type: "text", text: "The exec tool failed: post-processing error." }],
});
backendMocks.runSettledFinalization.mockResolvedValueOnce({
outcome: "answered",
result: {
assistant: finalAssistant,
usage: finalAssistant.usage,
diagnosticTrace: { traceId: "trace-final", spanId: "span-final" },
},
});
const result = await prepareTerminalWithSettledTurnFinalization(input);
expect(result.attempt).toMatchObject({
agentHarnessId: "codex",
contextTokens: 1_000_000,
contextTokensSource: "runtime",
});
expect(result.prepared.agentMeta).toMatchObject({
agentHarnessId: "codex",
contextTokens: 1_000_000,
contextTokensSource: "runtime",
});
});
it("fails closed and preserves the initial terminal preparation", async () => {
const attempt = settledFailedAttempt();
backendMocks.runSettledFinalization.mockRejectedValueOnce(new Error("finalizer failed"));
@@ -217,6 +217,8 @@ function buildSettledTurnFinalizationAttemptResult(input: {
sessionIdUsed: settledAttempt.sessionIdUsed,
sessionFileUsed: settledAttempt.sessionFileUsed,
...(input.agentHarnessId ? { agentHarnessId: input.agentHarnessId } : {}),
contextTokens: settledAttempt.contextTokens,
contextTokensSource: settledAttempt.contextTokensSource,
authBindingFingerprint: settledAttempt.authBindingFingerprint,
runtimeArtifact: settledAttempt.runtimeArtifact,
systemPromptReport: settledAttempt.systemPromptReport,
@@ -320,6 +320,7 @@ describe("prepareEmbeddedRunTerminal run stats", () => {
config?: unknown;
provider?: string;
model?: string;
outerContextTokenMeta?: { contextTokens?: number };
responseModel?: string;
usage?: Partial<
Pick<
@@ -366,7 +367,7 @@ describe("prepareEmbeddedRunTerminal run stats", () => {
activeErrorContext: { provider, model },
authProfileStore: { version: 1, profiles: {} },
sessionIdUsed: "session-1",
outerContextTokenMeta: {},
outerContextTokenMeta: statsInput.outerContextTokenMeta ?? {},
usageAccumulator,
contextRecoveryState: createEmbeddedRunContextRecoveryState(),
resolvedToolResultFormat: "markdown",
@@ -401,6 +402,34 @@ describe("prepareEmbeddedRunTerminal run stats", () => {
expect(prepared.agentMeta.codeModeEngaged).toBe(expected);
});
it("records whether the context window came from the harness or prepared resolution", async () => {
const observed = await prepareStats({
attempt: { contextTokens: 1_000_000, contextTokensSource: "runtime" },
outerContextTokenMeta: { contextTokens: 272_000 },
});
expect(observed.agentMeta).toMatchObject({
contextTokens: 1_000_000,
contextTokensSource: "runtime",
});
const configured = await prepareStats({
attempt: { contextTokens: 272_000, contextTokensSource: "runtime-configured" },
outerContextTokenMeta: { contextTokens: 1_000_000 },
});
expect(configured.agentMeta).toMatchObject({
contextTokens: 272_000,
contextTokensSource: "runtime-configured",
});
const resolved = await prepareStats({
outerContextTokenMeta: { contextTokens: 272_000 },
});
expect(resolved.agentMeta).toMatchObject({
contextTokens: 272_000,
contextTokensSource: "resolved",
});
});
it("stamps assistantTurns from the run accumulator and omits zero", async () => {
const counted = await prepareStats({ assistantTurns: 3 });
expect(counted.agentMeta.assistantTurns).toBe(3);
@@ -96,12 +96,21 @@ export function prepareEmbeddedRunTerminal(input: {
// Attempt normalization already folded every attempt (terminal included)
// into the accumulator, so read it directly instead of re-adding the attempt.
const runAssistantTurns = input.usageAccumulator.assistantTurns;
const contextTokens = attempt.contextTokens ?? input.outerContextTokenMeta.contextTokens;
const agentMeta: EmbeddedAgentMeta = {
sessionId: input.sessionIdUsed,
sessionFile: input.sessionFileUsed,
provider: reportedModelRef.provider,
model: reportedModelRef.model,
contextTokens: attempt.contextTokens ?? input.outerContextTokenMeta.contextTokens,
contextTokens,
...(contextTokens !== undefined
? {
contextTokensSource:
attempt.contextTokens !== undefined
? (attempt.contextTokensSource ?? "resolved")
: "resolved",
}
: {}),
agentHarnessId: attempt.agentHarnessId,
usage: usageMeta.usage,
lastCallUsage: usageMeta.lastCallUsage,
@@ -326,6 +326,8 @@ export type EmbeddedRunAttemptResult = {
cloudCodeAssistFormatError: boolean;
/** Effective context window reported by the harness during this attempt. */
contextTokens?: number;
/** Whether the harness observed the window or carried prepared resolution forward. */
contextTokensSource?: "runtime" | "runtime-configured" | "resolved";
attemptUsage?: NormalizedUsage;
promptCache?: ContextEnginePromptCacheInfo;
contextBudgetStatus?: SessionContextBudgetStatus;
@@ -44,6 +44,7 @@ export type EmbeddedAgentMeta = {
provider: string;
model: string;
contextTokens?: number;
contextTokensSource?: "runtime" | "runtime-configured" | "resolved";
agentHarnessId?: string;
fallbackAttempts?: FallbackAttempt[];
cliSessionBinding?: CliSessionBinding;
@@ -430,6 +430,29 @@ describe("sessions-list-tool", () => {
});
});
it("preserves the context window already projected by the Gateway", async () => {
mocks.gatewayCall.mockResolvedValue({
path: "/tmp/sessions.json",
sessions: [
{
key: "agent:main:main",
agentId: "main",
kind: "direct",
classification: "main",
model: "gpt-5.6-sol",
contextTokens: 1_000_000,
},
],
});
const result = await createSessionsListTool({ config: VALID_CONFIG }).execute(
"gateway-context-window",
{},
);
expect(getSessionsListDetails(result).sessions?.[0]?.contextTokens).toBe(1_000_000);
});
it("keeps channel discovery but omits delivery routing metadata", async () => {
mocks.gatewayCall.mockImplementation(async (opts: unknown) => {
const request = opts as { method?: string };
+2
View File
@@ -397,6 +397,8 @@ export function createSessionsListTool(opts?: {
: undefined;
const updatedAt = typeof entry.updatedAt === "number" ? entry.updatedAt : undefined;
const model = readStringValue(entry.model);
// sessions.list owns runtime/context provenance; this tool only filters and
// narrows its GatewaySessionListRow without reinterpreting raw session state.
const contextTokens =
typeof entry.contextTokens === "number" ? entry.contextTokens : undefined;
const totalTokens = typeof entry.totalTokens === "number" ? entry.totalTokens : undefined;
@@ -4,6 +4,7 @@ import type { AdmittedFollowupTurn, FollowupRunnerParams } from "./followup-turn
import type { FollowupExecutionResult } from "./followup-turn-execution.js";
const mocks = vi.hoisted(() => ({
persistRunSessionUsage: vi.fn(async (_params: unknown) => undefined),
refreshQueuedFollowupSession: vi.fn(),
}));
@@ -64,7 +65,7 @@ vi.mock("./reply-usage-state.js", () => ({
vi.mock("./session-run-accounting.js", () => ({
incrementRunCompactionCount: vi.fn(async () => undefined),
persistRunSessionUsage: vi.fn(async () => undefined),
persistRunSessionUsage: (params: unknown) => mocks.persistRunSessionUsage(params),
}));
import { accountFollowupTurn } from "./agent-runner-result-accounting.js";
@@ -167,6 +168,32 @@ describe("accountFollowupTurn", () => {
vi.clearAllMocks();
});
it("forwards typed runtime context provenance to session persistence", async () => {
const params = createParams();
const result = params.execution.execution.outcome;
if (result.kind !== "settled") {
throw new Error("expected settled test execution");
}
result.result.meta.agentMeta = {
sessionId: "session-1",
provider: "openai",
model: "gpt-4o",
agentHarnessId: "codex",
contextTokens: 1_000_000,
contextTokensSource: "runtime",
};
await accountFollowupTurn(params);
expect(mocks.persistRunSessionUsage).toHaveBeenCalledWith(
expect.objectContaining({
agentHarnessId: "codex",
contextTokensUsed: 1_000_000,
contextTokensSource: "runtime",
}),
);
});
it.each([
{
name: "source-less legacy user pin",
@@ -240,6 +240,7 @@ export async function accountAgentTurn(context: AgentTurnAccountingContext) {
allowAsyncLoad: false,
}) ??
DEFAULT_CONTEXT_TOKENS;
const contextTokensSource = runResult.meta?.agentMeta?.contextTokensSource ?? "resolved";
await persistRunSessionUsage({
storePath,
@@ -257,11 +258,13 @@ export async function accountAgentTurn(context: AgentTurnAccountingContext) {
modelUsed,
providerUsed,
contextTokensUsed,
contextTokensSource,
systemPromptReport: runResult.meta?.systemPromptReport,
cliSessionId,
cliSessionBinding,
clearCliSessionBinding,
preserveFreshTotalTokensOnStaleUsage: preflightCompactionApplied,
agentHarnessId: runResult.meta?.agentMeta?.agentHarnessId,
});
if (!isHeartbeat && !preserveUserFacingSessionState && !fallbackExhausted) {
// A completed run that executed the persisted selection consumes the
@@ -133,6 +133,7 @@ describe("resetReplyRunSession", () => {
modelProvider: "qwencode",
model: "qwen",
contextTokens: 123,
contextTokensSource: "runtime",
contextBudgetStatus: {
schemaVersion: 1,
source: "pre-prompt-estimate",
@@ -207,6 +208,7 @@ describe("resetReplyRunSession", () => {
expect(activeSessionEntry?.claudeCliSessionId).toBeUndefined();
expect(activeSessionEntry?.model).toBeUndefined();
expect(activeSessionEntry?.contextTokens).toBeUndefined();
expect(activeSessionEntry?.contextTokensSource).toBeUndefined();
expect(activeSessionEntry?.contextBudgetStatus).toBeUndefined();
expect(activeSessionEntry?.fallbackNotice).toBeUndefined();
expect(activeSessionEntry?.compactionCount).toBe(0);
@@ -231,6 +233,7 @@ describe("resetReplyRunSession", () => {
const persisted = loadSessionEntry({ storePath, sessionKey });
expect(persisted?.sessionId).toBe(activeSessionEntry?.sessionId);
expect(persisted?.contextTokensSource).toBeUndefined();
expect(persisted?.contextBudgetStatus).toBeUndefined();
expect(persisted?.fallbackNotice).toBeUndefined();
expect(persisted?.compactionCount).toBe(0);
@@ -96,6 +96,7 @@ export async function resetReplyRunSession(params: {
cacheRead: undefined,
cacheWrite: undefined,
contextTokens: undefined,
contextTokensSource: undefined,
contextBudgetStatus: undefined,
systemPromptReport: undefined,
fallbackNotice: undefined,
+10
View File
@@ -1,5 +1,6 @@
/** Persists usage, cost, model, and CLI session metadata after reply runs. */
import { asNonNegativeFiniteNumber } from "@openclaw/normalization-core/number-coercion";
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
import {
clearCliSession,
setCliSessionBinding,
@@ -109,7 +110,9 @@ export async function persistSessionUsageUpdate(params: {
lastCallUsage?: NormalizedUsage;
modelUsed?: string;
providerUsed?: string;
agentHarnessId?: string;
contextTokensUsed?: number;
contextTokensSource?: SessionEntry["contextTokensSource"];
promptTokens?: number;
isHeartbeat?: boolean;
systemPromptReport?: SessionSystemPromptReport;
@@ -129,6 +132,7 @@ export async function persistSessionUsageUpdate(params: {
const label = params.logLabel ? `${params.logLabel} ` : "";
const cfg = params.cfg ?? getRuntimeConfig();
const agentHarnessId = normalizeOptionalString(params.agentHarnessId);
const hasUsage = hasNonzeroUsage(params.usage);
const hasPromptTokens =
typeof params.promptTokens === "number" &&
@@ -192,6 +196,9 @@ export async function persistSessionUsageUpdate(params: {
? entry.modelProvider
: (params.providerUsed ?? entry.modelProvider),
model: preserveSessionModelState ? entry.model : (params.modelUsed ?? entry.model),
...(!preserveSessionModelState
? { agentHarnessId, contextTokensSource: params.contextTokensSource }
: {}),
...(resolvedContextTokens !== undefined
? { contextTokens: resolvedContextTokens }
: {}),
@@ -274,6 +281,9 @@ export async function persistSessionUsageUpdate(params: {
? entry.modelProvider
: (params.providerUsed ?? entry.modelProvider),
model: preserveSessionModelState ? entry.model : (params.modelUsed ?? entry.model),
...(!preserveSessionModelState
? { agentHarnessId, contextTokensSource: params.contextTokensSource }
: {}),
...(contextTokens !== undefined ? { contextTokens } : {}),
systemPromptReport: preserveUserFacingRunState
? entry.systemPromptReport
+113
View File
@@ -3441,6 +3441,7 @@ describe("initSessionState preserves behavior overrides across /new and /reset",
modelOverrideFallbackOriginModel: "gpt-5.5",
totalTokens: 231_980,
contextTokens: 272_000,
contextTokensSource: "runtime",
totalTokensFresh: true,
},
});
@@ -3480,6 +3481,7 @@ describe("initSessionState preserves behavior overrides across /new and /reset",
expect(result.sessionEntry.modelOverrideFallbackOriginModel).toBeUndefined();
expect(result.sessionEntry.totalTokens).toBe(0);
expect(result.sessionEntry.contextTokens).toBeUndefined();
expect(result.sessionEntry.contextTokensSource).toBeUndefined();
expect(result.sessionEntry.totalTokensFresh).toBe(true);
});
@@ -3491,6 +3493,7 @@ describe("initSessionState preserves behavior overrides across /new and /reset",
modelProvider: "openai",
model: "gpt-5.4-mini",
contextTokens: 400_000,
contextTokensSource: "runtime",
cacheRead: 1_000,
cacheWrite: 2_000,
fallbackNotice: {
@@ -3555,6 +3558,7 @@ describe("initSessionState preserves behavior overrides across /new and /reset",
explicitUserOverride.modelOverrideSource,
);
expect(stored[sessionKey]?.contextTokens, name).toBeUndefined();
expect(stored[sessionKey]?.contextTokensSource, name).toBeUndefined();
expect(stored[sessionKey]?.verboseLevel, name).toBe(runtimeModelCache.verboseLevel);
}
});
@@ -4589,6 +4593,115 @@ describe("persistSessionUsageUpdate", () => {
await writeSessionStoreFast(storePath, { [targetSessionKey]: entry });
}
it.each([
{ name: "usage accounting", usage: { input: 120, output: 8, total: 128 } },
{ name: "model-only accounting", usage: undefined },
])(
"persists the producing harness with its model and context window ($name)",
async ({ usage }) => {
const storePath = await createStorePath("openclaw-usage-harness-");
await seedSessionStore(storePath, sessionKey, {
sessionId: "s1",
updatedAt: 1,
modelProvider: "openai",
model: "gpt-5.6-sol",
agentHarnessId: "openclaw",
contextTokens: 272_000,
});
await persistSessionUsageUpdate({
storePath,
sessionKey,
usage,
providerUsed: "openai",
modelUsed: "gpt-5.6-sol",
agentHarnessId: "codex",
contextTokensUsed: 1_000_000,
contextTokensSource: "runtime",
});
expect(readSessionStoreFast(storePath)[sessionKey]).toMatchObject({
modelProvider: "openai",
model: "gpt-5.6-sol",
agentHarnessId: "codex",
contextTokens: 1_000_000,
contextTokensSource: "runtime",
});
},
);
it.each([
{ name: "usage accounting", usage: { input: 120, output: 8, total: 128 } },
{ name: "model-only accounting", usage: undefined },
])(
"preserves the complete producing-runtime tuple when model state is retained ($name)",
async ({ usage }) => {
const storePath = await createStorePath("openclaw-usage-preserved-runtime-");
await seedSessionStore(storePath, sessionKey, {
sessionId: "s1",
updatedAt: 1,
modelProvider: "google",
model: "gemini-3-pro",
agentHarnessId: "openclaw",
contextTokens: 1_000_000,
contextTokensSource: "runtime",
});
await persistSessionUsageUpdate({
storePath,
sessionKey,
usage,
providerUsed: "openai",
modelUsed: "gpt-5.6-sol",
agentHarnessId: "codex",
contextTokensUsed: 272_000,
contextTokensSource: "runtime-configured",
preserveRuntimeModel: true,
});
expect(readSessionStoreFast(storePath)[sessionKey]).toMatchObject({
modelProvider: "google",
model: "gemini-3-pro",
agentHarnessId: "openclaw",
contextTokens: 1_000_000,
contextTokensSource: "runtime",
});
},
);
it.each([
{ name: "usage accounting", usage: { input: 120, output: 8, total: 128 } },
{ name: "model-only accounting", usage: undefined },
])("clears stale harness provenance when a committed run omits it ($name)", async ({ usage }) => {
const storePath = await createStorePath("openclaw-usage-harness-missing-");
await seedSessionStore(storePath, sessionKey, {
sessionId: "s1",
updatedAt: 1,
modelProvider: "openai",
model: "gpt-5.6-sol",
agentHarnessId: "openclaw",
contextTokens: 272_000,
});
await persistSessionUsageUpdate({
storePath,
sessionKey,
usage,
providerUsed: "openai",
modelUsed: "gpt-5.6-sol",
contextTokensUsed: 1_000_000,
contextTokensSource: "runtime",
});
expect(readSessionStoreFast(storePath)[sessionKey]).toMatchObject({
modelProvider: "openai",
model: "gpt-5.6-sol",
contextTokens: 1_000_000,
contextTokensSource: "runtime",
});
expect(readSessionStoreFast(storePath)[sessionKey]).not.toHaveProperty("agentHarnessId");
});
it("accounts exhausted-run usage without committing its model and persists CLI binding", async () => {
const storePath = await createStorePath("openclaw-usage-exhausted-");
await seedSessionStore(storePath, sessionKey, {
+1
View File
@@ -970,6 +970,7 @@ async function initSessionStateAttemptLocked(
sessionEntry.cacheRead = undefined;
sessionEntry.cacheWrite = undefined;
sessionEntry.contextTokens = undefined;
sessionEntry.contextTokensSource = undefined;
sessionEntry.contextBudgetStatus = undefined;
sessionEntry.goal = undefined;
// Skills snapshots are prompt/runtime caches. Do not preserve a stale
+18 -1
View File
@@ -157,13 +157,20 @@ function makeFallbackContextStatusArgs({
totalTokens: 49_000,
totalTokensFresh: true,
totalTokensVersion: 1 as const,
...(sessionContextTokens === undefined ? {} : { contextTokens: sessionContextTokens }),
...(sessionContextTokens === undefined
? {}
: {
contextTokens: sessionContextTokens,
contextTokensSource: "runtime" as const,
agentHarnessId: "openclaw" as const,
}),
},
sessionKey: "agent:main:main",
sessionScope: "per-sender",
queue: { mode: "collect", depth: 0 },
modelAuth: "api-key",
activeModelAuth: "api-key",
resolvedHarness: "openclaw",
};
}
@@ -1846,13 +1853,18 @@ describe("buildStatusMessage", () => {
sessionId: params.sessionId,
updatedAt: 0,
totalTokens: 3,
modelProvider: "anthropic",
model: "claude-opus-4-6",
agentHarnessId: "openclaw",
contextTokens: 32_000,
contextTokensSource: "runtime",
},
sessionKey: params.sessionKey,
sessionScope: "per-sender",
queue: { mode: "collect", depth: 0 },
includeTranscriptUsage: true,
modelAuth: "api-key",
resolvedHarness: "openclaw",
});
}
@@ -2010,13 +2022,18 @@ describe("buildStatusMessage", () => {
sessionId,
updatedAt: 0,
totalTokens: 5,
modelProvider: "anthropic",
model: "claude-opus-4-6",
agentHarnessId: "openclaw",
contextTokens: 32_000,
contextTokensSource: "runtime",
},
// Intentionally omitted: sessionKey
sessionScope: "per-sender",
queue: { mode: "collect", depth: 0 },
includeTranscriptUsage: true,
modelAuth: "api-key",
resolvedHarness: "openclaw",
});
expect(normalizeTestText(text)).toContain("Context: 1.2k/32k");
@@ -174,8 +174,9 @@ function repairProviderlessCodexSessionOverride(
delete entry.model;
delete entry.modelProvider;
}
if (entry.contextTokens !== undefined) {
if (entry.contextTokens !== undefined || entry.contextTokensSource !== undefined) {
delete entry.contextTokens;
delete entry.contextTokensSource;
}
if (entry.contextBudgetStatus !== undefined) {
delete entry.contextBudgetStatus;
@@ -3297,6 +3297,7 @@ describe("collectCodexRouteWarnings", () => {
authProfileOverride: "openai-codex:default",
authProfileOverrideSource: "auto",
contextTokens: 64_000,
contextTokensSource: "runtime",
contextBudgetStatus: {
schemaVersion: 1,
source: "pre-prompt-estimate",
@@ -3336,6 +3337,7 @@ describe("collectCodexRouteWarnings", () => {
expect(getSession(store, "main").modelProvider).toBeUndefined();
expect(getSession(store, "main").model).toBeUndefined();
expect(getSession(store, "main").contextTokens).toBeUndefined();
expect(getSession(store, "main").contextTokensSource).toBeUndefined();
expect(getSession(store, "main").contextBudgetStatus).toBeUndefined();
});
@@ -3373,6 +3375,8 @@ describe("collectCodexRouteWarnings", () => {
agentHarnessId: "openclaw",
agentRuntimeOverride: "openclaw",
authProfileOverride: "openai:work",
contextTokens: 128_000,
contextTokensSource: "runtime",
},
};
@@ -3386,6 +3390,8 @@ describe("collectCodexRouteWarnings", () => {
expect(getSession(store, "main").agentHarnessId).toBe("openclaw");
expect(getSession(store, "main").agentRuntimeOverride).toBe("openclaw");
expect(getSession(store, "main").authProfileOverride).toBe("openai:work");
expect(getSession(store, "main").contextTokens).toBe(128_000);
expect(getSession(store, "main").contextTokensSource).toBe("runtime");
});
it("repairs legacy routes without probing OAuth readiness", () => {
@@ -1,6 +1,9 @@
// Sessions ACP runtime metadata tests cover session-owned runtime overlays.
import { describe, expect, it } from "vitest";
import { resolveModelAgentRuntimeMetadata } from "../agents/agent-runtime-metadata.js";
import {
resolveCurrentSessionAgentRuntimeMetadata,
resolveModelAgentRuntimeMetadata,
} from "../agents/agent-runtime-metadata.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { parseAgentSessionKey } from "../routing/session-key.js";
@@ -93,4 +96,43 @@ describe("session ACP runtime metadata", () => {
expect(agentRuntime).toEqual({ id: "codex", source: "session" });
});
it("reports current model policy instead of an unlocked historical producer", () => {
const agentRuntime = resolveCurrentSessionAgentRuntimeMetadata({
cfg: {
agents: {
defaults: {
models: {
"openai/gpt-5.6-sol": { agentRuntime: { id: "codex" } },
},
},
},
} as OpenClawConfig,
agentId: "main",
provider: "openai",
model: "gpt-5.6-sol",
sessionKey: NON_ACP_SESSION_KEY,
sessionEntry: {
agentHarnessId: "openclaw",
},
});
expect(agentRuntime).toEqual({ id: "codex", source: "model" });
});
it("keeps an explicit compatible runtime override", () => {
const agentRuntime = resolveCurrentSessionAgentRuntimeMetadata({
cfg: buildConfigWithoutAgentRuntimePolicy(),
agentId: "main",
provider: "openai",
model: "gpt-5.6-sol",
sessionKey: NON_ACP_SESSION_KEY,
sessionEntry: {
agentHarnessId: "openclaw",
agentRuntimeOverride: "codex",
},
});
expect(agentRuntime).toEqual({ id: "codex", source: "session-key" });
});
});
@@ -24,6 +24,7 @@ type SessionsJsonPayload = {
modelProvider?: string | null;
model?: string | null;
agentRuntime?: { id: string; source: string };
contextTokens?: number | null;
}>;
};
@@ -200,4 +201,153 @@ describe("sessionsCommand model resolution", () => {
},
);
});
it("projects current runtime and context after a same-model harness change", async () => {
setMockSessionsConfig(() => ({
agents: {
defaults: {
model: { primary: "openai/gpt-5.6-sol" },
models: {
"openai/gpt-5.6-sol": { agentRuntime: { id: "codex" } },
},
},
},
models: {
providers: {
openai: {
models: [{ id: "gpt-5.6-sol", contextTokens: 1_000_000, contextWindow: 1_050_000 }],
},
},
},
}));
await withSqliteStore(
"sessions-current-runtime-context",
{
"agent:main:main": {
sessionId: "stale-openclaw-window",
updatedAt: Date.now() - 60_000,
modelProvider: "openai",
model: "gpt-5.6-sol",
agentHarnessId: "openclaw",
contextTokens: 272_000,
contextTokensSource: "runtime",
},
},
async (store) => {
const payload = await runSessionsJson<SessionsJsonPayload>(sessionsCommand, store);
const session = payload.sessions?.find((row) => row.key === "agent:main:main");
expect(session?.agentRuntime).toEqual({ id: "codex", source: "model" });
expect(session?.contextTokens).toBe(1_000_000);
},
);
});
it("keeps matching runtime telemetry below a higher native window", async () => {
setMockSessionsConfig(() => ({
agents: {
defaults: {
model: { primary: "openai/gpt-5.6-sol" },
models: {
"openai/gpt-5.6-sol": { agentRuntime: { id: "codex" } },
},
},
},
models: {
providers: {
openai: { models: [{ id: "gpt-5.6-sol", contextWindow: 1_000_000 }] },
},
},
}));
await withSqliteStore(
"sessions-matching-runtime-context",
{
"agent:main:main": {
sessionId: "matching-codex-window",
updatedAt: Date.now() - 60_000,
modelProvider: "openai",
model: "gpt-5.6-sol",
agentHarnessId: "codex",
contextTokens: 272_000,
contextTokensSource: "runtime",
},
},
async (store) => {
const payload = await runSessionsJson<SessionsJsonPayload>(sessionsCommand, store);
expect(payload.sessions?.[0]?.contextTokens).toBe(272_000);
},
);
});
it("keeps no-snapshot context resolution scoped to the selected provider", async () => {
setMockSessionsConfig(() => ({
agents: {
defaults: {
model: { primary: "provider-a/shared-model" },
models: {
"provider-a/shared-model": {},
"provider-b/shared-model": {},
},
},
},
models: {
providers: {
"provider-a": { models: [{ id: "shared-model", contextTokens: 128_000 }] },
"provider-b": { models: [{ id: "shared-model", contextTokens: 900_000 }] },
},
},
}));
await withSqliteStore(
"sessions-provider-scoped-context",
{
"agent:main:main": {
sessionId: "provider-a-context",
updatedAt: Date.now() - 60_000,
modelProvider: "provider-a",
model: "shared-model",
},
},
async (store) => {
const payload = await runSessionsJson<SessionsJsonPayload>(sessionsCommand, store);
expect(payload.sessions?.[0]?.contextTokens).toBe(128_000);
},
);
});
it("preserves a locked runtime window above current configuration", async () => {
setMockSessionsConfig(() => ({
agents: {
defaults: {
model: { primary: "openai/gpt-5.6-sol" },
models: {
"openai/gpt-5.6-sol": { agentRuntime: { id: "openclaw" } },
},
},
},
models: {
providers: {
openai: { models: [{ id: "gpt-5.6-sol", contextTokens: 272_000 }] },
},
},
}));
await withSqliteStore(
"sessions-locked-runtime-context",
{
"agent:main:main": {
sessionId: "locked-codex-window",
updatedAt: Date.now() - 60_000,
modelProvider: "openai",
model: "gpt-5.6-sol",
agentHarnessId: "codex",
contextTokens: 1_000_000,
modelSelectionLocked: true,
},
},
async (store) => {
const payload = await runSessionsJson<SessionsJsonPayload>(sessionsCommand, store);
expect(payload.sessions?.[0]?.agentRuntime).toEqual({ id: "codex", source: "session" });
expect(payload.sessions?.[0]?.contextTokens).toBe(1_000_000);
},
);
});
});
+46
View File
@@ -148,6 +148,52 @@ describe("sessionsCommand", () => {
);
});
it("renders current context after a same-model runtime change", async () => {
setMockSessionsConfig(() => ({
agents: {
defaults: {
model: { primary: "openai/gpt-5.6-sol" },
models: {
"openai/gpt-5.6-sol": { agentRuntime: { id: "codex" } },
},
},
},
models: {
providers: {
openai: {
models: [{ id: "gpt-5.6-sol", contextTokens: 1_000_000, contextWindow: 1_050_000 }],
},
},
},
}));
const store = await writeStore(
{
"agent:main:main": {
sessionId: "stale-openclaw-window",
updatedAt: Date.now() - 60_000,
modelProvider: "openai",
model: "gpt-5.6-sol",
agentHarnessId: "openclaw",
contextTokens: 272_000,
contextTokensSource: "runtime",
totalTokens: 11,
totalTokensFresh: true,
totalTokensVersion: 1,
},
},
"sessions-current-runtime-table",
);
const { runtime, logs } = makeRuntime();
await sessionsCommand({ store }, runtime);
cleanupStore(store);
const row = logs.find((line) => line.includes("agent:main:main")) ?? "";
expect(row).toContain("OpenAI Codex");
expect(row).toContain("0.0k/1000k (0%)");
expect(row).not.toContain("272k");
});
it("shows placeholder rows when tokens are missing", async () => {
const store = await writeStore({
"agent:main:quietchat:group:demo": {
+49 -32
View File
@@ -11,7 +11,8 @@ import {
} from "@openclaw/normalization-core/string-coerce";
import { isRich, theme } from "../../packages/terminal-core/src/theme.js";
import { readAcpSessionMetaBatch } from "../acp/runtime/session-meta.js";
import { resolveModelAgentRuntimeMetadata } from "../agents/agent-runtime-metadata.js";
import { resolveCurrentSessionAgentRuntimeMetadata } from "../agents/agent-runtime-metadata.js";
import { resolveAuthoredModelContextTokens } from "../agents/context-resolution.js";
import { DEFAULT_CONTEXT_TOKENS } from "../agents/defaults.js";
import {
prepareCliProviderClassifier,
@@ -21,6 +22,7 @@ import { resolveRuntimePolicySessionKey } from "../auto-reply/reply/runtime-poli
import { normalizeChatType } from "../channels/chat-type.js";
import { getRuntimeConfig } from "../config/config.js";
import { resolveFreshSessionTotalTokens, resolveSessionTotalTokens } from "../config/sessions.js";
import { resolveProjectedSessionContextTokens } from "../config/sessions/context-token-provenance.js";
import { listSessionEntriesReadOnly } from "../config/sessions/session-accessor.js";
import { resolveSqliteTargetFromSessionStorePath } from "../config/sessions/session-sqlite-target.js";
import type { SessionEntry } from "../config/sessions/types.js";
@@ -57,7 +59,7 @@ import {
type SessionRow = SessionDisplayRow & {
agentId: string;
kind: SessionKind;
agentRuntime: ReturnType<typeof resolveModelAgentRuntimeMetadata>;
agentRuntime: ReturnType<typeof resolveCurrentSessionAgentRuntimeMetadata>;
runtimeLabel: string;
/** Carry the prepared identity into JSON/table emission without re-resolving plugin metadata. */
displayModelRef: { provider: string; model: string };
@@ -178,11 +180,6 @@ const formatTokensCell = (
return colorByPct(padded, pct, rich);
};
async function lookupContextTokensForDisplay(model: string): Promise<number | undefined> {
const { lookupContextTokens } = await contextLookupRuntimeLoader.load();
return lookupContextTokens(model, { allowAsyncLoad: false });
}
const formatKindCell = (kind: SessionRow["kind"], rich: boolean) => {
const label = kind.padEnd(KIND_PAD);
if (!rich) {
@@ -203,7 +200,7 @@ const formatKindCell = (kind: SessionRow["kind"], rich: boolean) => {
function resolveSessionRuntimeLabel(params: {
cfg: OpenClawConfig;
entry: SessionEntry;
agentRuntime: ReturnType<typeof resolveModelAgentRuntimeMetadata>;
agentRuntime: ReturnType<typeof resolveCurrentSessionAgentRuntimeMetadata>;
modelProvider: string;
classifyCliProvider: CliProviderClassifier;
}): string {
@@ -322,8 +319,10 @@ export async function sessionsCommand(
const aggregateAgents = opts.allAgents === true;
const cfg = getRuntimeConfig();
const displayDefaults = resolveSessionDisplayDefaults(cfg);
const { lookupContextTokens, resolveContextTokensForModel } =
await contextLookupRuntimeLoader.load();
const configContextTokens =
(await lookupContextTokensForDisplay(displayDefaults.model)) ?? DEFAULT_CONTEXT_TOKENS;
lookupContextTokens(displayDefaults.model, { allowAsyncLoad: false }) ?? DEFAULT_CONTEXT_TOKENS;
const targets = resolveSessionStoreTargetsOrExit({
cfg,
opts: {
@@ -392,7 +391,7 @@ export async function sessionsCommand(
acpSessionKey,
acpRuntime,
);
const agentRuntime = resolveModelAgentRuntimeMetadata({
const agentRuntime = resolveCurrentSessionAgentRuntimeMetadata({
cfg,
agentId,
sessionEntry: entry,
@@ -402,10 +401,37 @@ export async function sessionsCommand(
acpRuntime,
acpBackend: acpMeta?.backend,
});
const hasPersistedContextTokens =
typeof entry.contextTokens === "number" && entry.contextTokens > 0;
// CLI-backed rows can store a canonical display provider that does not own
// the runtime's context policy, so retain their model-only offline fallback.
const usesCliContextFallback =
!hasPersistedContextTokens && classifyCliProvider(agentRuntime.id);
const resolvedContextTokens = usesCliContextFallback
? lookupContextTokens(modelRef.model, { allowAsyncLoad: false })
: resolveContextTokensForModel({
cfg,
provider: modelRef.provider,
model: modelRef.model,
allowAsyncLoad: false,
});
const contextTokens = resolveProjectedSessionContextTokens({
entry,
provider: modelRef.provider,
model: modelRef.model,
agentHarnessId: agentRuntime.id,
resolvedContextTokens,
authoredContextTokens: resolveAuthoredModelContextTokens({
cfg,
provider: modelRef.provider,
model: modelRef.model,
}),
});
return Object.assign({}, row, {
agentId,
acpRuntime,
agentRuntime,
contextTokens,
displayModelRef: modelRef,
kind: classifySessionKind(row.key, entry),
runtimePolicySessionKey: resolveDisplayRuntimePolicySessionKey({
@@ -444,26 +470,18 @@ export async function sessionsCommand(
limitApplied: limit ?? null,
hasMore,
activeMinutes: activeMinutes ?? null,
sessions: await Promise.all(
rows.map(async (row) => {
const r = toJsonSessionRow(row);
const modelRef = row.displayModelRef;
return {
...r,
totalTokens: resolveSessionTotalTokens(r) ?? null,
totalTokensFresh: resolveFreshSessionTotalTokens(r) !== undefined,
// Prefer row-level context tokens, then config/model lookup, so JSON
// mirrors the terminal percentage calculation.
contextTokens:
r.contextTokens ??
(await lookupContextTokensForDisplay(modelRef.model)) ??
configContextTokens ??
null,
modelProvider: modelRef.provider,
model: modelRef.model,
};
}),
),
sessions: rows.map((row) => {
const r = toJsonSessionRow(row);
const modelRef = row.displayModelRef;
return {
...r,
totalTokens: resolveSessionTotalTokens(r) ?? null,
totalTokensFresh: resolveFreshSessionTotalTokens(r) !== undefined,
contextTokens: r.contextTokens ?? configContextTokens ?? null,
modelProvider: modelRef.provider,
model: modelRef.model,
};
}),
});
return;
}
@@ -508,8 +526,7 @@ export async function sessionsCommand(
for (const row of rows) {
const model = row.displayModelRef.model;
const contextTokens =
row.contextTokens ?? (await lookupContextTokensForDisplay(model)) ?? configContextTokens;
const contextTokens = row.contextTokens ?? configContextTokens;
const total = resolveSessionTotalTokens(row);
const freshTotal = resolveFreshSessionTotalTokens(row);
+36 -12
View File
@@ -4,10 +4,10 @@ import { ANTHROPIC_CONTEXT_1M_TOKENS } from "../agents/context-resolution.js";
import { migratePersistedImplicitMainRoster } from "../config/legacy.roster.js";
import { statusSummaryRuntime } from "../status/summary.runtime.js";
function resolveSessionRuntimeLabel(
params: Parameters<typeof statusSummaryRuntime.resolveSessionRuntimeLabel>[0],
function resolveSessionRuntime(
params: Parameters<typeof statusSummaryRuntime.resolveSessionRuntime>[0],
) {
return statusSummaryRuntime.resolveSessionRuntimeLabel({
return statusSummaryRuntime.resolveSessionRuntime({
...params,
cfg: migratePersistedImplicitMainRoster(params.cfg).config as never,
});
@@ -181,10 +181,10 @@ describe("statusSummaryRuntime.classifySessionKey", () => {
});
});
describe("statusSummaryRuntime.resolveSessionRuntimeLabel", () => {
describe("statusSummaryRuntime.resolveSessionRuntime", () => {
it("uses the shared /status runtime label for the implicit OpenAI Codex route", () => {
expect(
resolveSessionRuntimeLabel({
resolveSessionRuntime({
cfg: {} as never,
entry: {
sessionId: "session-1",
@@ -194,12 +194,12 @@ describe("statusSummaryRuntime.resolveSessionRuntimeLabel", () => {
model: "gpt-5.5",
sessionKey: "agent:main:main",
}),
).toBe("OpenAI Codex");
).toEqual({ id: "codex", label: "OpenAI Codex" });
});
it("preserves configured default model CLI runtimes", () => {
expect(
resolveSessionRuntimeLabel({
resolveSessionRuntime({
cfg: {
agents: {
defaults: {
@@ -217,12 +217,12 @@ describe("statusSummaryRuntime.resolveSessionRuntimeLabel", () => {
model: "claude-sonnet-4-6",
sessionKey: "agent:main:main",
}),
).toBe("Claude CLI");
).toEqual({ id: "claude-cli", label: "Claude CLI" });
});
it("preserves configured agent model runtimes before harness selection", () => {
expect(
resolveSessionRuntimeLabel({
resolveSessionRuntime({
cfg: {
agents: {
defaults: {
@@ -249,12 +249,36 @@ describe("statusSummaryRuntime.resolveSessionRuntimeLabel", () => {
agentId: "research",
sessionKey: "agent:research:main",
}),
).toBe("OpenAI Codex");
).toEqual({ id: "codex", label: "OpenAI Codex" });
});
it("does not treat an unlocked producing harness as the current runtime", () => {
expect(
resolveSessionRuntime({
cfg: {
agents: {
defaults: {
models: {
"openai/gpt-5.5": { agentRuntime: { id: "codex" } },
},
},
},
} as never,
entry: {
sessionId: "openclaw-produced-session",
updatedAt: 0,
agentHarnessId: "openclaw",
},
provider: "openai",
model: "gpt-5.5",
sessionKey: "agent:main:main",
}),
).toEqual({ id: "codex", label: "OpenAI Codex" });
});
it("reports the owning Codex harness for a locked session with stale OpenClaw metadata", () => {
expect(
resolveSessionRuntimeLabel({
resolveSessionRuntime({
cfg: {
agents: {
defaults: {
@@ -275,7 +299,7 @@ describe("statusSummaryRuntime.resolveSessionRuntimeLabel", () => {
model: "gpt-5.5",
sessionKey: "agent:main:main",
}),
).toBe("OpenAI Codex");
).toEqual({ id: "codex", label: "OpenAI Codex" });
});
});
+179
View File
@@ -0,0 +1,179 @@
/** Shared status-summary cases for session runtime and context-window projection. */
import { describe, expect, it, vi } from "vitest";
import { SESSION_TOTAL_TOKENS_VERSION } from "../config/sessions/types.js";
type GetStatusSummary = typeof import("../status/summary.js").getStatusSummary;
type StatusSummaryRuntime = typeof import("../status/summary.runtime.js").statusSummaryRuntime;
type SessionStore = Record<string, Record<string, unknown>>;
export function registerStatusSummarySessionRowCases(params: {
getStatusSummary: () => ReturnType<GetStatusSummary>;
getStatusSummaryRuntime: () => StatusSummaryRuntime;
rejectProviderStaticModel: (error: Error) => void;
setSessions: (store: SessionStore) => void;
}): void {
describe("status summary session rows", () => {
it("keeps status available when static catalog lookup fails", async () => {
vi.mocked(
params.getStatusSummaryRuntime().resolveConfiguredStatusModelRef,
).mockReturnValueOnce({
provider: "broken-provider",
model: "broken-model",
});
params.rejectProviderStaticModel(new Error("static catalog unavailable"));
await expect(params.getStatusSummary()).resolves.toMatchObject({
sessions: {
defaults: {
model: "broken-model",
contextTokens: 200_000,
},
},
});
});
it("includes the selected agent runtime on recent sessions", async () => {
vi.mocked(params.getStatusSummaryRuntime().resolveSessionRuntime).mockReturnValue({
id: "codex",
label: "OpenAI Codex",
});
params.setSessions({
"agent:main:main": {
sessionId: "session-1",
updatedAt: Date.now(),
},
});
const summary = await params.getStatusSummary();
expect(summary.sessions.recent[0]?.runtime).toBe("OpenAI Codex");
});
it("rejects a stale runtime window after a same-model harness change", async () => {
vi.mocked(params.getStatusSummaryRuntime().resolveContextTokensForModel).mockReturnValue(
1_000_000,
);
vi.mocked(params.getStatusSummaryRuntime().resolveSessionRuntime).mockReturnValue({
id: "codex",
label: "OpenAI Codex",
});
params.setSessions({
"agent:main:main": {
sessionId: "same-model-runtime-change",
updatedAt: Date.now(),
modelProvider: "openai",
model: "gpt-5.5",
agentHarnessId: "openclaw",
contextTokens: 272_000,
contextTokensSource: "runtime",
totalTokens: 11,
totalTokensFresh: true,
totalTokensVersion: SESSION_TOTAL_TOKENS_VERSION,
},
});
const summary = await params.getStatusSummary();
expect(summary.sessions.recent[0]).toMatchObject({
runtime: "OpenAI Codex",
contextTokens: 1_000_000,
remainingTokens: 999_989,
});
});
it("keeps telemetry from the matching runtime producer", async () => {
vi.mocked(params.getStatusSummaryRuntime().resolveContextTokensForModel).mockReturnValue(
1_000_000,
);
params.setSessions({
"agent:main:main": {
sessionId: "matching-runtime-window",
updatedAt: Date.now(),
modelProvider: "openai",
model: "gpt-5.5",
agentHarnessId: "openclaw",
contextTokens: 272_000,
contextTokensSource: "runtime",
totalTokens: 11,
totalTokensFresh: true,
totalTokensVersion: SESSION_TOTAL_TOKENS_VERSION,
},
});
const summary = await params.getStatusSummary();
expect(summary.sessions.recent[0]?.contextTokens).toBe(272_000);
});
it("replaces matching runtime telemetry with a newly authored effective cap", async () => {
vi.mocked(params.getStatusSummaryRuntime().resolveAuthoredModelContextTokens).mockReturnValue(
1_000_000,
);
vi.mocked(params.getStatusSummaryRuntime().resolveContextTokensForModel).mockReturnValue(
1_000_000,
);
params.setSessions({
"agent:main:main": {
sessionId: "authored-context-cap",
updatedAt: Date.now(),
modelProvider: "openai",
model: "gpt-5.5",
agentHarnessId: "openclaw",
contextTokens: 272_000,
contextTokensSource: "runtime",
},
});
const summary = await params.getStatusSummary();
expect(summary.sessions.recent[0]?.contextTokens).toBe(1_000_000);
});
it("preserves the native window owned by a locked legacy session", async () => {
vi.mocked(params.getStatusSummaryRuntime().resolveContextTokensForModel).mockReturnValue(
272_000,
);
vi.mocked(params.getStatusSummaryRuntime().resolveSessionRuntime).mockReturnValue({
id: "codex",
label: "OpenAI Codex",
});
params.setSessions({
"agent:main:main": {
sessionId: "locked-legacy-window",
updatedAt: Date.now(),
modelSelectionLocked: true,
contextTokens: 1_000_000,
},
});
const summary = await params.getStatusSummary();
expect(summary.sessions.recent[0]?.contextTokens).toBe(1_000_000);
});
it("caps matching unlocked runtime telemetry to the lower current window", async () => {
vi.mocked(params.getStatusSummaryRuntime().resolveContextTokensForModel).mockReturnValue(
272_000,
);
vi.mocked(params.getStatusSummaryRuntime().resolveSessionRuntime).mockReturnValue({
id: "codex",
label: "OpenAI Codex",
});
params.setSessions({
"agent:main:main": {
sessionId: "unlocked-runtime-window",
updatedAt: Date.now(),
modelProvider: "openai",
model: "gpt-5.5",
agentHarnessId: "codex",
contextTokens: 1_000_000,
contextTokensSource: "runtime",
},
});
const summary = await params.getStatusSummary();
expect(summary.sessions.recent[0]?.contextTokens).toBe(272_000);
});
});
}
+26 -38
View File
@@ -6,6 +6,7 @@ import { setActiveDegradedSecretOwners } from "../secrets/runtime-degraded-state
import type { TaskAuditFinding } from "../tasks/task-registry.audit.js";
import type { TaskRecord, TaskRegistrySummary } from "../tasks/task-registry.types.js";
import { normalizeSessionDeliveryState } from "../utils/delivery-context.shared.js";
import { registerStatusSummarySessionRowCases } from "./status.summary.test-support.js";
const statusSummaryMocks = vi.hoisted(() => ({
hasConfiguredChannelsForReadOnlyScope: vi.fn(() => true),
@@ -82,7 +83,7 @@ vi.mock("../status/summary.runtime.js", () => ({
provider: "openai",
model: "gpt-5.5",
})),
resolveSessionRuntimeLabel: vi.fn(() => "OpenClaw Default"),
resolveSessionRuntime: vi.fn(() => ({ id: "openclaw", label: "OpenClaw Default" })),
resolveStatusModelLookupRef: vi.fn(({ provider, model }) =>
typeof model === "string" && model.length > 0
? {
@@ -96,6 +97,7 @@ vi.mock("../status/summary.runtime.js", () => ({
? `${typeof provider === "string" && provider.length > 0 ? provider : "openai"}/${model}`
: null,
),
resolveAuthoredModelContextTokens: vi.fn(() => undefined),
resolveContextTokensForModel: vi.fn(() => 200_000),
waitForContextWindowCacheLoad: vi.fn(async () => "idle" as const),
},
@@ -265,6 +267,12 @@ describe("getStatusSummary", () => {
: undefined,
);
statusSummaryMocks.listSessionEntriesCore.mockReturnValue([]);
vi.mocked(statusSummaryRuntime.resolveAuthoredModelContextTokens).mockReturnValue(undefined);
vi.mocked(statusSummaryRuntime.resolveContextTokensForModel).mockReturnValue(200_000);
vi.mocked(statusSummaryRuntime.resolveSessionRuntime).mockReturnValue({
id: "openclaw",
label: "OpenClaw Default",
});
vi.mocked(resolveSessionStorePathCore).mockReturnValue("/tmp/sessions.json");
vi.mocked(listGatewayAgentsBasic).mockReturnValue({
defaultId: "main",
@@ -274,6 +282,15 @@ describe("getStatusSummary", () => {
});
});
registerStatusSummarySessionRowCases({
getStatusSummary: () => getStatusSummary(),
getStatusSummaryRuntime: () => statusSummaryRuntime,
rejectProviderStaticModel: (error) =>
statusSummaryMocks.resolveProviderStaticModel.mockRejectedValueOnce(error),
setSessions: (store) =>
statusSummaryMocks.listSessionEntriesCore.mockReturnValue(toSessionEntrySummaries(store)),
});
it("includes runtimeVersion in the status payload", async () => {
const summary = await getStatusSummary();
@@ -702,41 +719,6 @@ describe("getStatusSummary", () => {
});
});
it("keeps status available when static catalog lookup fails", async () => {
vi.mocked(statusSummaryRuntime.resolveConfiguredStatusModelRef).mockReturnValue({
provider: "broken-provider",
model: "broken-model",
});
statusSummaryMocks.resolveProviderStaticModel.mockRejectedValueOnce(
new Error("static catalog unavailable"),
);
await expect(getStatusSummary()).resolves.toMatchObject({
sessions: {
defaults: {
model: "broken-model",
contextTokens: 200_000,
},
},
});
});
it("includes the selected agent runtime on recent sessions", async () => {
vi.mocked(statusSummaryRuntime.resolveSessionRuntimeLabel).mockReturnValue("OpenAI Codex");
statusSummaryMocks.listSessionEntriesCore.mockReturnValue(
toSessionEntrySummaries({
"agent:main:main": {
sessionId: "session-1",
updatedAt: Date.now(),
},
}),
);
const summary = await getStatusSummary();
expect(summary.sessions.recent[0]?.runtime).toBe("OpenAI Codex");
});
it("hydrates only recent session rows while preserving total counts", async () => {
const store = Object.fromEntries(
Array.from({ length: 12 }, (_, index) => {
@@ -773,7 +755,7 @@ describe("getStatusSummary", () => {
);
const hydratedKeys = vi
.mocked(statusSummaryRuntime.resolveSessionRuntimeLabel)
.mocked(statusSummaryRuntime.resolveSessionRuntime)
.mock.calls.map(([params]) => params.sessionKey);
expect(hydratedKeys).not.toContain("agent:main:session-1");
expect(hydratedKeys).not.toContain("agent:main:session-2");
@@ -924,6 +906,11 @@ describe("getStatusSummary", () => {
modelOverrideSource: "auto",
modelOverrideFallbackOriginProvider: "zhipu",
modelOverrideFallbackOriginModel: "glm-4.5-air",
modelProvider: "deepseek",
model: "deepseek-v4-flash",
agentHarnessId: "openclaw",
contextTokens: 128_000,
contextTokensSource: "runtime",
},
}),
);
@@ -933,6 +920,7 @@ describe("getStatusSummary", () => {
expect(summary.sessions.recent[0]?.configuredModel).toBe("zhipu/glm-4.5-air");
expect(summary.sessions.recent[0]?.selectedModel).toBe("deepseek/deepseek-v4-flash");
expect(summary.sessions.recent[0]?.modelSelectionReason).toBe("fallback selected");
expect(summary.sessions.recent[0]?.contextTokens).toBe(128_000);
});
it("does not mark configured subagent models as auto fallback", async () => {
@@ -1040,7 +1028,7 @@ describe("getStatusSummary", () => {
expect(summary.sessions.recent[0]?.configuredModel).toBe("anthropic/claude-opus-4-8");
expect(summary.sessions.recent[0]?.selectedModel).toBe("anthropic/opus");
expect(summary.sessions.recent[0]?.modelSelectionReason).toBeNull();
expect(statusSummaryRuntime.resolveSessionRuntimeLabel).toHaveBeenCalledWith(
expect(statusSummaryRuntime.resolveSessionRuntime).toHaveBeenCalledWith(
expect.objectContaining({
provider: "anthropic",
model: "claude-opus-4-8",
+1
View File
@@ -27,3 +27,4 @@ export * from "./sessions/delivery-info.js";
export * from "./sessions/disk-budget.js";
export * from "./sessions/targets.js";
export * from "./sessions/cleanup-service.js";
export * from "./sessions/context-token-provenance.js";
@@ -0,0 +1,140 @@
import { describe, expect, it } from "vitest";
import {
resolveProjectedSessionContextTokens,
resolveTrustedSessionContextTokens,
} from "./context-token-provenance.js";
const currentSelection = {
provider: "openai",
model: "gpt-5.6-sol",
agentHarnessId: "codex",
};
describe("resolveTrustedSessionContextTokens", () => {
it("trusts only runtime telemetry from the exact producing selection", () => {
expect(
resolveTrustedSessionContextTokens({
entry: {
modelProvider: "OpenAI",
model: "GPT-5.6-SOL",
agentHarnessId: "Codex",
contextTokens: 272_000,
contextTokensSource: "runtime",
},
...currentSelection,
}),
).toBe(272_000);
});
it.each([
{ name: "missing source", patch: { contextTokensSource: undefined } },
{ name: "resolved source", patch: { contextTokensSource: "resolved" as const } },
{
name: "runtime-configured source",
patch: { contextTokensSource: "runtime-configured" as const },
},
{ name: "missing harness", patch: { agentHarnessId: undefined } },
{ name: "different harness", patch: { agentHarnessId: "openclaw" } },
{ name: "different provider", patch: { modelProvider: "openrouter" } },
{ name: "different model", patch: { model: "gpt-5.5" } },
])("rejects $name", ({ patch }) => {
expect(
resolveTrustedSessionContextTokens({
entry: {
modelProvider: "openai",
model: "gpt-5.6-sol",
agentHarnessId: "codex",
contextTokens: 272_000,
contextTokensSource: "runtime",
...patch,
},
...currentSelection,
}),
).toBeUndefined();
});
it("preserves the native window owned by a locked legacy session", () => {
expect(
resolveTrustedSessionContextTokens({
entry: {
modelSelectionLocked: true,
contextTokens: 272_000,
},
...currentSelection,
}),
).toBe(272_000);
});
it.each([
{ name: "provider", patch: { modelProvider: "openrouter" } },
{ name: "model", patch: { model: "gpt-5.5" } },
])("rejects a locked window owned by a different $name", ({ patch }) => {
expect(
resolveTrustedSessionContextTokens({
entry: {
modelProvider: "openai",
model: "gpt-5.6-sol",
modelSelectionLocked: true,
contextTokens: 272_000,
...patch,
},
...currentSelection,
}),
).toBeUndefined();
});
});
describe("resolveProjectedSessionContextTokens", () => {
const matchingRuntimeEntry = {
modelProvider: "openai",
model: "gpt-5.6-sol",
agentHarnessId: "codex",
contextTokens: 272_000,
contextTokensSource: "runtime" as const,
};
it("uses an authored effective cap instead of older matching telemetry", () => {
expect(
resolveProjectedSessionContextTokens({
entry: matchingRuntimeEntry,
...currentSelection,
resolvedContextTokens: 1_000_000,
authoredContextTokens: 1_000_000,
}),
).toBe(1_000_000);
});
it("keeps matching runtime telemetry below a higher native window", () => {
expect(
resolveProjectedSessionContextTokens({
entry: matchingRuntimeEntry,
...currentSelection,
resolvedContextTokens: 1_000_000,
}),
).toBe(272_000);
});
it("falls back to current resolution when producer provenance differs", () => {
expect(
resolveProjectedSessionContextTokens({
entry: { ...matchingRuntimeEntry, agentHarnessId: "openclaw" },
...currentSelection,
resolvedContextTokens: 1_000_000,
}),
).toBe(1_000_000);
});
it("preserves a locked native window ahead of current configuration", () => {
expect(
resolveProjectedSessionContextTokens({
entry: {
modelSelectionLocked: true,
contextTokens: 1_000_000,
},
...currentSelection,
resolvedContextTokens: 272_000,
authoredContextTokens: 272_000,
}),
).toBe(1_000_000);
});
});
@@ -0,0 +1,90 @@
import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce";
import type { SessionEntry } from "./types.js";
type SessionContextTokenOwner = Pick<
SessionEntry,
| "agentHarnessId"
| "contextTokens"
| "contextTokensSource"
| "model"
| "modelProvider"
| "modelSelectionLocked"
>;
function resolvePositiveContextTokens(value: number | null | undefined): number | undefined {
return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : undefined;
}
/** Returns persisted telemetry only when it belongs to the current producing selection. */
export function resolveTrustedSessionContextTokens(params: {
entry: SessionContextTokenOwner | undefined;
provider: string | null | undefined;
model: string | null | undefined;
agentHarnessId: string | null | undefined;
}): number | undefined {
const contextTokens = resolvePositiveContextTokens(params.entry?.contextTokens);
if (contextTokens === undefined) {
return undefined;
}
const entryProvider = normalizeLowercaseStringOrEmpty(params.entry?.modelProvider);
const entryModel = normalizeLowercaseStringOrEmpty(params.entry?.model);
const currentProvider = normalizeLowercaseStringOrEmpty(params.provider);
const currentModel = normalizeLowercaseStringOrEmpty(params.model);
// Locked sessions own their native window, including rows created before
// context-window provenance was persisted. A known selection mismatch is a
// different owner, while missing identity remains a supported legacy state.
if (params.entry?.modelSelectionLocked === true) {
if (
(entryProvider && currentProvider && entryProvider !== currentProvider) ||
(entryModel && currentModel && entryModel !== currentModel)
) {
return undefined;
}
return contextTokens;
}
if (params.entry?.contextTokensSource !== "runtime") {
return undefined;
}
const entryHarness = normalizeLowercaseStringOrEmpty(params.entry.agentHarnessId);
const currentHarness = normalizeLowercaseStringOrEmpty(params.agentHarnessId);
if (
!entryProvider ||
!entryModel ||
!entryHarness ||
!currentProvider ||
!currentModel ||
!currentHarness
) {
return undefined;
}
return entryProvider === currentProvider &&
entryModel === currentModel &&
entryHarness === currentHarness
? contextTokens
: undefined;
}
/** Projects the context window owned by the current session selection. */
export function resolveProjectedSessionContextTokens(params: {
entry: SessionContextTokenOwner | undefined;
provider: string | null | undefined;
model: string | null | undefined;
agentHarnessId: string | null | undefined;
resolvedContextTokens: number | null | undefined;
authoredContextTokens?: number | null | undefined;
}): number | undefined {
const resolvedContextTokens = resolvePositiveContextTokens(params.resolvedContextTokens);
const authoredContextTokens = resolvePositiveContextTokens(params.authoredContextTokens);
const trustedContextTokens = resolveTrustedSessionContextTokens(params);
// An authored effective cap owns the current selection. Otherwise current
// model capacity only constrains telemetry from that exact producer tuple.
const currentContextTokens =
authoredContextTokens !== undefined
? resolvedContextTokens
: trustedContextTokens !== undefined && resolvedContextTokens !== undefined
? Math.min(trustedContextTokens, resolvedContextTokens)
: (trustedContextTokens ?? resolvedContextTokens);
return params.entry?.modelSelectionLocked === true
? (trustedContextTokens ?? currentContextTokens)
: currentContextTokens;
}
@@ -86,6 +86,7 @@ async function createSession(options: { activeLeafTarget?: string } = {}) {
cliSessionIds: { "claude-cli": "claude-conversation" },
compactionCount: 2,
contextTokens: 100_000,
contextTokensSource: "runtime",
createdVia: "operator",
createdActor: { type: "human", id: "profile-1" },
createdAt: 1_000,
@@ -488,6 +489,7 @@ describe("SQLite session message cuts", () => {
cliSessionIds: undefined,
compactionCount: undefined,
contextTokens: undefined,
contextTokensSource: undefined,
createdVia: "operator",
createdActor: { type: "human", id: "profile-1" },
createdAt: 1_000,
@@ -524,6 +524,7 @@ function cloneMessageCutSessionEntry(params: {
// A rotated transcript cannot resume provider/runtime identity from the old tail.
// Clear transcript-derived accounting too so the next turn rebuilds canonical state.
contextTokens: undefined,
contextTokensSource: undefined,
contextBudgetStatus: undefined,
compactionCount: undefined,
compactionCheckpoints: undefined,
@@ -167,6 +167,7 @@ describe("session snapshot merge", () => {
reason: "rate_limit",
},
contextTokens: 100_000,
contextTokensSource: "resolved",
contextBudgetStatus: {
schemaVersion: 1,
source: "pre-prompt-estimate",
@@ -195,6 +196,7 @@ describe("session snapshot merge", () => {
model: undefined,
fallbackNotice: undefined,
contextTokens: undefined,
contextTokensSource: undefined,
contextBudgetStatus: undefined,
};
const current: SessionEntry = {
@@ -208,6 +210,7 @@ describe("session snapshot merge", () => {
activeModel: "openai/gpt-5.4-nano",
},
contextTokens: 80_000,
contextTokensSource: "runtime",
};
const merged = mergeSessionSnapshotChanges({ initial: initialOverride, next, current });
@@ -221,6 +224,7 @@ describe("session snapshot merge", () => {
expect(merged.model).toBeUndefined();
expect(merged.fallbackNotice).toBeUndefined();
expect(merged.contextTokens).toBeUndefined();
expect(merged.contextTokensSource).toBeUndefined();
expect(merged.contextBudgetStatus).toBeUndefined();
});
@@ -27,6 +27,7 @@ const MODEL_OVERRIDE_RUNTIME_FIELDS = [
"model",
"fallbackNotice",
"contextTokens",
"contextTokensSource",
"contextBudgetStatus",
] as const satisfies ReadonlyArray<keyof SessionEntry>;
const MODEL_OVERRIDE_RUNTIME_FIELD_SET = new Set<keyof SessionEntry>(MODEL_OVERRIDE_RUNTIME_FIELDS);
+2
View File
@@ -580,6 +580,8 @@ type SessionEntryCore = SessionRestartRecoveryState &
agentHarnessId?: string;
fallbackNotice?: FallbackNoticeState;
contextTokens?: number;
/** Origin of the persisted context window; absent on legacy/unproven rows. */
contextTokensSource?: "runtime" | "runtime-configured" | "resolved";
contextBudgetStatus?: SessionContextBudgetStatus;
compactionCount?: number;
compactionCheckpoints?: SessionCompactionCheckpoint[];
+16 -9
View File
@@ -66,12 +66,13 @@ import {
runWithModelFallback,
} from "./run-execution.runtime.js";
import { resolveCronFallbacksOverride } from "./run-fallback-policy.js";
import type {
CronLiveSelection,
MutableCronSession,
PersistCronSessionEntry,
import {
type CronLiveSelection,
type MutableCronSession,
type PersistCronSessionEntry,
setCronSessionRuntimeModel,
syncCronSessionLiveSelection,
} from "./run-session-state.js";
import { syncCronSessionLiveSelection } from "./run-session-state.js";
import { resolveEffectiveAgentRuntime, resolveThinkingDefault } from "./run.runtime.js";
import { isLikelyInterimCronMessage } from "./subagent-followup-hints.js";
@@ -524,8 +525,11 @@ function createCronPromptExecutor(params: {
});
// The validated candidate that admits detached work owns its continuation
// even if the provider throws before returning result metadata.
params.cronSession.sessionEntry.modelProvider = providerOverride;
params.cronSession.sessionEntry.model = modelOverride;
setCronSessionRuntimeModel({
entry: params.cronSession.sessionEntry,
provider: providerOverride,
model: modelOverride,
});
await params.persistRunContinuationSession?.();
await params.setRunContinuationCliExecutionProvider?.(
cliExecution ? executionProvider : undefined,
@@ -771,8 +775,11 @@ function createCronPromptExecutor(params: {
fallbackModel = fallbackResult.model;
params.liveSelection.provider = fallbackResult.provider;
params.liveSelection.model = fallbackResult.model;
params.cronSession.sessionEntry.modelProvider = fallbackResult.provider;
params.cronSession.sessionEntry.model = fallbackResult.model;
setCronSessionRuntimeModel({
entry: params.cronSession.sessionEntry,
provider: fallbackResult.provider,
model: fallbackResult.model,
});
await params.persistRunContinuationSession?.();
runEndedAt = Date.now();
pendingUserTurn = undefined;
+60 -12
View File
@@ -5,10 +5,15 @@ import {
} from "@openclaw/normalization-core/number-coercion";
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
import { hasAcceptedSessionSpawn } from "../../agents/accepted-session-spawn.js";
import { resolveAuthoredModelContextTokens } from "../../agents/context-resolution.js";
import { hasCommittedMessagingToolDeliveryEvidence } from "../../agents/embedded-agent-runner/delivery-evidence.js";
import { deriveContextPromptTokens } from "../../agents/usage.js";
import { isSilentReplyPayloadText } from "../../auto-reply/tokens.js";
import { SESSION_TOTAL_TOKENS_VERSION } from "../../config/sessions.js";
import {
resolveProjectedSessionContextTokens,
resolveTrustedSessionContextTokens,
} from "../../config/sessions/context-token-provenance.js";
import { emitTrustedDiagnosticEvent, isDiagnosticsEnabled } from "../../infra/diagnostic-events.js";
import {
createChildDiagnosticTraceContext,
@@ -26,13 +31,16 @@ import { resolveCronChannelOutputPolicy } from "./channel-output-policy.js";
import { resolveCronPayloadOutcome } from "./helpers.js";
import { buildCronDeliveryTrace, loadCronDeliveryRuntime } from "./run-delivery-trace.js";
import type { PreparedCronRunContext } from "./run-prepare.js";
import { adoptCronRunSessionMetadata } from "./run-session-state.js";
import {
adoptCronRunSessionMetadata,
setCronSessionAgentHarnessId,
setCronSessionRuntimeModel,
} from "./run-session-state.js";
import {
DEFAULT_CONTEXT_TOKENS,
deriveSessionTotalTokens,
hasNonzeroUsage,
isCliProvider,
setSessionRuntimeModel,
} from "./run.runtime.js";
import type { RunCronAgentTurnResult } from "./run.types.js";
import { cleanupCronRunSessionAfterRun } from "./session-cleanup.js";
@@ -91,22 +99,62 @@ export async function finalizeCronRun(params: {
finalRunResult.meta?.agentMeta?.provider ??
execution.fallbackProvider ??
execution.liveSelection.provider;
const contextTokens =
(await cronContextRuntimeLoader.load()).resolveContextTokensForModel({
cfg: prepared.cfgWithAgentDefaults,
provider: providerUsed,
model: modelUsed,
allowAsyncLoad: false,
}) ??
resolvePositiveContextTokens(prepared.cronSession.sessionEntry.contextTokens) ??
DEFAULT_CONTEXT_TOKENS;
const runtimeContextTokens = resolvePositiveContextTokens(
finalRunResult.meta?.agentMeta?.contextTokens,
);
const modelContextTokens = (await cronContextRuntimeLoader.load()).resolveContextTokensForModel({
cfg: prepared.cfgWithAgentDefaults,
provider: providerUsed,
model: modelUsed,
allowAsyncLoad: false,
});
const agentHarnessId = normalizeOptionalString(finalRunResult.meta?.agentMeta?.agentHarnessId);
const authoredContextTokens = resolveAuthoredModelContextTokens({
cfg: prepared.cfgWithAgentDefaults,
provider: providerUsed,
model: modelUsed,
});
const retainedRuntimeContextTokens = resolveTrustedSessionContextTokens({
entry: prepared.cronSession.sessionEntry,
provider: providerUsed,
model: modelUsed,
agentHarnessId,
});
const projectedContextTokens = resolveProjectedSessionContextTokens({
entry: prepared.cronSession.sessionEntry,
provider: providerUsed,
model: modelUsed,
agentHarnessId,
resolvedContextTokens: modelContextTokens,
authoredContextTokens,
});
const contextTokens = runtimeContextTokens ?? projectedContextTokens ?? DEFAULT_CONTEXT_TOKENS;
// Preserve persisted provenance only when the projector selected that owner;
// a current/authored clamp stays resolved so removed caps cannot stick.
const projectedUsesPersistedContext =
retainedRuntimeContextTokens !== undefined &&
(prepared.cronSession.sessionEntry.modelSelectionLocked === true ||
(authoredContextTokens === undefined &&
projectedContextTokens === retainedRuntimeContextTokens));
const contextTokensSource =
runtimeContextTokens !== undefined
? (finalRunResult.meta?.agentMeta?.contextTokensSource ?? "resolved")
: projectedUsesPersistedContext
? prepared.cronSession.sessionEntry.contextTokensSource
: "resolved";
if (!params.isAborted()) {
setSessionRuntimeModel(prepared.cronSession.sessionEntry, {
setCronSessionRuntimeModel({
entry: prepared.cronSession.sessionEntry,
provider: providerUsed,
model: modelUsed,
});
setCronSessionAgentHarnessId({
entry: prepared.cronSession.sessionEntry,
agentHarnessId,
});
prepared.cronSession.sessionEntry.contextTokens = contextTokens;
prepared.cronSession.sessionEntry.contextTokensSource = contextTokensSource;
if (isCliProvider(providerUsed, prepared.cfgWithAgentDefaults)) {
const cliSessionBinding = finalRunResult.meta?.agentMeta?.cliSessionBinding;
const cliSessionId = finalRunResult.meta?.agentMeta?.sessionId?.trim();
@@ -19,6 +19,7 @@ import {
CronSessionLifecycleClaimError,
createCronRunContinuationSession,
createPersistCronSessionEntry,
markCronSessionPreRun,
resolveCronLifecycleRevisionIdentity,
syncCronSessionLiveSelection,
type MutableCronSession,
@@ -66,7 +67,69 @@ function makeGuardedPersistSessionEntry(persistedStore: Record<string, SessionEn
);
}
describe("markCronSessionPreRun", () => {
it("clears model-derived state when the selected model changes", () => {
const entry = makeSessionEntry({
modelProvider: "openai",
model: "gpt-5.3",
contextTokens: 272_000,
contextTokensSource: "runtime",
contextBudgetStatus: {} as NonNullable<SessionEntry["contextBudgetStatus"]>,
});
markCronSessionPreRun({ entry, provider: "openai", model: "gpt-5.4" });
expect(entry.modelProvider).toBe("openai");
expect(entry.model).toBe("gpt-5.4");
expect(entry.contextTokens).toBeUndefined();
expect(entry.contextTokensSource).toBeUndefined();
expect(entry.contextBudgetStatus).toBeUndefined();
});
it("preserves model-derived state when the selected model is unchanged", () => {
const contextBudgetStatus = {} as NonNullable<SessionEntry["contextBudgetStatus"]>;
const entry = makeSessionEntry({
modelProvider: "openai",
model: "gpt-5.4",
contextTokens: 272_000,
contextTokensSource: "runtime",
contextBudgetStatus,
});
markCronSessionPreRun({ entry, provider: "openai", model: "gpt-5.4" });
expect(entry.contextTokens).toBe(272_000);
expect(entry.contextTokensSource).toBe("runtime");
expect(entry.contextBudgetStatus).toBe(contextBudgetStatus);
});
});
describe("syncCronSessionLiveSelection", () => {
it("clears model-derived state when only the agent runtime changes", () => {
const entry = makeSessionEntry({
modelProvider: "openai",
model: "gpt-5.6-luna",
agentRuntimeOverride: "openclaw",
contextTokens: 272_000,
contextTokensSource: "runtime",
contextBudgetStatus: {} as NonNullable<SessionEntry["contextBudgetStatus"]>,
});
syncCronSessionLiveSelection({
entry,
liveSelection: {
provider: "openai",
model: "gpt-5.6-luna",
agentRuntimeOverride: "codex",
},
});
expect(entry.agentRuntimeOverride).toBe("codex");
expect(entry.contextTokens).toBeUndefined();
expect(entry.contextTokensSource).toBeUndefined();
expect(entry.contextBudgetStatus).toBeUndefined();
});
it("stamps a source-less live profile as a user pin", () => {
const entry = makeSessionEntry({
compactionCount: 4,
+56 -4
View File
@@ -1,5 +1,6 @@
/** Mutates and persists isolated cron session state around one run. */
import { isDeepStrictEqual } from "node:util";
import { normalizeOptionalAgentRuntimeId } from "../../agents/agent-runtime-id.js";
import { clearBootstrapSnapshotOnSessionBoundary } from "../../agents/bootstrap-cache.js";
import type { LiveSessionModelSelection } from "../../agents/live-model-switch.js";
import type { SessionEntry } from "../../config/sessions.js";
@@ -18,10 +19,17 @@ import type {
CronScheduledToolCallerOrigin,
CronScheduledToolPolicy,
} from "../scheduled-tool-policy.js";
import { setSessionRuntimeModel } from "./run.runtime.js";
import type { resolveCronSession } from "./session.js";
type MutableSessionStore = Record<string, SessionEntry>;
function clearCronContextOwnerState(entry: SessionEntry) {
delete entry.contextTokens;
delete entry.contextTokensSource;
delete entry.contextBudgetStatus;
}
/** Mutable cron session entry updated by an isolated run before persistence. */
type MutableCronSessionEntry = SessionEntry;
/** Resolved cron session plus its mutable backing store and active entry. */
@@ -380,14 +388,50 @@ export async function persistCronSkillsSnapshotIfChanged(params: {
await params.persistSessionEntry();
}
/**
* Updates the cron selection and drops facts produced by the previous model.
* Keeping those facts after the owner tuple changes lets a later run relabel stale telemetry.
*/
export function setCronSessionRuntimeModel(params: {
entry: MutableCronSessionEntry;
provider: string;
model: string;
}) {
const provider = params.provider.trim();
const model = params.model.trim();
if (!provider || !model) {
return false;
}
const selectionChanged =
params.entry.modelProvider?.trim() !== provider || params.entry.model?.trim() !== model;
if (selectionChanged) {
clearCronContextOwnerState(params.entry);
}
setSessionRuntimeModel(params.entry, { provider, model });
return selectionChanged;
}
/** Updates the producing harness and drops context facts owned by the previous runtime. */
export function setCronSessionAgentHarnessId(params: {
entry: MutableCronSessionEntry;
agentHarnessId: string | undefined;
}) {
const previousRuntime = normalizeOptionalAgentRuntimeId(params.entry.agentHarnessId);
const nextRuntime = normalizeOptionalAgentRuntimeId(params.agentHarnessId);
if (previousRuntime !== nextRuntime) {
clearCronContextOwnerState(params.entry);
}
params.entry.agentHarnessId = params.agentHarnessId;
return previousRuntime !== nextRuntime;
}
/** Records the selected provider/model before a cron run starts. */
export function markCronSessionPreRun(params: {
entry: MutableCronSessionEntry;
provider: string;
model: string;
}) {
params.entry.modelProvider = params.provider;
params.entry.model = params.model;
setCronSessionRuntimeModel(params);
params.entry.systemSent = true;
}
@@ -396,8 +440,16 @@ export function syncCronSessionLiveSelection(params: {
entry: MutableCronSessionEntry;
liveSelection: CronLiveSelection;
}) {
params.entry.modelProvider = params.liveSelection.provider;
params.entry.model = params.liveSelection.model;
const previousRuntime = normalizeOptionalAgentRuntimeId(params.entry.agentRuntimeOverride);
const nextRuntime = normalizeOptionalAgentRuntimeId(params.liveSelection.agentRuntimeOverride);
setCronSessionRuntimeModel({
entry: params.entry,
provider: params.liveSelection.provider,
model: params.liveSelection.model,
});
if (previousRuntime !== nextRuntime) {
clearCronContextOwnerState(params.entry);
}
if (params.liveSelection.agentRuntimeOverride) {
params.entry.agentRuntimeOverride = params.liveSelection.agentRuntimeOverride;
} else {
@@ -329,6 +329,11 @@ describe("runCronIsolatedAgentTurn — LiveSessionModelSwitchError retry (#57206
model: "gpt-5.6-luna",
modelProvider: "openai",
agentRuntimeOverride: "openclaw",
contextTokens: 272_000,
contextTokensSource: "runtime",
contextBudgetStatus: {} as NonNullable<
ReturnType<typeof makeCronSessionEntry>["contextBudgetStatus"]
>,
}),
isNewSession: false,
});
@@ -374,6 +379,9 @@ describe("runCronIsolatedAgentTurn — LiveSessionModelSwitchError retry (#57206
expect(requireEmbeddedAgentCall(0).agentHarnessRuntimeOverride).toBe("openclaw");
expect(requireEmbeddedAgentCall(1).agentHarnessRuntimeOverride).toBe("codex");
expect(cronSession.sessionEntry.agentRuntimeOverride).toBe("codex");
expect(cronSession.sessionEntry.contextTokens).toBe(128_000);
expect(cronSession.sessionEntry.contextTokensSource).toBe("resolved");
expect(cronSession.sessionEntry.contextBudgetStatus).toBeUndefined();
});
it("returns error (not infinite loop) when LiveSessionModelSwitchError is thrown repeatedly", async () => {
@@ -402,25 +402,270 @@ describe("runCronIsolatedAgentTurn — skill filter", () => {
});
describe("context token fallback", () => {
it("prefers the harness-reported runtime window and provenance", async () => {
const session = makeCronSession({
sessionEntry: makeCronSessionEntry({
agentHarnessId: "openclaw",
contextTokens: 222_000,
contextTokensSource: "resolved",
}),
});
resolveCronSessionMock.mockReturnValue(session);
resolveContextTokensForModelMock.mockReturnValue(512_000);
runWithModelFallbackMock.mockResolvedValueOnce({
result: {
payloads: [{ text: "test output" }],
meta: {
agentMeta: {
provider: "openai",
model: "gpt-5.4",
agentHarnessId: "codex",
contextTokens: 1_000_000,
contextTokensSource: "runtime",
},
},
},
provider: "openai",
model: "gpt-5.4",
});
const result = await runSkillFilterCase();
expect(result.status).toBe("ok");
expect(session.sessionEntry.agentHarnessId).toBe("codex");
expect(session.sessionEntry.contextTokens).toBe(1_000_000);
expect(session.sessionEntry.contextTokensSource).toBe("runtime");
});
it("preserves existing session contextTokens when no configured or cached model window is loaded", async () => {
const session = makeCronSession({
sessionEntry: makeCronSessionEntry({
modelProvider: "openai",
model: "gpt-5.4",
agentHarnessId: "codex",
contextTokens: 222_000,
contextTokensSource: "runtime",
}),
});
resolveCronSessionMock.mockReturnValue(session);
resolveContextTokensForModelMock.mockReturnValue(undefined);
runWithModelFallbackMock.mockResolvedValueOnce({
result: {
payloads: [{ text: "test output" }],
meta: {
agentMeta: {
provider: "openai",
model: "gpt-5.4",
agentHarnessId: "codex",
},
},
},
provider: "openai",
model: "gpt-5.4",
});
const result = await runSkillFilterCase();
expect(result.status).toBe("ok");
expect(session.sessionEntry.contextTokens).toBe(222_000);
expect(session.sessionEntry.contextTokensSource).toBe("runtime");
});
it("preserves a matching lower runtime window when current model capacity is higher", async () => {
const session = makeCronSession({
sessionEntry: makeCronSessionEntry({
modelProvider: "openai",
model: "gpt-5.4",
agentHarnessId: "codex",
contextTokens: 222_000,
contextTokensSource: "runtime",
}),
});
resolveCronSessionMock.mockReturnValue(session);
resolveContextTokensForModelMock.mockReturnValue(512_000);
runWithModelFallbackMock.mockResolvedValueOnce({
result: {
payloads: [{ text: "test output" }],
meta: {
agentMeta: {
provider: "openai",
model: "gpt-5.4",
agentHarnessId: "codex",
},
},
},
provider: "openai",
model: "gpt-5.4",
});
const result = await runSkillFilterCase();
expect(result.status).toBe("ok");
expect(session.sessionEntry.contextTokens).toBe(222_000);
expect(session.sessionEntry.contextTokensSource).toBe("runtime");
});
it("preserves a locked session window when current model capacity differs", async () => {
const session = makeCronSession({
sessionEntry: makeCronSessionEntry({
modelProvider: "openai",
model: "gpt-5.4",
agentHarnessId: "codex",
contextTokens: 222_000,
modelSelectionLocked: true,
}),
});
resolveCronSessionMock.mockReturnValue(session);
resolveContextTokensForModelMock.mockReturnValue(512_000);
runWithModelFallbackMock.mockResolvedValueOnce({
result: {
payloads: [{ text: "test output" }],
meta: {
agentMeta: {
provider: "openai",
model: "gpt-5.4",
agentHarnessId: "codex",
},
},
},
provider: "openai",
model: "gpt-5.4",
});
const result = await runSkillFilterCase();
expect(result.status).toBe("ok");
expect(session.sessionEntry.contextTokens).toBe(222_000);
expect(session.sessionEntry.contextTokensSource).toBeUndefined();
});
it("prefers a current authored cap over matching older runtime telemetry", async () => {
const session = makeCronSession({
sessionEntry: makeCronSessionEntry({
modelProvider: "openai",
model: "gpt-5.4",
agentHarnessId: "codex",
contextTokens: 222_000,
contextTokensSource: "runtime",
}),
});
resolveCronSessionMock.mockReturnValue(session);
resolveContextTokensForModelMock.mockReturnValue(512_000);
runWithModelFallbackMock.mockResolvedValueOnce({
result: {
payloads: [{ text: "test output" }],
meta: {
agentMeta: {
provider: "openai",
model: "gpt-5.4",
agentHarnessId: "codex",
},
},
},
provider: "openai",
model: "gpt-5.4",
});
const result = await runSkillFilterCase({
cfg: {
models: {
providers: {
openai: {
models: [{ id: "gpt-5.4", contextTokens: 512_000 }],
},
},
},
},
});
expect(result.status).toBe("ok");
expect(session.sessionEntry.contextTokens).toBe(512_000);
expect(session.sessionEntry.contextTokensSource).toBe("resolved");
});
it("does not relabel a previous harness window when the current run reports none", async () => {
const session = makeCronSession({
sessionEntry: makeCronSessionEntry({
modelProvider: "openai",
model: "gpt-5.4",
agentHarnessId: "openclaw",
contextTokens: 222_000,
contextTokensSource: "runtime",
contextBudgetStatus: {} as NonNullable<
ReturnType<typeof makeCronSessionEntry>["contextBudgetStatus"]
>,
}),
});
resolveCronSessionMock.mockReturnValue(session);
resolveContextTokensForModelMock.mockReturnValue(undefined);
runWithModelFallbackMock.mockResolvedValueOnce({
result: {
payloads: [{ text: "test output" }],
meta: {
agentMeta: {
provider: "openai",
model: "gpt-5.4",
agentHarnessId: "codex",
},
},
},
provider: "openai",
model: "gpt-5.4",
});
const result = await runSkillFilterCase();
expect(result.status).toBe("ok");
expect(session.sessionEntry.agentHarnessId).toBe("codex");
expect(session.sessionEntry.contextTokens).toBe(128_000);
expect(session.sessionEntry.contextTokensSource).toBe("resolved");
expect(session.sessionEntry.contextBudgetStatus).toBeUndefined();
});
it("does not relabel a previous model window when the harness stays the same", async () => {
const session = makeCronSession({
sessionEntry: makeCronSessionEntry({
modelProvider: "openai",
model: "gpt-5.3",
agentHarnessId: "codex",
contextTokens: 222_000,
contextTokensSource: "runtime",
contextBudgetStatus: {} as NonNullable<
ReturnType<typeof makeCronSessionEntry>["contextBudgetStatus"]
>,
}),
});
resolveCronSessionMock.mockReturnValue(session);
resolveContextTokensForModelMock.mockReturnValue(undefined);
runWithModelFallbackMock.mockResolvedValueOnce({
result: {
payloads: [{ text: "test output" }],
meta: {
agentMeta: {
provider: "openai",
model: "gpt-5.4",
agentHarnessId: "codex",
},
},
},
provider: "openai",
model: "gpt-5.4",
});
const result = await runSkillFilterCase();
expect(result.status).toBe("ok");
expect(session.sessionEntry.model).toBe("gpt-5.4");
expect(session.sessionEntry.contextTokens).toBe(128_000);
expect(session.sessionEntry.contextTokensSource).toBe("resolved");
expect(session.sessionEntry.contextBudgetStatus).toBeUndefined();
});
it("prefers sync-configured model contextTokens over the previous session value", async () => {
const session = makeCronSession({
sessionEntry: makeCronSessionEntry({
contextTokens: 222_000,
contextTokensSource: "runtime",
}),
});
resolveCronSessionMock.mockReturnValue(session);
@@ -430,6 +675,7 @@ describe("runCronIsolatedAgentTurn — skill filter", () => {
expect(result.status).toBe("ok");
expect(session.sessionEntry.contextTokens).toBe(512_000);
expect(session.sessionEntry.contextTokensSource).toBe("resolved");
expect(resolveContextTokensForModelMock).toHaveBeenCalledWith({
cfg: expect.any(Object),
provider: "openai",
+15 -1
View File
@@ -839,7 +839,21 @@ export function resetRunCronIsolatedAgentTurnHarness(): void {
resetRunExecutionMocks();
resetRunOutcomeMocks();
resetRunSessionMocks();
setSessionRuntimeModelMock.mockReturnValue(undefined);
setSessionRuntimeModelMock.mockImplementation(
(
entry: { modelProvider?: string; model?: string },
runtime: { provider: string; model: string },
) => {
const provider = runtime.provider.trim();
const model = runtime.model.trim();
if (!provider || !model) {
return false;
}
entry.modelProvider = provider;
entry.model = model;
return true;
},
);
logWarnMock.mockReset();
hasUsableWebSearchProviderMock.mockReset();
hasUsableWebSearchProviderMock.mockImplementation(
+2
View File
@@ -423,6 +423,7 @@ describe("resolveCronSession", () => {
cacheRead: 4,
cacheWrite: 5,
contextTokens: 200_000,
contextTokensSource: "runtime",
compactionCount: 9,
memoryFlush: { kind: "succeeded", compactionCount: 9 },
abortCutoffMessageSid: "old-message",
@@ -508,6 +509,7 @@ describe("resolveCronSession", () => {
expect(result.sessionEntry.cacheRead).toBeUndefined();
expect(result.sessionEntry.cacheWrite).toBeUndefined();
expect(result.sessionEntry.contextTokens).toBeUndefined();
expect(result.sessionEntry.contextTokensSource).toBeUndefined();
expect(result.sessionEntry.compactionCount).toBeUndefined();
expect(result.sessionEntry.memoryFlush).toBeUndefined();
expect(result.sessionEntry.abortCutoffMessageSid).toBeUndefined();
@@ -1123,7 +1123,9 @@ describe("gateway server chat", () => {
modelOverride: "gpt-5",
modelProvider: "openai",
model: "gpt-5",
agentHarnessId: "openclaw",
contextTokens: 128_000,
contextTokensSource: "runtime",
});
await writeMainSessionTranscript([
createTextTranscriptEvent("user", "persisted metadata", { timestamp: updatedAt }),
@@ -909,7 +909,9 @@ test("sessions.changed mutation events include live usage metadata", async () =>
modelOverride: "gpt-5.3-codex-spark",
modelProvider: "openai",
model: "gpt-5.3-codex-spark",
agentHarnessId: "openclaw",
contextTokens: 123_456,
contextTokensSource: "runtime",
totalTokens: 0,
totalTokensFresh: false,
}),
@@ -1768,7 +1768,9 @@ describe("session.message websocket events", () => {
modelOverride: "gpt-5.4",
modelProvider: "openai",
model: "gpt-5.4",
agentHarnessId: "openclaw",
contextTokens: 123_456,
contextTokensSource: "runtime",
totalTokens: 0,
totalTokensFresh: false,
},
+6 -17
View File
@@ -4,7 +4,10 @@ import {
normalizeOptionalString,
} from "@openclaw/normalization-core/string-coerce";
import { readAcpSessionMeta } from "../acp/runtime/session-meta.js";
import { resolveModelAgentRuntimeMetadata } from "../agents/agent-runtime-metadata.js";
import {
resolveCurrentSessionAgentRuntimeMetadata,
resolveModelAgentRuntimeMetadata,
} from "../agents/agent-runtime-metadata.js";
import { resolveAgentConfig, resolveSessionAgentId } from "../agents/agent-scope.js";
import { resolveContextTokensForModel } from "../agents/context.js";
import { DEFAULT_CONTEXT_TOKENS, DEFAULT_MODEL, DEFAULT_PROVIDER } from "../agents/defaults.js";
@@ -25,7 +28,6 @@ import {
import { resolveThinkingDefaultCore } from "../agents/model-thinking-default-core.js";
import { publishedModelCatalogOwnerMatchesAgent } from "../agents/prepared-model-catalog-owner.js";
import { resolveSessionModelRef } from "../agents/session-model-ref.js";
import { resolveSessionRuntimeOverrideForProvider } from "../agents/session-runtime-compat.js";
import {
concretizeAgentRuntime,
resolveEffectiveAgentRuntime,
@@ -234,29 +236,16 @@ export function resolveGatewaySessionThinkingProjectionInternal(
(params.entry && cachedAcpMeta?.has(params.entry)
? cachedAcpMeta.get(params.entry)
: readAcpSessionMeta({ sessionKey: params.sessionKey, agentId: params.agentId }));
const configuredAgentRuntime = resolveModelAgentRuntimeMetadata({
const agentRuntime = resolveCurrentSessionAgentRuntimeMetadata({
cfg: params.cfg,
agentId: params.agentId,
provider: params.provider,
model: params.model,
sessionKey: params.sessionKey,
sessionEntry: params.entry,
acpRuntime: acpMeta != null,
acpBackend: acpMeta?.backend,
});
const persistedAgentRuntime = resolveSessionRuntimeOverrideForProvider({
provider: params.provider,
entry: params.entry,
cfg: params.cfg,
});
const persistedAgentRuntimeSource: "session" | "session-key" =
params.entry?.modelSelectionLocked === true ? "session" : "session-key";
const agentRuntime =
acpMeta || !persistedAgentRuntime
? configuredAgentRuntime
: {
id: persistedAgentRuntime,
source: persistedAgentRuntimeSource,
};
const catalogEntry = params.modelCatalog
? findModelCatalogEntry(params.modelCatalog, {
provider: params.provider,
+2
View File
@@ -521,7 +521,9 @@ it("keeps the serialized list response deterministic for the current filter path
opts: { archived: "all", includeGlobal: true, search: "needle" },
store: {
global: {
agentHarnessId: "codex",
contextTokens: 100,
contextTokensSource: "runtime",
createdActor: { type: "system", id: "creator-b" },
estimatedCostUsd: 0,
model: "gpt-5.4",
-10
View File
@@ -1,5 +1,4 @@
import { uniqueStrings } from "@openclaw/normalization-core/string-normalization";
import { resolveContextTokensForModel } from "../agents/context.js";
import { normalizeStoredOverrideModel } from "../agents/model-selection.js";
import { resolveSessionModelRef } from "../agents/session-model-ref.js";
import { buildSubagentSessionListReadIndex } from "../agents/subagents/registry/subagent-registry-read.js";
@@ -157,7 +156,6 @@ export function resolveTranscriptUsageFallback(params: {
estimatedCostUsd?: number;
totalTokens?: number;
totalTokensFresh?: boolean;
contextTokens?: number;
modelProvider?: string;
model?: string;
} | null {
@@ -192,13 +190,6 @@ export function resolveTranscriptUsageFallback(params: {
}
const modelProvider = snapshot.modelProvider ?? params.fallbackProvider;
const model = snapshot.model ?? params.fallbackModel;
const contextTokens = resolveContextTokensForModel({
cfg: params.cfg,
provider: modelProvider,
model,
// Gateway/session listing is read-only; don't start async model discovery.
allowAsyncLoad: false,
});
const estimatedCostUsd = resolveEstimatedSessionCostUsd({
cfg: params.cfg,
provider: modelProvider,
@@ -217,7 +208,6 @@ export function resolveTranscriptUsageFallback(params: {
model,
totalTokens: resolvePositiveNumber(snapshot.totalTokens),
totalTokensFresh: snapshot.totalTokensFresh === true,
contextTokens: resolvePositiveNumber(contextTokens),
estimatedCostUsd,
};
}
+26 -24
View File
@@ -5,6 +5,7 @@ import type {
SessionOwner,
} from "../../packages/gateway-protocol/src/index.js";
import { listAgentIds } from "../agents/agent-scope-config.js";
import { resolveAuthoredModelContextTokens } from "../agents/context-resolution.js";
import { resolveContextTokensForModel } from "../agents/context.js";
import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "../agents/defaults.js";
import { resolveFastModeState } from "../agents/fast-mode.js";
@@ -26,6 +27,7 @@ import {
buildGroupDisplayTitle,
resolveFreshSessionTotalTokens,
resolveSessionGoalDisplayState,
resolveProjectedSessionContextTokens,
SESSION_TOTAL_TOKENS_VERSION,
type InternalSessionEntry,
type SessionEntry,
@@ -328,7 +330,6 @@ export function buildGatewaySessionRow(params: {
);
const freshSessionTotalTokens = asNonNegativeFiniteNumber(resolveFreshSessionTotalTokens(entry));
const needsTranscriptTotalTokens = freshSessionTotalTokens === undefined;
const needsTranscriptContextTokens = resolvePositiveNumber(entry?.contextTokens) === undefined;
const needsTranscriptEstimatedCostUsd =
!skipTranscriptUsage &&
resolveEstimatedSessionCostUsd({
@@ -339,8 +340,7 @@ export function buildGatewaySessionRow(params: {
rowContext,
}) === undefined;
const transcriptUsage =
!skipTranscriptUsage &&
(needsTranscriptTotalTokens || needsTranscriptContextTokens || needsTranscriptEstimatedCostUsd)
!skipTranscriptUsage && (needsTranscriptTotalTokens || needsTranscriptEstimatedCostUsd)
? resolveTranscriptUsageFallback({
cfg,
key,
@@ -414,27 +414,6 @@ export function buildGatewaySessionRow(params: {
entry,
rowContext: params.rowContext,
}) ?? asNonNegativeFiniteNumber(transcriptUsage?.estimatedCostUsd));
const contextTokens = lightweight
? (resolvePositiveNumber(entry?.contextTokens) ??
resolvePositiveNumber(
resolveContextTokensForModel({
cfg,
provider: rowModelProvider,
model: rowModel,
allowAsyncLoad: false,
}),
))
: (resolvePositiveNumber(entry?.contextTokens) ??
resolvePositiveNumber(transcriptUsage?.contextTokens) ??
resolvePositiveNumber(
resolveContextTokensForModel({
cfg,
provider: rowModelProvider,
model: rowModel,
allowAsyncLoad: false,
}),
));
let derivedTitle: string | undefined;
let lastMessagePreview: string | undefined;
if (entry?.sessionId && (params.includeDerivedTitles || params.includeLastMessage)) {
@@ -470,6 +449,29 @@ export function buildGatewaySessionRow(params: {
rowContext,
providerPolicySource: lightweight ? "active" : undefined,
});
const resolvedCurrentContextTokens = resolvePositiveNumber(
resolveContextTokensForModel({
cfg,
provider: rowModelProvider,
model: rowModel,
allowAsyncLoad: false,
}),
);
const authoredContextTokens = resolvePositiveNumber(
resolveAuthoredModelContextTokens({
cfg,
provider: rowModelProvider,
model: rowModel,
}),
);
const contextTokens = resolveProjectedSessionContextTokens({
entry,
provider: rowModelProvider,
model: rowModel,
agentHarnessId: thinkingProjection.agentRuntime.id,
resolvedContextTokens: resolvedCurrentContextTokens,
authoredContextTokens,
});
const fastModeState = resolveFastModeState({
cfg,
provider: selectedModelProvider,
+401
View File
@@ -1117,6 +1117,407 @@ describe("gateway session utils", () => {
expect(row.agentRuntime).toEqual({ id: "codex", source: "session" });
});
test.each([true, false])(
"projects current context for a stale different-runtime producer (lightweight=%s)",
(lightweightListRow) => {
const cfg = {
agents: {
defaults: {
model: { primary: "openai/gpt-5.6-sol" },
models: { "openai/gpt-5.6-sol": { agentRuntime: { id: "codex" } } },
},
},
models: {
providers: {
openai: {
models: [{ id: "gpt-5.6-sol", contextTokens: 1_000_000 }],
},
},
},
} as unknown as OpenClawConfig;
const row = buildGatewaySessionRow({
cfg,
storePath: "",
store: {},
key: "agent:main:main",
entry: {
sessionId: "stale-openclaw",
modelProvider: "openai",
model: "gpt-5.6-sol",
agentHarnessId: "openclaw",
contextTokens: 272_000,
contextTokensSource: "runtime",
} as SessionEntry,
lightweightListRow,
});
expect(row.agentRuntime?.id).toBe("codex");
expect(row.contextTokens).toBe(1_000_000);
},
);
test.each([true, false])(
"projects current Codex context when producer provenance is missing (lightweight=%s)",
(lightweightListRow) => {
const cfg = {
agents: {
defaults: {
model: { primary: "openai/gpt-5.6-sol" },
models: { "openai/gpt-5.6-sol": { agentRuntime: { id: "codex" } } },
},
},
models: {
providers: {
openai: {
models: [{ id: "gpt-5.6-sol", contextTokens: 1_000_000 }],
},
},
},
} as unknown as OpenClawConfig;
const row = buildGatewaySessionRow({
cfg,
storePath: "",
store: {},
key: "agent:main:main",
entry: {
sessionId: "missing-provenance",
modelProvider: "openai",
model: "gpt-5.6-sol",
contextTokens: 272_000,
} as SessionEntry,
lightweightListRow,
});
expect(row.agentRuntime?.id).toBe("codex");
expect(row.contextTokens).toBe(1_000_000);
},
);
test.each([true, false])(
"projects a changed explicit cap for the same runtime and model (lightweight=%s)",
(lightweightListRow) => {
const cfg = {
agents: {
defaults: {
model: { primary: "openai/gpt-5.6-sol" },
models: { "openai/gpt-5.6-sol": { agentRuntime: { id: "codex" } } },
},
},
models: {
providers: {
openai: {
models: [{ id: "gpt-5.6-sol", contextTokens: 1_000_000 }],
},
},
},
} as unknown as OpenClawConfig;
const row = buildGatewaySessionRow({
cfg,
storePath: "",
store: {},
key: "agent:main:main",
entry: {
sessionId: "stale-cap",
modelProvider: "openai",
model: "gpt-5.6-sol",
agentHarnessId: "codex",
contextTokens: 272_000,
contextTokensSource: "runtime",
} as SessionEntry,
lightweightListRow,
});
expect(row.contextTokens).toBe(1_000_000);
},
);
test.each([true, false])(
"projects an authored contextWindow cap below matching runtime telemetry (lightweight=%s)",
(lightweightListRow) => {
const cfg = {
agents: {
defaults: {
model: { primary: "openai/gpt-5.6-sol" },
models: { "openai/gpt-5.6-sol": { agentRuntime: { id: "codex" } } },
},
},
models: {
providers: {
openai: {
models: [{ id: "gpt-5.6-sol", contextWindow: 128_000 }],
},
},
},
} as unknown as OpenClawConfig;
const row = buildGatewaySessionRow({
cfg,
storePath: "",
store: {},
key: "agent:main:main",
entry: {
sessionId: "authored-window-cap",
modelProvider: "openai",
model: "gpt-5.6-sol",
agentHarnessId: "codex",
contextTokens: 272_000,
contextTokensSource: "runtime",
} as SessionEntry,
lightweightListRow,
});
expect(row.agentRuntime?.id).toBe("codex");
expect(row.contextTokens).toBe(128_000);
},
);
test.each([true, false])(
"clamps an authored effective cap to a smaller authored contextWindow (lightweight=%s)",
(lightweightListRow) => {
const cfg = {
agents: {
defaults: {
model: { primary: "openai/gpt-5.6-sol" },
models: { "openai/gpt-5.6-sol": { agentRuntime: { id: "openclaw" } } },
},
},
models: {
providers: {
openai: {
models: [
{
id: "gpt-5.6-sol",
contextTokens: 1_000_000,
contextWindow: 128_000,
},
],
},
},
},
} as unknown as OpenClawConfig;
const row = buildGatewaySessionRow({
cfg,
storePath: "",
store: {},
key: "agent:main:main",
entry: {
sessionId: "authored-effective-above-native",
modelProvider: "openai",
model: "gpt-5.6-sol",
contextTokens: 272_000,
} as SessionEntry,
lightweightListRow,
});
expect(row.contextTokens).toBe(128_000);
},
);
test.each([true, false])(
"keeps matching runtime telemetry below a higher authored contextWindow (lightweight=%s)",
(lightweightListRow) => {
const cfg = {
agents: {
defaults: {
model: { primary: "openai/gpt-5.6-sol" },
models: { "openai/gpt-5.6-sol": { agentRuntime: { id: "codex" } } },
},
},
models: {
providers: {
openai: {
models: [{ id: "gpt-5.6-sol", contextWindow: 1_000_000 }],
},
},
},
} as unknown as OpenClawConfig;
const row = buildGatewaySessionRow({
cfg,
storePath: "",
store: {},
key: "agent:main:main",
entry: {
sessionId: "runtime-window-below-native-cap",
modelProvider: "openai",
model: "gpt-5.6-sol",
agentHarnessId: "codex",
contextTokens: 272_000,
contextTokensSource: "runtime",
} as SessionEntry,
lightweightListRow,
});
expect(row.agentRuntime?.id).toBe("codex");
expect(row.contextTokens).toBe(272_000);
},
);
test.each([
{
name: "a locked Codex session under OpenClaw config",
configuredRuntime: "openclaw",
expectedRuntime: "codex",
entry: {
agentHarnessId: "codex",
contextTokens: 1_000_000,
modelSelectionLocked: true,
},
},
{
name: "locked legacy telemetry without harness provenance",
configuredRuntime: "openclaw",
expectedRuntime: "openclaw",
entry: {
contextTokens: 1_000_000,
modelSelectionLocked: true,
},
},
])("preserves $name", ({ configuredRuntime, entry, expectedRuntime }) => {
const cfg = {
agents: {
defaults: {
model: { primary: "openai/gpt-5.6-sol" },
models: {
"openai/gpt-5.6-sol": { agentRuntime: { id: configuredRuntime } },
},
},
},
models: {
providers: {
openai: {
models: [{ id: "gpt-5.6-sol", contextWindow: 272_000 }],
},
},
},
} as unknown as OpenClawConfig;
const row = buildGatewaySessionRow({
cfg,
storePath: "",
store: {},
key: "agent:main:main",
entry: {
sessionId: "native-window",
modelProvider: "openai",
model: "gpt-5.6-sol",
...entry,
} as SessionEntry,
});
expect(row.agentRuntime?.id).toBe(expectedRuntime);
expect(row.contextTokens).toBe(1_000_000);
});
test.each([true, false])(
"does not reuse stale transcript context after an OpenClaw to Codex change (lightweight=%s)",
async (lightweightListRow) => {
await withStateDirEnv("session-utils-stale-transcript-context-", async ({ stateDir }) => {
const sessionId = "stale-transcript-context";
const sessionKey = "agent:main:main";
const storePath = path.join(stateDir, "agents", "main", "sessions", "sessions.json");
const entry = {
sessionId,
updatedAt: 1,
modelProvider: "openai",
model: "gpt-5.5",
agentHarnessId: "openclaw",
} as SessionEntry;
await seedSessionEntries(storePath, { [sessionKey]: entry });
appendTranscriptMessages({
sessionId,
sessionKey,
storePath,
messages: [
{
role: "assistant",
content: "old OpenClaw turn",
provider: "openai",
model: "gpt-5.5",
usage: { input: 1, output: 1 },
},
],
});
const cfg = {
agents: {
defaults: {
model: { primary: "openai/gpt-5.6-sol" },
models: { "openai/gpt-5.6-sol": { agentRuntime: { id: "codex" } } },
},
},
models: {
providers: {
openai: {
models: [
{ id: "gpt-5.5", contextWindow: 272_000 },
{ id: "gpt-5.6-sol", contextWindow: 1_000_000 },
],
},
},
},
} as unknown as OpenClawConfig;
const row = buildGatewaySessionRow({
cfg,
storePath,
store: { [sessionKey]: entry },
key: sessionKey,
entry,
lightweightListRow,
});
expect(row.agentRuntime?.id).toBe("codex");
expect(row.model).toBe("gpt-5.6-sol");
expect(row.contextTokens).toBe(1_000_000);
});
},
);
test.each(["resolved", "runtime-configured"] as const)(
"invalidates a persisted %s cap after the cap is removed",
(contextTokensSource) => {
const cfg = {
agents: {
defaults: {
model: { primary: "openai/gpt-5.6-sol" },
models: { "openai/gpt-5.6-sol": { agentRuntime: { id: "codex" } } },
},
},
models: {
providers: {
openai: {
models: [{ id: "gpt-5.6-sol", contextWindow: 1_000_000 }],
},
},
},
} as unknown as OpenClawConfig;
for (const lightweightListRow of [true, false]) {
const row = buildGatewaySessionRow({
cfg,
storePath: "",
store: {},
key: "agent:main:main",
entry: {
sessionId: "removed-cap",
modelProvider: "openai",
model: "gpt-5.6-sol",
agentHarnessId: "codex",
contextTokens: 272_000,
contextTokensSource,
} as SessionEntry,
lightweightListRow,
});
expect(row.contextTokens).toBe(1_000_000);
}
},
);
test.each(["xhigh", "max"] as const)(
"preserves catalog-less persisted %s in session change projections",
(thinkingLevel) => {
@@ -319,6 +319,11 @@ describe("plugin session extension SessionEntry projection", () => {
description: "reserved custom icon",
sessionEntrySlotKey: "icon",
});
api.registerSessionExtension({
namespace: "context-window-source",
description: "reserved context window provenance",
sessionEntrySlotKey: "contextTokensSource",
});
api.registerSessionExtension({
namespace: "pending-final-text",
description: "retired pending-final field",
@@ -355,6 +360,10 @@ describe("plugin session extension SessionEntry projection", () => {
pluginId: "slot-collision",
message: "sessionEntrySlotKey is reserved by SessionEntry: icon",
},
{
pluginId: "slot-collision",
message: "sessionEntrySlotKey is reserved by SessionEntry: contextTokensSource",
},
{
pluginId: "slot-collision",
message: "sessionEntrySlotKey is reserved by SessionEntry: pendingFinalDeliveryText",
+1
View File
@@ -149,6 +149,7 @@ const SESSION_ENTRY_RESERVED_SLOT_KEY_LIST = [
"agentHarnessId",
"fallbackNotice",
"contextTokens",
"contextTokensSource",
"contextBudgetStatus",
"compactionCount",
"compactionCheckpoints",
+4
View File
@@ -96,6 +96,7 @@ describe("applyModelOverrideToSessionEntry", () => {
providerOverride: "anthropic",
modelOverride: "claude-sonnet-4-6",
contextTokens: 160_000,
contextTokensSource: "runtime",
contextBudgetStatus: contextBudgetStatus({
updatedAt: before,
provider: "anthropic",
@@ -115,6 +116,7 @@ describe("applyModelOverrideToSessionEntry", () => {
expect(result.updated).toBe(true);
expectRuntimeModelFieldsCleared(entry, before);
expect(entry.contextTokens).toBeUndefined();
expect(entry.contextTokensSource).toBeUndefined();
expect(entry.contextBudgetStatus).toBeUndefined();
expect(entry.fallbackNotice).toBeUndefined();
expect(entry.modelOverrideSource).toBe("user");
@@ -157,6 +159,7 @@ describe("applyModelOverrideToSessionEntry", () => {
providerOverride: "openai",
modelOverride: "gpt-5.4",
contextTokens: 200_000,
contextTokensSource: "runtime",
contextBudgetStatus: contextBudgetStatus({
updatedAt: before,
provider: "openai",
@@ -178,6 +181,7 @@ describe("applyModelOverrideToSessionEntry", () => {
expect(entry.model).toBe("gpt-5.4");
expect(entry.modelOverrideSource).toBe("user");
expect(entry.contextTokens).toBe(200_000);
expect(entry.contextTokensSource).toBe("runtime");
expect(entry.contextBudgetStatus?.contextTokenBudget).toBe(200_000);
expect((entry.updatedAt ?? 0) >= before).toBe(true);
});
+14 -13
View File
@@ -136,19 +136,20 @@ export function applyModelOverrideToSessionEntry(params: {
// contextTokens are derived from the active session model. When the selected
// model changes (or runtime model is already stale), the cached window can
// pin the session to an older/smaller limit until another run refreshes it.
if (
entry.contextTokens !== undefined &&
(selectionUpdated || (runtimePresent && !runtimeAligned))
) {
delete entry.contextTokens;
updated = true;
}
if (
entry.contextBudgetStatus !== undefined &&
(selectionUpdated || (runtimePresent && !runtimeAligned))
) {
delete entry.contextBudgetStatus;
updated = true;
const shouldClearModelDerivedState = selectionUpdated || (runtimePresent && !runtimeAligned);
if (shouldClearModelDerivedState) {
if (entry.contextTokens !== undefined) {
delete entry.contextTokens;
updated = true;
}
if (entry.contextTokensSource !== undefined) {
delete entry.contextTokensSource;
updated = true;
}
if (entry.contextBudgetStatus !== undefined) {
delete entry.contextBudgetStatus;
updated = true;
}
}
if (profileOverride) {
+146
View File
@@ -1,6 +1,7 @@
// Status message tests cover status message formatting and persistence.
import { afterEach, describe, expect, it } from "vitest";
import { testing as cliBackendsTesting } from "../agents/cli-backends.test-support.js";
import { SESSION_TOTAL_TOKENS_VERSION } from "../config/sessions/types.js";
import type { ModelDefinitionConfig } from "../config/types.models.js";
import { buildStatusMessage, buildStatusMessageParts } from "./status-message.js";
@@ -139,6 +140,146 @@ describe("buildStatusMessageParts presentation", () => {
});
describe("buildStatusMessage context window", () => {
it("rejects a stale runtime window after a same-model harness change", () => {
const text = buildStatusMessage({
config: {
agents: {
defaults: {
model: "openai/gpt-5.6-sol",
},
},
models: {
providers: {
openai: {
baseUrl: "https://api.openai.com/v1",
models: [statusTestModel("gpt-5.6-sol", "GPT-5.6 Sol", 1_050_000)],
},
},
},
},
agent: { model: "openai/gpt-5.6-sol" },
runtimeContextTokens: 1_000_000,
resolvedHarness: "codex",
sessionEntry: {
sessionId: "same-model-runtime-change",
updatedAt: 0,
modelProvider: "openai",
model: "gpt-5.6-sol",
agentHarnessId: "openclaw",
contextTokens: 272_000,
contextTokensSource: "runtime",
totalTokens: 11,
totalTokensFresh: true,
totalTokensVersion: SESSION_TOTAL_TOKENS_VERSION,
},
sessionKey: "agent:main:main",
sessionScope: "per-sender",
queue: { mode: "steer", depth: 0 },
modelAuth: "oauth",
});
expect(text).toContain("Context: 11/1.0m");
expect(text).not.toContain("Context: 11/272k");
});
it("replaces matching runtime telemetry with a newly authored effective cap", () => {
const text = buildStatusMessage({
config: {
agents: {
defaults: {
model: "openai/gpt-5.6-sol",
},
},
models: {
providers: {
openai: {
baseUrl: "https://api.openai.com/v1",
models: [
{
...statusTestModel("gpt-5.6-sol", "GPT-5.6 Sol", 1_050_000),
contextTokens: 1_000_000,
},
],
},
},
},
},
agent: { model: "openai/gpt-5.6-sol" },
runtimeContextTokens: 1_000_000,
resolvedHarness: "codex",
sessionEntry: {
sessionId: "authored-context-cap",
updatedAt: 0,
modelProvider: "openai",
model: "gpt-5.6-sol",
agentHarnessId: "codex",
contextTokens: 272_000,
contextTokensSource: "runtime",
totalTokens: 11,
totalTokensFresh: true,
totalTokensVersion: SESSION_TOTAL_TOKENS_VERSION,
},
sessionKey: "agent:main:main",
sessionScope: "per-sender",
queue: { mode: "steer", depth: 0 },
modelAuth: "oauth",
});
expect(text).toContain("Context: 11/1.0m");
expect(text).not.toContain("Context: 11/272k");
});
it("preserves a locked legacy session window", () => {
const text = buildStatusMessage({
agent: { model: "openai/gpt-5.6-sol" },
runtimeContextTokens: 272_000,
resolvedHarness: "codex",
sessionEntry: {
sessionId: "locked-legacy-window",
updatedAt: 0,
modelSelectionLocked: true,
contextTokens: 1_000_000,
totalTokens: 11,
totalTokensFresh: true,
totalTokensVersion: SESSION_TOTAL_TOKENS_VERSION,
},
sessionKey: "agent:main:main",
sessionScope: "per-sender",
queue: { mode: "steer", depth: 0 },
modelAuth: "oauth",
});
expect(text).toContain("Context: 11/1.0m");
expect(text).not.toContain("Context: 11/272k");
});
it("caps matching unlocked runtime telemetry to the lower current window", () => {
const text = buildStatusMessage({
agent: { model: "openai/gpt-5.6-sol" },
runtimeContextTokens: 272_000,
resolvedHarness: "codex",
sessionEntry: {
sessionId: "unlocked-runtime-window",
updatedAt: 0,
modelProvider: "openai",
model: "gpt-5.6-sol",
agentHarnessId: "codex",
contextTokens: 1_000_000,
contextTokensSource: "runtime",
totalTokens: 11,
totalTokensFresh: true,
totalTokensVersion: SESSION_TOTAL_TOKENS_VERSION,
},
sessionKey: "agent:main:main",
sessionScope: "per-sender",
queue: { mode: "steer", depth: 0 },
modelAuth: "oauth",
});
expect(text).toContain("Context: 11/272k");
expect(text).not.toContain("Context: 11/1.0m");
});
it("ignores stale runtime context after a manual session model switch", () => {
const text = buildStatusMessage({
config: {
@@ -297,6 +438,9 @@ describe("buildStatusMessage context window", () => {
modelOverrideFallbackOriginModel: "deepseek-v4-pro",
modelProvider: "ollama-cloud",
model: "deepseek-v4-pro",
agentHarnessId: "openclaw",
contextTokens: 128_000,
contextTokensSource: "runtime",
totalTokens: 50_000,
totalTokensFresh: true,
totalTokensVersion: 1,
@@ -305,12 +449,14 @@ describe("buildStatusMessage context window", () => {
sessionScope: "per-sender",
queue: { mode: "steer", depth: 0 },
modelAuth: "api-key",
resolvedHarness: "openclaw",
});
expect(text).toContain("Model: ollama-cloud/qwen3.6-blue");
expect(text).toContain("auto fallback; config primary ollama-cloud/deepseek-v4-pro");
expect(text).toContain("check provider");
expect(text).not.toContain("pinned session");
expect(text).toContain("Context: 50k/128k");
});
it("does not label a configured subagent model as auto fallback", () => {
+24 -88
View File
@@ -5,6 +5,7 @@ import {
normalizeOptionalLowercaseString,
normalizeOptionalString,
} from "@openclaw/normalization-core/string-coerce";
import { resolveAuthoredModelContextTokens } from "../agents/context-resolution.js";
import { resolveContextTokensForModel } from "../agents/context.js";
import { resolveCronStyleNow } from "../agents/current-time.js";
import { DEFAULT_CONTEXT_TOKENS, DEFAULT_MODEL, DEFAULT_PROVIDER } from "../agents/defaults.js";
@@ -36,6 +37,7 @@ import { resolveChannelModelOverride } from "../channels/model-overrides.js";
import {
resolveMainSessionKey,
resolveFreshSessionTotalTokens,
resolveProjectedSessionContextTokens,
resolveSessionPluginStatusLines,
resolveSessionPluginTraceLines,
type SessionEntry,
@@ -763,100 +765,34 @@ export function buildStatusMessageParts(args: StatusArgs): StatusMessageParts {
selectedModel: selectedLookupModel,
parentSessionKey: args.parentSessionKey,
});
const persistedContextTokens =
typeof entry?.contextTokens === "number" && entry.contextTokens > 0
? entry.contextTokens
: undefined;
const persistedContextMatchesActiveModel = (() => {
if (persistedContextTokens === undefined) {
return false;
}
const entryProvider = normalizeLowercaseStringOrEmpty(entry?.modelProvider);
const entryModel = normalizeLowercaseStringOrEmpty(entry?.model);
const lookupProvider = normalizeLowercaseStringOrEmpty(contextLookupProvider);
const lookupModel = normalizeLowercaseStringOrEmpty(contextLookupModel);
if (!entryModel || !lookupModel || entryModel !== lookupModel) {
return false;
}
if (entryProvider && lookupProvider && entryProvider !== lookupProvider) {
return false;
}
return !runtimeDiffersFromSelected || initialFallbackState.active;
})();
const cappedPersistedContextTokens =
typeof persistedContextTokens === "number" && typeof activeContextTokens === "number"
? Math.min(persistedContextTokens, activeContextTokens)
: persistedContextMatchesActiveModel
? persistedContextTokens
: undefined;
const channelOverrideContextTokens = channelModelNote
? (explicitRuntimeContextTokens ?? cappedPersistedContextTokens ?? activeContextTokens)
: undefined;
const projectedActiveContextTokens = resolveProjectedSessionContextTokens({
entry,
provider: contextLookupProvider,
model: contextLookupModel,
agentHarnessId: args.resolvedHarness,
resolvedContextTokens: activeContextTokens,
authoredContextTokens: resolveAuthoredModelContextTokens({
cfg: contextConfig,
provider: contextLookupProvider,
model: contextLookupModel,
}),
});
const runtimeSnapshotHasFallbackProvenance =
initialFallbackState.active ||
hasSessionAutoModelFallbackProvenance(entry) ||
areRuntimeModelRefsEquivalent(activeModelLabel, modelRefs.selected.label || "unknown", {
config: args.config,
});
// When a fallback model is active, the selected-model context limit that
// callers keep on the agent config is often stale. Prefer an explicit runtime
// snapshot only when it belongs to a real fallback/equivalent runtime. A
// transcript-derived previous model is stale after a manual switch and must
// not pin the newly selected model to the old context window. Separately,
// Persisted runtime snapshots still take precedence over model metadata so
// historical fallback sessions keep their last known live limit even if the
// active model later becomes unresolvable.
const contextTokens = runtimeDiffersFromSelected
? (() => {
if (!runtimeSnapshotHasFallbackProvenance) {
if (typeof selectedContextTokens === "number") {
return selectedContextTokens;
}
return DEFAULT_CONTEXT_TOKENS;
}
if (explicitRuntimeContextTokens !== undefined) {
return explicitRuntimeContextTokens;
}
if (cappedPersistedContextTokens !== undefined) {
const trustedPersistedContextTokens = cappedPersistedContextTokens;
const persistedLooksSelectedWindow =
typeof selectedContextTokens === "number" &&
trustedPersistedContextTokens === selectedContextTokens;
const activeWindowDiffersFromSelected =
typeof selectedContextTokens === "number" &&
typeof activeContextTokens === "number" &&
activeContextTokens !== selectedContextTokens;
if (persistedLooksSelectedWindow && activeWindowDiffersFromSelected) {
return activeContextTokens;
}
if (typeof activeContextTokens === "number") {
return Math.min(trustedPersistedContextTokens, activeContextTokens);
}
return trustedPersistedContextTokens;
}
if (typeof activeContextTokens === "number") {
return activeContextTokens;
}
return DEFAULT_CONTEXT_TOKENS;
})()
: (() => {
const resolvedContextTokens = resolveContextTokensForModel({
cfg: contextConfig,
...(contextLookupProvider ? { provider: contextLookupProvider } : {}),
model: contextLookupModel,
allowAsyncLoad: false,
});
const runtimeLimit =
channelOverrideContextTokens ??
cappedPersistedContextTokens ??
explicitRuntimeContextTokens;
if (runtimeLimit === undefined) {
return resolvedContextTokens ?? DEFAULT_CONTEXT_TOKENS;
}
return resolvedContextTokens === undefined
? runtimeLimit
: Math.min(runtimeLimit, resolvedContextTokens);
})();
// A transcript-derived previous model must not pin a newly selected model to
// its old window. Once fallback provenance is established, the shared
// projector owns authored caps, runtime telemetry, and locked-session state.
const useSelectedContext =
entry?.modelSelectionLocked !== true &&
runtimeDiffersFromSelected &&
!runtimeSnapshotHasFallbackProvenance;
const contextTokens = useSelectedContext
? (selectedContextTokens ?? DEFAULT_CONTEXT_TOKENS)
: (projectedActiveContextTokens ?? DEFAULT_CONTEXT_TOKENS);
const thinkLevel =
args.resolvedThink ?? args.sessionEntry?.thinkingLevel ?? args.agent?.thinkingDefault ?? "off";
+20 -13
View File
@@ -7,10 +7,13 @@ import {
normalizeOptionalLowercaseString,
} from "@openclaw/normalization-core/string-coerce";
import { readAcpSessionMeta } from "../acp/runtime/session-meta.js";
import { resolveModelAgentRuntimeMetadata } from "../agents/agent-runtime-metadata.js";
import { resolveCurrentSessionAgentRuntimeMetadata } from "../agents/agent-runtime-metadata.js";
import { resolveAgentConfig } from "../agents/agent-scope-config.js";
import { resolveConfiguredProviderFallback } from "../agents/configured-provider-fallback.js";
import { resolveContextTokensForModelFromCache as resolveContextTokensForModel } from "../agents/context-resolution.js";
import {
resolveAuthoredModelContextTokens,
resolveContextTokensForModelFromCache as resolveContextTokensForModel,
} from "../agents/context-resolution.js";
import { waitForContextWindowCacheLoad } from "../agents/context.js";
import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "../agents/defaults.js";
import { parseModelRef, resolvePersistedSelectedModelRef } from "../agents/model-selection.js";
@@ -192,14 +195,14 @@ function resolveSessionModelRef(
);
}
function resolveSessionRuntimeLabel(params: {
function resolveSessionRuntime(params: {
cfg: OpenClawConfig;
entry?: SessionEntry;
provider: string;
model: string;
agentId?: string;
sessionKey: string;
}): string {
}): { id: string | undefined; label: string } {
const acpSessionKey = params.agentId
? resolveStoredSessionKeyForAgentStore({
cfg: params.cfg,
@@ -208,33 +211,37 @@ function resolveSessionRuntimeLabel(params: {
})
: params.sessionKey;
const acpMeta = readAcpSessionMeta({ sessionKey: acpSessionKey });
const runtime = resolveModelAgentRuntimeMetadata({
const runtime = resolveCurrentSessionAgentRuntimeMetadata({
cfg: params.cfg,
agentId: params.agentId ?? "",
sessionEntry: params.entry,
provider: params.provider,
model: params.model,
sessionKey: acpSessionKey,
sessionEntry: params.entry,
acpRuntime: acpMeta != null,
acpBackend: acpMeta?.backend,
});
const id = normalizeOptionalLowercaseString(runtime.id);
// OpenClaw/auto are generic labels; concrete harness ids give better operator signal.
const resolvedHarness = id && id !== "openclaw" && id !== "auto" ? id : undefined;
return resolveAgentRuntimeLabel({
config: params.cfg,
sessionEntry: params.entry,
resolvedHarness,
fallbackProvider: params.provider,
});
return {
id,
label: resolveAgentRuntimeLabel({
config: params.cfg,
sessionEntry: params.entry,
resolvedHarness,
fallbackProvider: params.provider,
}),
};
}
export const statusSummaryRuntime = {
waitForContextWindowCacheLoad,
resolveAuthoredModelContextTokens,
resolveContextTokensForModel,
classifySessionKey: classifySessionKind,
resolveSessionModelRef,
resolveSessionRuntimeLabel,
resolveSessionRuntime,
resolveConfiguredStatusModelRef,
resolveStatusModelLookupRef,
resolveStatusModelComparisonLabel,
+21 -47
View File
@@ -1,11 +1,11 @@
// Builds the status summary used by human and JSON status output.
// It aggregates sessions, tasks, heartbeat, channel summary, and model/runtime metadata.
import { normalizeLowercaseStringOrEmpty as normalizeStatusModelPart } from "@openclaw/normalization-core/string-coerce";
import { resolveAgentConfig } from "../agents/agent-scope.js";
import { DEFAULT_CONTEXT_TOKENS, DEFAULT_MODEL, DEFAULT_PROVIDER } from "../agents/defaults.js";
import { areRuntimeModelRefsEquivalent } from "../agents/model-runtime-aliases.js";
import { getRuntimeConfig } from "../config/config.js";
import { resolveProjectedSessionContextTokens } from "../config/sessions/context-token-provenance.js";
import { resolveSystemMainSessionKey } from "../config/sessions/main-session.js";
import {
hasSessionActiveAutoModelFallback,
@@ -161,34 +161,6 @@ function hasUserPinnedModelSelection(entry: SessionEntry | undefined): boolean {
return !hasSessionAutoModelFallbackProvenance(entry);
}
function resolveTrustedSessionContextTokens(params: {
entry: SessionEntry | undefined;
provider: string | undefined;
model: string | null;
}): number | undefined {
const contextTokens =
typeof params.entry?.contextTokens === "number" && params.entry.contextTokens > 0
? params.entry.contextTokens
: undefined;
if (contextTokens === undefined) {
return undefined;
}
if (hasSessionAutoModelFallbackProvenance(params.entry)) {
return contextTokens;
}
const entryProvider = normalizeStatusModelPart(params.entry?.modelProvider);
const entryModel = normalizeStatusModelPart(params.entry?.model);
const resolvedProvider = normalizeStatusModelPart(params.provider);
const resolvedModel = normalizeStatusModelPart(params.model);
if (!entryModel || !resolvedModel || entryModel !== resolvedModel) {
return undefined;
}
if (entryProvider && resolvedProvider && entryProvider !== resolvedProvider) {
return undefined;
}
return contextTokens;
}
type SessionCandidate = {
key: string;
entry: SessionEntry;
@@ -271,8 +243,9 @@ export async function getStatusSummary(
const {
classifySessionKey,
resolveConfiguredStatusModelRef,
resolveAuthoredModelContextTokens,
resolveContextTokensForModel,
resolveSessionRuntimeLabel,
resolveSessionRuntime,
resolveSessionModelRef,
resolveStatusModelComparisonLabel,
resolveStatusModelLookupRef,
@@ -480,17 +453,27 @@ export async function getStatusSummary(
fallbackContextTokens: configContextTokens ?? undefined,
allowAsyncLoad: false,
});
const trustedSessionContextTokens = resolveTrustedSessionContextTokens({
const runtime = resolveSessionRuntime({
cfg,
entry,
provider: lookupModel.provider,
model: lookupModelId,
model: lookupModelId ?? "",
agentId,
sessionKey: key,
});
const contextTokens =
trustedSessionContextTokens === undefined
? (resolvedContextTokens ?? null)
: resolvedContextTokens === undefined
? trustedSessionContextTokens
: Math.min(trustedSessionContextTokens, resolvedContextTokens);
resolveProjectedSessionContextTokens({
entry,
provider: lookupModel.provider,
model: lookupModelId,
agentHarnessId: runtime.id,
resolvedContextTokens,
authoredContextTokens: resolveAuthoredModelContextTokens({
cfg,
provider: lookupModel.provider,
model: lookupModelId,
}),
}) ?? null;
const total = resolveSessionTotalTokens(entry);
const freshTotal = resolveFreshSessionTotalTokens(entry);
const totalTokensFresh = freshTotal !== undefined;
@@ -502,15 +485,6 @@ export async function getStatusSummary(
contextTokens && contextTokens > 0 && freshTotal !== undefined
? Math.min(999, Math.round((freshTotal / contextTokens) * 100))
: null;
const runtime = resolveSessionRuntimeLabel({
cfg,
entry,
provider: lookupModel.provider,
model: lookupModelId ?? "",
agentId,
sessionKey: key,
});
return {
agentId,
key,
@@ -542,7 +516,7 @@ export async function getStatusSummary(
? "session override"
: "fallback selected"
: null,
runtime,
runtime: runtime.label,
contextTokens,
flags: buildFlags(entry),
} satisfies SessionStatus;