From beb466df3cfc6d48d77004a35a229a0e28fe443d Mon Sep 17 00:00:00 2001 From: "Jason (Json)" <263060202+fuller-stack-dev@users.noreply.github.com> Date: Wed, 19 Aug 2026 16:34:20 -0600 Subject: [PATCH] fix(sessions): preserve resolved context in cold projections (#126422) * fix(sessions): preserve resolved context in cold projections * fix(sessions): preserve verified context provenance * test(sessions): complete context metadata fixture * fix(sessions): version verified context resolution --- .../agent-runner-result-accounting.test.ts | 81 ++++++++++++++++++- .../reply/agent-runner-result-accounting.ts | 26 ++++-- .../sessions/context-token-provenance.test.ts | 48 +++++++++++ .../sessions/context-token-provenance.ts | 66 ++++++++++----- src/config/sessions/types.ts | 4 +- src/gateway/session-utils.test.ts | 68 ++++++++++++++++ 6 files changed, 264 insertions(+), 29 deletions(-) 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 c4518a335d4b..995682f3498d 100644 --- a/src/auto-reply/reply/agent-runner-result-accounting.test.ts +++ b/src/auto-reply/reply/agent-runner-result-accounting.test.ts @@ -6,10 +6,11 @@ import type { FollowupExecutionResult } from "./followup-turn-execution.js"; const mocks = vi.hoisted(() => ({ persistRunSessionUsage: vi.fn(async (_params: unknown) => undefined), refreshQueuedFollowupSession: vi.fn(), + resolveContextTokensForModel: vi.fn<() => number | undefined>(() => 200_000), })); vi.mock("../../agents/context.js", () => ({ - resolveContextTokensForModel: () => 200_000, + resolveContextTokensForModel: () => mocks.resolveContextTokensForModel(), })); vi.mock("../../agents/fast-mode.js", () => ({ @@ -166,6 +167,7 @@ function createParams( describe("accountFollowupTurn", () => { beforeEach(() => { vi.clearAllMocks(); + mocks.resolveContextTokensForModel.mockReturnValue(200_000); }); it("forwards typed runtime context provenance to session persistence", async () => { @@ -194,6 +196,83 @@ describe("accountFollowupTurn", () => { ); }); + it("treats a source-less current-run context window as runtime provenance", 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: "legacy-runtime", + contextTokens: 512_000, + }; + + await accountFollowupTurn(params); + + expect(mocks.persistRunSessionUsage).toHaveBeenCalledWith( + expect.objectContaining({ + agentHarnessId: "legacy-runtime", + contextTokensUsed: 512_000, + contextTokensSource: "runtime", + }), + ); + }); + + it("marks a successful current model lookup with versioned resolved provenance", async () => { + const params = createParams(); + + await accountFollowupTurn(params); + + expect(mocks.persistRunSessionUsage).toHaveBeenCalledWith( + expect.objectContaining({ + contextTokensUsed: 200_000, + contextTokensSource: "resolved-v1", + }), + ); + }); + + it("does not label a prior context fallback as a current resolution after a model switch", async () => { + mocks.resolveContextTokensForModel.mockReturnValueOnce(undefined); + const params = createParams(); + const session = params.turn.session as unknown as { + current: () => SessionEntry; + adopt: (entry: SessionEntry) => void; + }; + session.adopt({ + ...session.current(), + modelProvider: "anthropic", + model: "claude", + agentHarnessId: "openclaw", + contextTokens: 272_000, + contextTokensSource: "resolved", + }); + 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", + }; + + await accountFollowupTurn(params); + + expect(mocks.persistRunSessionUsage).toHaveBeenCalledWith( + expect.objectContaining({ + providerUsed: "openai", + modelUsed: "gpt-4o", + agentHarnessId: "codex", + contextTokensUsed: 272_000, + contextTokensSource: undefined, + }), + ); + }); + 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 83cfbd4d87bd..b80b43e49127 100644 --- a/src/auto-reply/reply/agent-runner-result-accounting.ts +++ b/src/auto-reply/reply/agent-runner-result-accounting.ts @@ -230,17 +230,27 @@ export async function accountAgentTurn(context: AgentTurnAccountingContext) { runResult.meta.agentMeta.contextTokens > 0 ? Math.floor(runResult.meta.agentMeta.contextTokens) : undefined; + const resolvedContextTokens = + runtimeContextTokens === undefined + ? resolveContextTokensForModel({ + cfg, + provider: providerUsed, + model: modelUsed, + allowAsyncLoad: false, + }) + : undefined; const contextTokensUsed = runtimeContextTokens ?? - resolveContextTokensForModel({ - cfg, - provider: providerUsed, - model: modelUsed, - fallbackContextTokens: activeSessionEntry?.contextTokens ?? DEFAULT_CONTEXT_TOKENS, - allowAsyncLoad: false, - }) ?? + resolvedContextTokens ?? + activeSessionEntry?.contextTokens ?? DEFAULT_CONTEXT_TOKENS; - const contextTokensSource = runResult.meta?.agentMeta?.contextTokensSource ?? "resolved"; + const contextTokensSource = + runResult.meta?.agentMeta?.contextTokensSource ?? + (runtimeContextTokens !== undefined + ? "runtime" + : resolvedContextTokens !== undefined + ? "resolved-v1" + : undefined); await persistRunSessionUsage({ storePath, diff --git a/src/config/sessions/context-token-provenance.test.ts b/src/config/sessions/context-token-provenance.test.ts index 15223f1594d6..4644d8c5f302 100644 --- a/src/config/sessions/context-token-provenance.test.ts +++ b/src/config/sessions/context-token-provenance.test.ts @@ -124,6 +124,54 @@ describe("resolveProjectedSessionContextTokens", () => { ).toBe(1_000_000); }); + it("falls back to the matching persisted resolution while current resolution is unavailable", () => { + expect( + resolveProjectedSessionContextTokens({ + entry: { ...matchingRuntimeEntry, contextTokensSource: "resolved-v1" }, + ...currentSelection, + resolvedContextTokens: undefined, + }), + ).toBe(272_000); + }); + + it("rejects a legacy resolved row because its producer may have reused a fallback", () => { + expect( + resolveProjectedSessionContextTokens({ + entry: { ...matchingRuntimeEntry, contextTokensSource: "resolved" }, + ...currentSelection, + resolvedContextTokens: undefined, + }), + ).toBeUndefined(); + }); + + it("does not resurrect a removed runtime-configured cap while resolution is unavailable", () => { + expect( + resolveProjectedSessionContextTokens({ + entry: { ...matchingRuntimeEntry, contextTokensSource: "runtime-configured" }, + ...currentSelection, + resolvedContextTokens: undefined, + }), + ).toBeUndefined(); + }); + + it.each([ + { name: "provider", patch: { modelProvider: "openrouter" } }, + { name: "model", patch: { model: "gpt-5.5" } }, + { name: "harness", patch: { agentHarnessId: "openclaw" } }, + ])("rejects a persisted resolution owned by a different $name", ({ patch }) => { + expect( + resolveProjectedSessionContextTokens({ + entry: { + ...matchingRuntimeEntry, + contextTokensSource: "resolved-v1", + ...patch, + }, + ...currentSelection, + resolvedContextTokens: undefined, + }), + ).toBeUndefined(); + }); + it("preserves a locked native window ahead of current configuration", () => { expect( resolveProjectedSessionContextTokens({ diff --git a/src/config/sessions/context-token-provenance.ts b/src/config/sessions/context-token-provenance.ts index ec465b3c4230..42f339adbede 100644 --- a/src/config/sessions/context-token-provenance.ts +++ b/src/config/sessions/context-token-provenance.ts @@ -15,6 +15,46 @@ function resolvePositiveContextTokens(value: number | null | undefined): number return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : undefined; } +function isExactProducerSelection(params: { + entry: SessionContextTokenOwner | undefined; + provider: string | null | undefined; + model: string | null | undefined; + agentHarnessId: string | null | undefined; +}): boolean { + const entryProvider = normalizeLowercaseStringOrEmpty(params.entry?.modelProvider); + const entryModel = normalizeLowercaseStringOrEmpty(params.entry?.model); + const entryHarness = normalizeLowercaseStringOrEmpty(params.entry?.agentHarnessId); + const currentProvider = normalizeLowercaseStringOrEmpty(params.provider); + const currentModel = normalizeLowercaseStringOrEmpty(params.model); + const currentHarness = normalizeLowercaseStringOrEmpty(params.agentHarnessId); + return Boolean( + entryProvider && + entryModel && + entryHarness && + currentProvider && + currentModel && + currentHarness && + entryProvider === currentProvider && + entryModel === currentModel && + entryHarness === currentHarness, + ); +} + +/** Returns a persisted effective resolution only for its exact producing selection. */ +function resolveMatchingPersistedResolution(params: { + entry: SessionContextTokenOwner | undefined; + provider: string | null | undefined; + model: string | null | undefined; + agentHarnessId: string | null | undefined; +}): number | undefined { + if (params.entry?.contextTokensSource !== "resolved-v1") { + return undefined; + } + return isExactProducerSelection(params) + ? resolvePositiveContextTokens(params.entry?.contextTokens) + : undefined; +} + /** Returns persisted telemetry only when it belongs to the current producing selection. */ export function resolveTrustedSessionContextTokens(params: { entry: SessionContextTokenOwner | undefined; @@ -45,23 +85,7 @@ export function resolveTrustedSessionContextTokens(params: { 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; + return isExactProducerSelection(params) ? contextTokens : undefined; } /** Projects the context window owned by the current session selection. */ @@ -76,14 +100,20 @@ export function resolveProjectedSessionContextTokens(params: { const resolvedContextTokens = resolvePositiveContextTokens(params.resolvedContextTokens); const authoredContextTokens = resolvePositiveContextTokens(params.authoredContextTokens); const trustedContextTokens = resolveTrustedSessionContextTokens(params); + const persistedResolution = + resolvedContextTokens === undefined && authoredContextTokens === undefined + ? resolveMatchingPersistedResolution(params) + : undefined; // An authored effective cap owns the current selection. Otherwise current // model capacity only constrains telemetry from that exact producer tuple. + // When synchronous model resolution is unavailable, preserve the last + // matching effective resolution instead of publishing an unknown window. const currentContextTokens = authoredContextTokens !== undefined ? resolvedContextTokens : trustedContextTokens !== undefined && resolvedContextTokens !== undefined ? Math.min(trustedContextTokens, resolvedContextTokens) - : (trustedContextTokens ?? resolvedContextTokens); + : (trustedContextTokens ?? resolvedContextTokens ?? persistedResolution); return params.entry?.modelSelectionLocked === true ? (trustedContextTokens ?? currentContextTokens) : currentContextTokens; diff --git a/src/config/sessions/types.ts b/src/config/sessions/types.ts index 83d85f0c78f6..da4bfc1aec72 100644 --- a/src/config/sessions/types.ts +++ b/src/config/sessions/types.ts @@ -581,8 +581,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"; + /** Origin of the persisted context window; `resolved` is legacy/unverified. */ + contextTokensSource?: "runtime" | "runtime-configured" | "resolved" | "resolved-v1"; contextBudgetStatus?: SessionContextBudgetStatus; compactionCount?: number; compactionCheckpoints?: SessionCompactionCheckpoint[]; diff --git a/src/gateway/session-utils.test.ts b/src/gateway/session-utils.test.ts index 86491ade6b64..de8af51eb206 100644 --- a/src/gateway/session-utils.test.ts +++ b/src/gateway/session-utils.test.ts @@ -1588,6 +1588,74 @@ describe("gateway session utils", () => { }, ); + test.each([true, false])( + "projects a matching persisted resolved cap when catalog resolution is unavailable (lightweight=%s)", + (lightweightListRow) => { + const cfg = { + agents: { + defaults: { + model: { primary: "openai/gpt-5.6-sol" }, + models: { "openai/gpt-5.6-sol": { agentRuntime: { id: "codex" } } }, + }, + }, + } as unknown as OpenClawConfig; + const entry = { + sessionId: "matching-resolved-cap", + modelProvider: "openai", + model: "gpt-5.6-sol", + agentHarnessId: "codex", + contextTokens: 272_000, + contextTokensSource: "resolved-v1", + } as SessionEntry; + + const row = buildGatewaySessionRow({ + cfg, + storePath: "", + store: { "agent:main:main": entry }, + key: "agent:main:main", + entry, + lightweightListRow, + }); + + expect(row.agentRuntime?.id).toBe("codex"); + expect(row.contextTokens).toBe(272_000); + }, + ); + + test.each([true, false])( + "rejects an unresolved fallback even after persistence records the current tuple (lightweight=%s)", + (lightweightListRow) => { + const cfg = { + agents: { + defaults: { + model: { primary: "openai/gpt-5.6-sol" }, + models: { "openai/gpt-5.6-sol": { agentRuntime: { id: "codex" } } }, + }, + }, + } as unknown as OpenClawConfig; + const entry = { + sessionId: "unresolved-fallback", + modelProvider: "openai", + model: "gpt-5.6-sol", + agentHarnessId: "codex", + contextTokens: 272_000, + contextTokensSource: undefined, + } as SessionEntry; + + const row = buildGatewaySessionRow({ + cfg, + storePath: "", + store: { "agent:main:main": entry }, + key: "agent:main:main", + entry, + lightweightListRow, + }); + + expect(row.agentRuntime?.id).toBe("codex"); + expect(row.contextTokens).toBeUndefined(); + }, + ); + test.each(["xhigh", "max"] as const)( "preserves catalog-less persisted %s in session change projections", (thinkingLevel) => {