mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-27 12:56:01 -06:00
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
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user