From 7c65bbcee31bd31fa5b46c84f3a3f54c2cc522fb Mon Sep 17 00:00:00 2001 From: "Jason (Json)" <263060202+fuller-stack-dev@users.noreply.github.com> Date: Tue, 18 Aug 2026 05:03:01 -0600 Subject: [PATCH] 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 --- .../src/app-server/event-projector-result.ts | 2 + .../codex/src/app-server/event-projector.ts | 14 +- .../app-server/event-projector.usage.test.ts | 18 + src/agents/agent-runtime-metadata.ts | 48 ++- src/agents/cli-runner.ts | 5 +- src/agents/command/session-store.test.ts | 10 + src/agents/command/session-store.ts | 8 +- src/agents/context-resolution.ts | 15 +- src/agents/context.lookup.test.ts | 27 ++ .../embedded-agent-runner/run/helpers.ts | 1 + .../run/settled-turn-finalization.test.ts | 36 ++ .../run/settled-turn-finalization.ts | 2 + .../run/terminal-preparation.test.ts | 31 +- .../run/terminal-preparation.ts | 11 +- src/agents/embedded-agent-runner/run/types.ts | 2 + src/agents/embedded-agent-runner/types.ts | 1 + src/agents/tools/sessions-list-tool.test.ts | 23 + src/agents/tools/sessions-list-tool.ts | 2 + .../agent-runner-result-accounting.test.ts | 29 +- .../reply/agent-runner-result-accounting.ts | 3 + .../reply/agent-runner-session-reset.test.ts | 3 + .../reply/agent-runner-session-reset.ts | 1 + src/auto-reply/reply/session-usage.ts | 10 + src/auto-reply/reply/session.test.ts | 113 +++++ src/auto-reply/reply/session.ts | 1 + src/auto-reply/status.test.ts | 19 +- .../shared/codex-route-session-repair.ts | 3 +- .../shared/codex-route-warnings.test.ts | 6 + .../sessions.acp-runtime-metadata.test.ts | 44 +- .../sessions.model-resolution.test.ts | 150 +++++++ src/commands/sessions.test.ts | 46 ++ src/commands/sessions.ts | 81 ++-- src/commands/status.summary.runtime.test.ts | 48 ++- src/commands/status.summary.test-support.ts | 179 ++++++++ src/commands/status.summary.test.ts | 64 ++- src/config/sessions.ts | 1 + .../sessions/context-token-provenance.test.ts | 140 ++++++ .../sessions/context-token-provenance.ts | 90 ++++ ...ession-accessor.sqlite-message-cut.test.ts | 2 + .../session-accessor.sqlite-message-cut.ts | 1 + .../sessions/session-snapshot-merge.test.ts | 4 + src/config/sessions/session-snapshot-merge.ts | 1 + src/config/sessions/types.ts | 2 + src/cron/isolated-agent/run-executor.ts | 25 +- src/cron/isolated-agent/run-finalize.ts | 72 +++- .../isolated-agent/run-session-state.test.ts | 63 +++ src/cron/isolated-agent/run-session-state.ts | 60 ++- .../run.live-session-model-switch.test.ts | 8 + .../isolated-agent/run.skill-filter.test.ts | 246 +++++++++++ src/cron/isolated-agent/run.test-harness.ts | 16 +- src/cron/isolated-agent/session.test.ts | 2 + .../server.chat.gateway-server-chat-b.test.ts | 2 + .../server.sessions.list-changed.test.ts | 2 + src/gateway/session-message-events.test.ts | 2 + src/gateway/session-utils-model.ts | 23 +- src/gateway/session-utils-owners.test.ts | 2 + src/gateway/session-utils-projection.ts | 10 - src/gateway/session-utils-row.ts | 50 +-- src/gateway/session-utils.test.ts | 401 ++++++++++++++++++ .../session-entry-projection.contract.test.ts | 9 + src/plugins/session-entry-slot-keys.ts | 1 + src/sessions/model-overrides.test.ts | 4 + src/sessions/model-overrides.ts | 27 +- src/status/status-message.test.ts | 146 +++++++ src/status/status-message.ts | 112 ++--- src/status/summary.runtime.ts | 33 +- src/status/summary.ts | 68 +-- 67 files changed, 2327 insertions(+), 354 deletions(-) create mode 100644 src/commands/status.summary.test-support.ts create mode 100644 src/config/sessions/context-token-provenance.test.ts create mode 100644 src/config/sessions/context-token-provenance.ts diff --git a/extensions/codex/src/app-server/event-projector-result.ts b/extensions/codex/src/app-server/event-projector-result.ts index a8e726e37238..e43ab3b073de 100644 --- a/extensions/codex/src/app-server/event-projector-result.ts +++ b/extensions/codex/src/app-server/event-projector-result.ts @@ -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 } diff --git a/extensions/codex/src/app-server/event-projector.ts b/extensions/codex/src/app-server/event-projector.ts index 5f96bb72cf8d..2174cd950123 100644 --- a/extensions/codex/src/app-server/event-projector.ts +++ b/extensions/codex/src/app-server/event-projector.ts @@ -84,6 +84,7 @@ export class CodexAppServerEventProjector { private aborted = false; private tokenUsage: ReturnType; 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, diff --git a/extensions/codex/src/app-server/event-projector.usage.test.ts b/extensions/codex/src/app-server/event-projector.usage.test.ts index 71c7688f4424..f80f0e583a96 100644 --- a/extensions/codex/src/app-server/event-projector.usage.test.ts +++ b/extensions/codex/src/app-server/event-projector.usage.test.ts @@ -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", }); }); diff --git a/src/agents/agent-runtime-metadata.ts b/src/agents/agent-runtime-metadata.ts index ec0fd13561db..e290f09e49c3 100644 --- a/src/agents/agent-runtime-metadata.ts +++ b/src/agents/agent-runtime-metadata.ts @@ -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[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", + }; +} diff --git a/src/agents/cli-runner.ts b/src/agents/cli-runner.ts index 48df75528097..20f32d0adbf6 100644 --- a/src/agents/cli-runner.ts +++ b/src/agents/cli-runner.ts @@ -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; diff --git a/src/agents/command/session-store.test.ts b/src/agents/command/session-store.test.ts index d61d9246fb0d..f2ca34a7c878 100644 --- a/src/agents/command/session-store.test.ts +++ b/src/agents/command/session-store.test.ts @@ -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", + ); }); }); diff --git a/src/agents/command/session-store.ts b/src/agents/command/session-store.ts index 05f312e35436..58af14bec120 100644 --- a/src/agents/command/session-store.ts +++ b/src/agents/command/session-store.ts @@ -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; diff --git a/src/agents/context-resolution.ts b/src/agents/context-resolution.ts index 1012b588010e..74a3385233e9 100644 --- a/src/agents/context-resolution.ts +++ b/src/agents/context-resolution.ts @@ -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 diff --git a/src/agents/context.lookup.test.ts b/src/agents/context.lookup.test.ts index c36758da1fb7..9ffaaa4e2271 100644 --- a/src/agents/context.lookup.test.ts +++ b/src/agents/context.lookup.test.ts @@ -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 }]); diff --git a/src/agents/embedded-agent-runner/run/helpers.ts b/src/agents/embedded-agent-runner/run/helpers.ts index 79c1d93f5818..2a84ae614f8e 100644 --- a/src/agents/embedded-agent-runner/run/helpers.ts +++ b/src/agents/embedded-agent-runner/run/helpers.ts @@ -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 } : {}), diff --git a/src/agents/embedded-agent-runner/run/settled-turn-finalization.test.ts b/src/agents/embedded-agent-runner/run/settled-turn-finalization.test.ts index dd23ded75441..84e870adc60c 100644 --- a/src/agents/embedded-agent-runner/run/settled-turn-finalization.test.ts +++ b/src/agents/embedded-agent-runner/run/settled-turn-finalization.test.ts @@ -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")); diff --git a/src/agents/embedded-agent-runner/run/settled-turn-finalization.ts b/src/agents/embedded-agent-runner/run/settled-turn-finalization.ts index 1de8695ba1e3..df4b86162152 100644 --- a/src/agents/embedded-agent-runner/run/settled-turn-finalization.ts +++ b/src/agents/embedded-agent-runner/run/settled-turn-finalization.ts @@ -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, diff --git a/src/agents/embedded-agent-runner/run/terminal-preparation.test.ts b/src/agents/embedded-agent-runner/run/terminal-preparation.test.ts index 6aec1aa063e7..cf5764f99386 100644 --- a/src/agents/embedded-agent-runner/run/terminal-preparation.test.ts +++ b/src/agents/embedded-agent-runner/run/terminal-preparation.test.ts @@ -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); diff --git a/src/agents/embedded-agent-runner/run/terminal-preparation.ts b/src/agents/embedded-agent-runner/run/terminal-preparation.ts index 40d365ff7fac..2e01c3734fd3 100644 --- a/src/agents/embedded-agent-runner/run/terminal-preparation.ts +++ b/src/agents/embedded-agent-runner/run/terminal-preparation.ts @@ -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, diff --git a/src/agents/embedded-agent-runner/run/types.ts b/src/agents/embedded-agent-runner/run/types.ts index 0536f1df1356..c262b479bc0d 100644 --- a/src/agents/embedded-agent-runner/run/types.ts +++ b/src/agents/embedded-agent-runner/run/types.ts @@ -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; diff --git a/src/agents/embedded-agent-runner/types.ts b/src/agents/embedded-agent-runner/types.ts index 7cefaf0be373..e658ee85b232 100644 --- a/src/agents/embedded-agent-runner/types.ts +++ b/src/agents/embedded-agent-runner/types.ts @@ -44,6 +44,7 @@ export type EmbeddedAgentMeta = { provider: string; model: string; contextTokens?: number; + contextTokensSource?: "runtime" | "runtime-configured" | "resolved"; agentHarnessId?: string; fallbackAttempts?: FallbackAttempt[]; cliSessionBinding?: CliSessionBinding; diff --git a/src/agents/tools/sessions-list-tool.test.ts b/src/agents/tools/sessions-list-tool.test.ts index 6661642fe85c..9523058fe765 100644 --- a/src/agents/tools/sessions-list-tool.test.ts +++ b/src/agents/tools/sessions-list-tool.test.ts @@ -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 }; diff --git a/src/agents/tools/sessions-list-tool.ts b/src/agents/tools/sessions-list-tool.ts index 6284c8990840..b75ad159a0dd 100644 --- a/src/agents/tools/sessions-list-tool.ts +++ b/src/agents/tools/sessions-list-tool.ts @@ -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; diff --git a/src/auto-reply/reply/agent-runner-result-accounting.test.ts b/src/auto-reply/reply/agent-runner-result-accounting.test.ts index ad83225e74bc..c4518a335d4b 100644 --- a/src/auto-reply/reply/agent-runner-result-accounting.test.ts +++ b/src/auto-reply/reply/agent-runner-result-accounting.test.ts @@ -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", diff --git a/src/auto-reply/reply/agent-runner-result-accounting.ts b/src/auto-reply/reply/agent-runner-result-accounting.ts index 879bf5f708cc..83cfbd4d87bd 100644 --- a/src/auto-reply/reply/agent-runner-result-accounting.ts +++ b/src/auto-reply/reply/agent-runner-result-accounting.ts @@ -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 diff --git a/src/auto-reply/reply/agent-runner-session-reset.test.ts b/src/auto-reply/reply/agent-runner-session-reset.test.ts index 8d529a30cf4d..0c2fd098b6bc 100644 --- a/src/auto-reply/reply/agent-runner-session-reset.test.ts +++ b/src/auto-reply/reply/agent-runner-session-reset.test.ts @@ -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); diff --git a/src/auto-reply/reply/agent-runner-session-reset.ts b/src/auto-reply/reply/agent-runner-session-reset.ts index da44eb40ac90..2c5bdd5d9206 100644 --- a/src/auto-reply/reply/agent-runner-session-reset.ts +++ b/src/auto-reply/reply/agent-runner-session-reset.ts @@ -96,6 +96,7 @@ export async function resetReplyRunSession(params: { cacheRead: undefined, cacheWrite: undefined, contextTokens: undefined, + contextTokensSource: undefined, contextBudgetStatus: undefined, systemPromptReport: undefined, fallbackNotice: undefined, diff --git a/src/auto-reply/reply/session-usage.ts b/src/auto-reply/reply/session-usage.ts index a950dde31fa8..2fdc1d355a68 100644 --- a/src/auto-reply/reply/session-usage.ts +++ b/src/auto-reply/reply/session-usage.ts @@ -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 diff --git a/src/auto-reply/reply/session.test.ts b/src/auto-reply/reply/session.test.ts index 66dc9e7bb492..d5643db0c875 100644 --- a/src/auto-reply/reply/session.test.ts +++ b/src/auto-reply/reply/session.test.ts @@ -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, { diff --git a/src/auto-reply/reply/session.ts b/src/auto-reply/reply/session.ts index 3acc1c8fc9b6..fe7acdbc8f86 100644 --- a/src/auto-reply/reply/session.ts +++ b/src/auto-reply/reply/session.ts @@ -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 diff --git a/src/auto-reply/status.test.ts b/src/auto-reply/status.test.ts index 3db1f4fcf4c7..540e57a9aee9 100644 --- a/src/auto-reply/status.test.ts +++ b/src/auto-reply/status.test.ts @@ -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"); diff --git a/src/commands/doctor/shared/codex-route-session-repair.ts b/src/commands/doctor/shared/codex-route-session-repair.ts index 53aacf70cdf6..3f2823433855 100644 --- a/src/commands/doctor/shared/codex-route-session-repair.ts +++ b/src/commands/doctor/shared/codex-route-session-repair.ts @@ -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; diff --git a/src/commands/doctor/shared/codex-route-warnings.test.ts b/src/commands/doctor/shared/codex-route-warnings.test.ts index 1eb0ae445aea..2cc3c98679c9 100644 --- a/src/commands/doctor/shared/codex-route-warnings.test.ts +++ b/src/commands/doctor/shared/codex-route-warnings.test.ts @@ -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", () => { diff --git a/src/commands/sessions.acp-runtime-metadata.test.ts b/src/commands/sessions.acp-runtime-metadata.test.ts index c1cda3b34121..3c0162271f05 100644 --- a/src/commands/sessions.acp-runtime-metadata.test.ts +++ b/src/commands/sessions.acp-runtime-metadata.test.ts @@ -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" }); + }); }); diff --git a/src/commands/sessions.model-resolution.test.ts b/src/commands/sessions.model-resolution.test.ts index 3a461dc9f0e6..1d3aa50a7e77 100644 --- a/src/commands/sessions.model-resolution.test.ts +++ b/src/commands/sessions.model-resolution.test.ts @@ -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(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(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(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(sessionsCommand, store); + expect(payload.sessions?.[0]?.agentRuntime).toEqual({ id: "codex", source: "session" }); + expect(payload.sessions?.[0]?.contextTokens).toBe(1_000_000); + }, + ); + }); }); diff --git a/src/commands/sessions.test.ts b/src/commands/sessions.test.ts index c4f6797b5d5d..ce85197158e6 100644 --- a/src/commands/sessions.test.ts +++ b/src/commands/sessions.test.ts @@ -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": { diff --git a/src/commands/sessions.ts b/src/commands/sessions.ts index 997bc33af848..238640d3d2d3 100644 --- a/src/commands/sessions.ts +++ b/src/commands/sessions.ts @@ -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; + agentRuntime: ReturnType; 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 { - 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; + agentRuntime: ReturnType; 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); diff --git a/src/commands/status.summary.runtime.test.ts b/src/commands/status.summary.runtime.test.ts index c0bb466a2ae0..4a628f3ee461 100644 --- a/src/commands/status.summary.runtime.test.ts +++ b/src/commands/status.summary.runtime.test.ts @@ -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[0], +function resolveSessionRuntime( + params: Parameters[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" }); }); }); diff --git a/src/commands/status.summary.test-support.ts b/src/commands/status.summary.test-support.ts new file mode 100644 index 000000000000..ba524fe5dfdc --- /dev/null +++ b/src/commands/status.summary.test-support.ts @@ -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>; + +export function registerStatusSummarySessionRowCases(params: { + getStatusSummary: () => ReturnType; + 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); + }); + }); +} diff --git a/src/commands/status.summary.test.ts b/src/commands/status.summary.test.ts index 10e07c675075..5b78dd0d470f 100644 --- a/src/commands/status.summary.test.ts +++ b/src/commands/status.summary.test.ts @@ -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", diff --git a/src/config/sessions.ts b/src/config/sessions.ts index 8f513a7e9d5b..2e9006f8c1ee 100644 --- a/src/config/sessions.ts +++ b/src/config/sessions.ts @@ -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"; diff --git a/src/config/sessions/context-token-provenance.test.ts b/src/config/sessions/context-token-provenance.test.ts new file mode 100644 index 000000000000..15223f1594d6 --- /dev/null +++ b/src/config/sessions/context-token-provenance.test.ts @@ -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); + }); +}); diff --git a/src/config/sessions/context-token-provenance.ts b/src/config/sessions/context-token-provenance.ts new file mode 100644 index 000000000000..ec465b3c4230 --- /dev/null +++ b/src/config/sessions/context-token-provenance.ts @@ -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; +} diff --git a/src/config/sessions/session-accessor.sqlite-message-cut.test.ts b/src/config/sessions/session-accessor.sqlite-message-cut.test.ts index 99ad09892e75..c8dff8cd01c3 100644 --- a/src/config/sessions/session-accessor.sqlite-message-cut.test.ts +++ b/src/config/sessions/session-accessor.sqlite-message-cut.test.ts @@ -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, diff --git a/src/config/sessions/session-accessor.sqlite-message-cut.ts b/src/config/sessions/session-accessor.sqlite-message-cut.ts index 8509d4646b42..350b7bb0da7e 100644 --- a/src/config/sessions/session-accessor.sqlite-message-cut.ts +++ b/src/config/sessions/session-accessor.sqlite-message-cut.ts @@ -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, diff --git a/src/config/sessions/session-snapshot-merge.test.ts b/src/config/sessions/session-snapshot-merge.test.ts index 319d0dfcb930..58db889f8c06 100644 --- a/src/config/sessions/session-snapshot-merge.test.ts +++ b/src/config/sessions/session-snapshot-merge.test.ts @@ -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(); }); diff --git a/src/config/sessions/session-snapshot-merge.ts b/src/config/sessions/session-snapshot-merge.ts index 30bd99b19863..41c3f64ad40e 100644 --- a/src/config/sessions/session-snapshot-merge.ts +++ b/src/config/sessions/session-snapshot-merge.ts @@ -27,6 +27,7 @@ const MODEL_OVERRIDE_RUNTIME_FIELDS = [ "model", "fallbackNotice", "contextTokens", + "contextTokensSource", "contextBudgetStatus", ] as const satisfies ReadonlyArray; const MODEL_OVERRIDE_RUNTIME_FIELD_SET = new Set(MODEL_OVERRIDE_RUNTIME_FIELDS); diff --git a/src/config/sessions/types.ts b/src/config/sessions/types.ts index b2d88abf2a13..ee0131272e80 100644 --- a/src/config/sessions/types.ts +++ b/src/config/sessions/types.ts @@ -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[]; diff --git a/src/cron/isolated-agent/run-executor.ts b/src/cron/isolated-agent/run-executor.ts index ca7b332427a2..97d941534351 100644 --- a/src/cron/isolated-agent/run-executor.ts +++ b/src/cron/isolated-agent/run-executor.ts @@ -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; diff --git a/src/cron/isolated-agent/run-finalize.ts b/src/cron/isolated-agent/run-finalize.ts index 99d7ecb84dd4..8c1300d72627 100644 --- a/src/cron/isolated-agent/run-finalize.ts +++ b/src/cron/isolated-agent/run-finalize.ts @@ -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(); diff --git a/src/cron/isolated-agent/run-session-state.test.ts b/src/cron/isolated-agent/run-session-state.test.ts index be776b5be9ef..47458385478b 100644 --- a/src/cron/isolated-agent/run-session-state.test.ts +++ b/src/cron/isolated-agent/run-session-state.test.ts @@ -19,6 +19,7 @@ import { CronSessionLifecycleClaimError, createCronRunContinuationSession, createPersistCronSessionEntry, + markCronSessionPreRun, resolveCronLifecycleRevisionIdentity, syncCronSessionLiveSelection, type MutableCronSession, @@ -66,7 +67,69 @@ function makeGuardedPersistSessionEntry(persistedStore: Record { + 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, + }); + + 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; + 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, + }); + + 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, diff --git a/src/cron/isolated-agent/run-session-state.ts b/src/cron/isolated-agent/run-session-state.ts index eb16ba8da948..a551aa1b01fd 100644 --- a/src/cron/isolated-agent/run-session-state.ts +++ b/src/cron/isolated-agent/run-session-state.ts @@ -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; +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 { diff --git a/src/cron/isolated-agent/run.live-session-model-switch.test.ts b/src/cron/isolated-agent/run.live-session-model-switch.test.ts index bcdba77be498..62a13e11095a 100644 --- a/src/cron/isolated-agent/run.live-session-model-switch.test.ts +++ b/src/cron/isolated-agent/run.live-session-model-switch.test.ts @@ -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["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 () => { diff --git a/src/cron/isolated-agent/run.skill-filter.test.ts b/src/cron/isolated-agent/run.skill-filter.test.ts index 91192de0046b..3b1ce8b8c5c3 100644 --- a/src/cron/isolated-agent/run.skill-filter.test.ts +++ b/src/cron/isolated-agent/run.skill-filter.test.ts @@ -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["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["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", diff --git a/src/cron/isolated-agent/run.test-harness.ts b/src/cron/isolated-agent/run.test-harness.ts index e7d46bb8ab30..0d5505c8de85 100644 --- a/src/cron/isolated-agent/run.test-harness.ts +++ b/src/cron/isolated-agent/run.test-harness.ts @@ -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( diff --git a/src/cron/isolated-agent/session.test.ts b/src/cron/isolated-agent/session.test.ts index b03c67898ff9..86314cb80eae 100644 --- a/src/cron/isolated-agent/session.test.ts +++ b/src/cron/isolated-agent/session.test.ts @@ -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(); diff --git a/src/gateway/server.chat.gateway-server-chat-b.test.ts b/src/gateway/server.chat.gateway-server-chat-b.test.ts index 9bcb4ad46263..716629d36112 100644 --- a/src/gateway/server.chat.gateway-server-chat-b.test.ts +++ b/src/gateway/server.chat.gateway-server-chat-b.test.ts @@ -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 }), diff --git a/src/gateway/server.sessions.list-changed.test.ts b/src/gateway/server.sessions.list-changed.test.ts index f68dc6b9b0a5..add54d23ef60 100644 --- a/src/gateway/server.sessions.list-changed.test.ts +++ b/src/gateway/server.sessions.list-changed.test.ts @@ -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, }), diff --git a/src/gateway/session-message-events.test.ts b/src/gateway/session-message-events.test.ts index 3e1f291a5079..04139e6a3b2c 100644 --- a/src/gateway/session-message-events.test.ts +++ b/src/gateway/session-message-events.test.ts @@ -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, }, diff --git a/src/gateway/session-utils-model.ts b/src/gateway/session-utils-model.ts index feaf806c96ee..b4cf129cf90a 100644 --- a/src/gateway/session-utils-model.ts +++ b/src/gateway/session-utils-model.ts @@ -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, diff --git a/src/gateway/session-utils-owners.test.ts b/src/gateway/session-utils-owners.test.ts index b7a412e61bc3..1d319a208627 100644 --- a/src/gateway/session-utils-owners.test.ts +++ b/src/gateway/session-utils-owners.test.ts @@ -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", diff --git a/src/gateway/session-utils-projection.ts b/src/gateway/session-utils-projection.ts index a85f66731a40..a101ee5d7155 100644 --- a/src/gateway/session-utils-projection.ts +++ b/src/gateway/session-utils-projection.ts @@ -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, }; } diff --git a/src/gateway/session-utils-row.ts b/src/gateway/session-utils-row.ts index 41f192600877..d15284890ecb 100644 --- a/src/gateway/session-utils-row.ts +++ b/src/gateway/session-utils-row.ts @@ -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, diff --git a/src/gateway/session-utils.test.ts b/src/gateway/session-utils.test.ts index 438294244457..1f27a10bb157 100644 --- a/src/gateway/session-utils.test.ts +++ b/src/gateway/session-utils.test.ts @@ -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) => { diff --git a/src/plugins/contracts/session-entry-projection.contract.test.ts b/src/plugins/contracts/session-entry-projection.contract.test.ts index 69b5dffc5330..c0d9aea66e8c 100644 --- a/src/plugins/contracts/session-entry-projection.contract.test.ts +++ b/src/plugins/contracts/session-entry-projection.contract.test.ts @@ -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", diff --git a/src/plugins/session-entry-slot-keys.ts b/src/plugins/session-entry-slot-keys.ts index 2c2aaaff15bd..2ee5213cbaaf 100644 --- a/src/plugins/session-entry-slot-keys.ts +++ b/src/plugins/session-entry-slot-keys.ts @@ -149,6 +149,7 @@ const SESSION_ENTRY_RESERVED_SLOT_KEY_LIST = [ "agentHarnessId", "fallbackNotice", "contextTokens", + "contextTokensSource", "contextBudgetStatus", "compactionCount", "compactionCheckpoints", diff --git a/src/sessions/model-overrides.test.ts b/src/sessions/model-overrides.test.ts index 41c8621d8f21..d7b9a7ac343e 100644 --- a/src/sessions/model-overrides.test.ts +++ b/src/sessions/model-overrides.test.ts @@ -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); }); diff --git a/src/sessions/model-overrides.ts b/src/sessions/model-overrides.ts index d634f31fe04f..e3e08871b8ef 100644 --- a/src/sessions/model-overrides.ts +++ b/src/sessions/model-overrides.ts @@ -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) { diff --git a/src/status/status-message.test.ts b/src/status/status-message.test.ts index 7e8edbd53cda..d8cdf4bd39f1 100644 --- a/src/status/status-message.test.ts +++ b/src/status/status-message.test.ts @@ -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", () => { diff --git a/src/status/status-message.ts b/src/status/status-message.ts index e49f1abd06bd..54044d62f0ae 100644 --- a/src/status/status-message.ts +++ b/src/status/status-message.ts @@ -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"; diff --git a/src/status/summary.runtime.ts b/src/status/summary.runtime.ts index 0c383e4cc3bb..4e4583218d8e 100644 --- a/src/status/summary.runtime.ts +++ b/src/status/summary.runtime.ts @@ -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, diff --git a/src/status/summary.ts b/src/status/summary.ts index de87c4457c01..bc7e569ed6f7 100644 --- a/src/status/summary.ts +++ b/src/status/summary.ts @@ -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;