mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-21 10:01:37 -06:00
fix(sessions): track context window provenance (#124303)
* fix(sessions): track context window provenance * fix(sessions): complete context provenance coverage * fix(sessions): honor authored context window caps * fix(agents): distinguish context source helpers * fix(sessions): clear context provenance on model invalidation * test(codex): preserve local operator authority provenance * fix(context): clamp effective caps to native windows * fix(cron): use the runtime model facade * fix(status): honor session context provenance * test(status): use public context token inputs * test(status): split session row cases * test(status): classify split cases as test support * test(status): declare runtime context provenance * fix(sessions): preserve context ownership through finalization * fix(sessions): apply context provenance to listings * fix(cron): preserve projected context ownership * fix(sessions): scope locked context ownership * fix(status): share session context projection
This commit is contained in:
@@ -45,6 +45,7 @@ type CodexAttemptResultInput = {
|
||||
aborted: boolean;
|
||||
tokenUsage: EmbeddedRunAttemptResult["attemptUsage"];
|
||||
contextTokens: number | undefined;
|
||||
contextTokensSource: EmbeddedRunAttemptResult["contextTokensSource"];
|
||||
completedCompactionCount: number;
|
||||
activeItemCount: number;
|
||||
completedItemCount: number;
|
||||
@@ -217,6 +218,7 @@ export function buildCodexAttemptResult(
|
||||
acceptedSessionSpawns: input.toolTelemetry.acceptedSessionSpawns,
|
||||
cloudCodeAssistFormatError: false,
|
||||
contextTokens: input.contextTokens,
|
||||
contextTokensSource: input.contextTokensSource,
|
||||
attemptUsage: projectedUsage,
|
||||
...(input.completedCompactionCount > 0
|
||||
? { compactionCount: input.completedCompactionCount }
|
||||
|
||||
@@ -84,6 +84,7 @@ export class CodexAppServerEventProjector {
|
||||
private aborted = false;
|
||||
private tokenUsage: ReturnType<typeof normalizeCodexThreadTokenUsage>;
|
||||
private contextTokens: number | undefined;
|
||||
private contextTokensSource: "runtime" | "runtime-configured" | "resolved" | undefined;
|
||||
private readonly responseCompletions = new CodexResponseCompletionProjection();
|
||||
private completedCompactionCount = 0;
|
||||
private lastTranscriptTimestamp = 0;
|
||||
@@ -95,6 +96,7 @@ export class CodexAppServerEventProjector {
|
||||
private readonly options: CodexAppServerEventProjectorOptions = {},
|
||||
) {
|
||||
this.contextTokens = options.initialContextTokens;
|
||||
this.contextTokensSource = options.initialContextTokens === undefined ? undefined : "resolved";
|
||||
this.diagnostics = new CodexProjectionDiagnostics(threadId, turnId);
|
||||
this.nativeToolLifecycleProjector = new CodexNativeToolLifecycleProjector(
|
||||
params,
|
||||
@@ -276,7 +278,16 @@ export class CodexAppServerEventProjector {
|
||||
this.tokenUsage,
|
||||
(usage) => (this.tokenUsage = usage),
|
||||
(data) => {
|
||||
this.contextTokens = data.modelContextWindow ?? this.contextTokens;
|
||||
if (data.modelContextWindow !== undefined) {
|
||||
this.contextTokens = data.modelContextWindow;
|
||||
// Codex reports the effective thread window. When OpenClaw supplied an
|
||||
// authored cap, retain that fact so removing the cap cannot make the
|
||||
// constrained observation look like uncapped native telemetry.
|
||||
this.contextTokensSource =
|
||||
this.params.authoredContextTokenCap === undefined
|
||||
? "runtime"
|
||||
: "runtime-configured";
|
||||
}
|
||||
this.emitAgentEvent({ stream: "codex_app_server.usage", data });
|
||||
},
|
||||
);
|
||||
@@ -339,6 +350,7 @@ export class CodexAppServerEventProjector {
|
||||
aborted: this.aborted,
|
||||
tokenUsage: this.tokenUsage,
|
||||
contextTokens: this.contextTokens,
|
||||
contextTokensSource: this.contextTokensSource,
|
||||
completedCompactionCount: this.completedCompactionCount,
|
||||
activeItemCount: this.activeItemIds.size,
|
||||
completedItemCount: this.completedItemIds.size,
|
||||
|
||||
@@ -27,6 +27,7 @@ describe("CodexAppServerEventProjector usage projection", () => {
|
||||
|
||||
expect(projector.buildResult(buildEmptyToolTelemetry())).toMatchObject({
|
||||
contextTokens: 1_050_000,
|
||||
contextTokensSource: "resolved",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -69,6 +70,23 @@ describe("CodexAppServerEventProjector usage projection", () => {
|
||||
});
|
||||
expect(projector.buildResult(buildEmptyToolTelemetry())).toMatchObject({
|
||||
contextTokens: 875_900,
|
||||
contextTokensSource: "runtime",
|
||||
});
|
||||
});
|
||||
|
||||
it("marks native telemetry constrained by an authored context cap", async () => {
|
||||
const params = await createParams();
|
||||
const projector = await createProjector({ ...params, authoredContextTokenCap: 272_000 });
|
||||
|
||||
await projector.handleNotification(
|
||||
forCurrentTurn("thread/tokenUsage/updated", {
|
||||
tokenUsage: { modelContextWindow: 272_000 },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(projector.buildResult(buildEmptyToolTelemetry())).toMatchObject({
|
||||
contextTokens: 272_000,
|
||||
contextTokensSource: "runtime-configured",
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -4,30 +4,28 @@ import { applyAcpRuntimeOverlay, type AgentRuntimeMetadata } from "./acp-runtime
|
||||
import { isDefaultAgentRuntimeId } from "./agent-runtime-id.js";
|
||||
import { resolveAgentHarnessPolicy } from "./harness/policy.js";
|
||||
import { resolveDefaultModelForAgent } from "./model-selection.js";
|
||||
import { resolvePersistedSessionRuntimeId } from "./session-runtime-compat.js";
|
||||
import {
|
||||
resolvePersistedSessionRuntimeId,
|
||||
resolveSessionRuntimeOverrideForProvider,
|
||||
} from "./session-runtime-compat.js";
|
||||
|
||||
/** Resolves the runtime id/source that should be reported for a model-backed agent session. */
|
||||
export function resolveModelAgentRuntimeMetadata(params: {
|
||||
type ModelAgentRuntimeMetadataParams = {
|
||||
cfg: OpenClawConfig;
|
||||
agentId: string;
|
||||
provider?: string;
|
||||
model?: string;
|
||||
sessionKey?: string;
|
||||
sessionEntry?: Parameters<typeof resolvePersistedSessionRuntimeId>[0];
|
||||
/**
|
||||
* True when the loaded session entry has persisted ACP metadata. ACP-shaped
|
||||
* keys without this marker can be bridge sessions that use the configured
|
||||
* model/runtime.
|
||||
*/
|
||||
/** True only when persisted ACP metadata owns the session. */
|
||||
acpRuntime?: boolean;
|
||||
/**
|
||||
* The ACP backend identifier stored on the session entry (`entry.acp.backend`).
|
||||
* When provided for an ACP-keyed session, the overlay reports this value as the
|
||||
* runtime id instead of the generic fallback "acpx", so sessions backed by a
|
||||
* non-default registered ACP backend are classified correctly.
|
||||
*/
|
||||
/** Persisted ACP backend id, falling back to acpx when absent. */
|
||||
acpBackend?: string;
|
||||
}): AgentRuntimeMetadata {
|
||||
};
|
||||
|
||||
/** Resolves the runtime id/source that should be reported for a model-backed agent session. */
|
||||
export function resolveModelAgentRuntimeMetadata(
|
||||
params: ModelAgentRuntimeMetadataParams,
|
||||
): AgentRuntimeMetadata {
|
||||
const persistedRuntimeId = resolvePersistedSessionRuntimeId(params.sessionEntry);
|
||||
if (persistedRuntimeId && !isDefaultAgentRuntimeId(persistedRuntimeId)) {
|
||||
return applyAcpRuntimeOverlay(
|
||||
@@ -54,3 +52,23 @@ export function resolveModelAgentRuntimeMetadata(params: {
|
||||
};
|
||||
return applyAcpRuntimeOverlay(meta, params.sessionKey, params.acpRuntime, params.acpBackend);
|
||||
}
|
||||
|
||||
/** Resolves the runtime selected for the next turn, excluding historical producer metadata. */
|
||||
export function resolveCurrentSessionAgentRuntimeMetadata(
|
||||
params: ModelAgentRuntimeMetadataParams,
|
||||
): AgentRuntimeMetadata {
|
||||
const { sessionEntry, ...configuredParams } = params;
|
||||
const configuredRuntime = resolveModelAgentRuntimeMetadata(configuredParams);
|
||||
const sessionRuntime = resolveSessionRuntimeOverrideForProvider({
|
||||
provider: params.provider,
|
||||
entry: sessionEntry,
|
||||
cfg: params.cfg,
|
||||
});
|
||||
if (params.acpRuntime || !sessionRuntime) {
|
||||
return configuredRuntime;
|
||||
}
|
||||
return {
|
||||
id: sessionRuntime,
|
||||
source: sessionEntry?.modelSelectionLocked === true ? "session" : "session-key",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -658,6 +658,33 @@ describe("lookupContextTokens", () => {
|
||||
).toBe(1_000_000);
|
||||
});
|
||||
|
||||
it("bounds an authored effective cap by a smaller authored context window", async () => {
|
||||
mockDiscoveryDeps([]);
|
||||
const resolveContextTokensForModel = await importResolveContextTokensForModel();
|
||||
|
||||
expect(
|
||||
resolveContextTokensForModel({
|
||||
cfg: {
|
||||
models: {
|
||||
providers: {
|
||||
openai: {
|
||||
models: [
|
||||
{
|
||||
id: "gpt-5.6-sol",
|
||||
contextWindow: 128_000,
|
||||
contextTokens: 1_000_000,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
provider: "openai",
|
||||
model: "gpt-5.6-sol",
|
||||
}),
|
||||
).toBe(128_000);
|
||||
});
|
||||
|
||||
it("resolveContextTokensForModel honors configured overrides when provider keys use mixed case", async () => {
|
||||
mockDiscoveryDeps([{ id: "openrouter/anthropic/claude-sonnet-4-5", contextWindow: 1_048_576 }]);
|
||||
|
||||
|
||||
@@ -272,6 +272,7 @@ export function buildErrorAgentMeta(params: {
|
||||
provider: params.provider,
|
||||
model: params.model,
|
||||
...(params.contextTokens ? { contextTokens: params.contextTokens } : {}),
|
||||
...(params.contextTokens ? { contextTokensSource: "resolved" as const } : {}),
|
||||
...(usageMeta.usage ? { usage: usageMeta.usage } : {}),
|
||||
...(usageMeta.lastCallUsage ? { lastCallUsage: usageMeta.lastCallUsage } : {}),
|
||||
...(usageMeta.promptTokens ? { promptTokens: usageMeta.promptTokens } : {}),
|
||||
|
||||
@@ -187,6 +187,42 @@ describe("prepareTerminalWithSettledTurnFinalization", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves the settled runtime context window through isolated finalization", async () => {
|
||||
const attempt = {
|
||||
...settledFailedAttempt(),
|
||||
agentHarnessId: "codex",
|
||||
contextTokens: 1_000_000,
|
||||
contextTokensSource: "runtime" as const,
|
||||
};
|
||||
const input = finalizationInput(attempt);
|
||||
input.terminalBase.outerContextTokenMeta = { contextTokens: 272_000 };
|
||||
input.finalization.preparedAttempt.agentHarnessId = "codex";
|
||||
const finalAssistant = buildEmbeddedRunnerAssistant({
|
||||
content: [{ type: "text", text: "The exec tool failed: post-processing error." }],
|
||||
});
|
||||
backendMocks.runSettledFinalization.mockResolvedValueOnce({
|
||||
outcome: "answered",
|
||||
result: {
|
||||
assistant: finalAssistant,
|
||||
usage: finalAssistant.usage,
|
||||
diagnosticTrace: { traceId: "trace-final", spanId: "span-final" },
|
||||
},
|
||||
});
|
||||
|
||||
const result = await prepareTerminalWithSettledTurnFinalization(input);
|
||||
|
||||
expect(result.attempt).toMatchObject({
|
||||
agentHarnessId: "codex",
|
||||
contextTokens: 1_000_000,
|
||||
contextTokensSource: "runtime",
|
||||
});
|
||||
expect(result.prepared.agentMeta).toMatchObject({
|
||||
agentHarnessId: "codex",
|
||||
contextTokens: 1_000_000,
|
||||
contextTokensSource: "runtime",
|
||||
});
|
||||
});
|
||||
|
||||
it("fails closed and preserves the initial terminal preparation", async () => {
|
||||
const attempt = settledFailedAttempt();
|
||||
backendMocks.runSettledFinalization.mockRejectedValueOnce(new Error("finalizer failed"));
|
||||
|
||||
@@ -217,6 +217,8 @@ function buildSettledTurnFinalizationAttemptResult(input: {
|
||||
sessionIdUsed: settledAttempt.sessionIdUsed,
|
||||
sessionFileUsed: settledAttempt.sessionFileUsed,
|
||||
...(input.agentHarnessId ? { agentHarnessId: input.agentHarnessId } : {}),
|
||||
contextTokens: settledAttempt.contextTokens,
|
||||
contextTokensSource: settledAttempt.contextTokensSource,
|
||||
authBindingFingerprint: settledAttempt.authBindingFingerprint,
|
||||
runtimeArtifact: settledAttempt.runtimeArtifact,
|
||||
systemPromptReport: settledAttempt.systemPromptReport,
|
||||
|
||||
@@ -320,6 +320,7 @@ describe("prepareEmbeddedRunTerminal run stats", () => {
|
||||
config?: unknown;
|
||||
provider?: string;
|
||||
model?: string;
|
||||
outerContextTokenMeta?: { contextTokens?: number };
|
||||
responseModel?: string;
|
||||
usage?: Partial<
|
||||
Pick<
|
||||
@@ -366,7 +367,7 @@ describe("prepareEmbeddedRunTerminal run stats", () => {
|
||||
activeErrorContext: { provider, model },
|
||||
authProfileStore: { version: 1, profiles: {} },
|
||||
sessionIdUsed: "session-1",
|
||||
outerContextTokenMeta: {},
|
||||
outerContextTokenMeta: statsInput.outerContextTokenMeta ?? {},
|
||||
usageAccumulator,
|
||||
contextRecoveryState: createEmbeddedRunContextRecoveryState(),
|
||||
resolvedToolResultFormat: "markdown",
|
||||
@@ -401,6 +402,34 @@ describe("prepareEmbeddedRunTerminal run stats", () => {
|
||||
expect(prepared.agentMeta.codeModeEngaged).toBe(expected);
|
||||
});
|
||||
|
||||
it("records whether the context window came from the harness or prepared resolution", async () => {
|
||||
const observed = await prepareStats({
|
||||
attempt: { contextTokens: 1_000_000, contextTokensSource: "runtime" },
|
||||
outerContextTokenMeta: { contextTokens: 272_000 },
|
||||
});
|
||||
expect(observed.agentMeta).toMatchObject({
|
||||
contextTokens: 1_000_000,
|
||||
contextTokensSource: "runtime",
|
||||
});
|
||||
|
||||
const configured = await prepareStats({
|
||||
attempt: { contextTokens: 272_000, contextTokensSource: "runtime-configured" },
|
||||
outerContextTokenMeta: { contextTokens: 1_000_000 },
|
||||
});
|
||||
expect(configured.agentMeta).toMatchObject({
|
||||
contextTokens: 272_000,
|
||||
contextTokensSource: "runtime-configured",
|
||||
});
|
||||
|
||||
const resolved = await prepareStats({
|
||||
outerContextTokenMeta: { contextTokens: 272_000 },
|
||||
});
|
||||
expect(resolved.agentMeta).toMatchObject({
|
||||
contextTokens: 272_000,
|
||||
contextTokensSource: "resolved",
|
||||
});
|
||||
});
|
||||
|
||||
it("stamps assistantTurns from the run accumulator and omits zero", async () => {
|
||||
const counted = await prepareStats({ assistantTurns: 3 });
|
||||
expect(counted.agentMeta.assistantTurns).toBe(3);
|
||||
|
||||
@@ -96,12 +96,21 @@ export function prepareEmbeddedRunTerminal(input: {
|
||||
// Attempt normalization already folded every attempt (terminal included)
|
||||
// into the accumulator, so read it directly instead of re-adding the attempt.
|
||||
const runAssistantTurns = input.usageAccumulator.assistantTurns;
|
||||
const contextTokens = attempt.contextTokens ?? input.outerContextTokenMeta.contextTokens;
|
||||
const agentMeta: EmbeddedAgentMeta = {
|
||||
sessionId: input.sessionIdUsed,
|
||||
sessionFile: input.sessionFileUsed,
|
||||
provider: reportedModelRef.provider,
|
||||
model: reportedModelRef.model,
|
||||
contextTokens: attempt.contextTokens ?? input.outerContextTokenMeta.contextTokens,
|
||||
contextTokens,
|
||||
...(contextTokens !== undefined
|
||||
? {
|
||||
contextTokensSource:
|
||||
attempt.contextTokens !== undefined
|
||||
? (attempt.contextTokensSource ?? "resolved")
|
||||
: "resolved",
|
||||
}
|
||||
: {}),
|
||||
agentHarnessId: attempt.agentHarnessId,
|
||||
usage: usageMeta.usage,
|
||||
lastCallUsage: usageMeta.lastCallUsage,
|
||||
|
||||
@@ -326,6 +326,8 @@ export type EmbeddedRunAttemptResult = {
|
||||
cloudCodeAssistFormatError: boolean;
|
||||
/** Effective context window reported by the harness during this attempt. */
|
||||
contextTokens?: number;
|
||||
/** Whether the harness observed the window or carried prepared resolution forward. */
|
||||
contextTokensSource?: "runtime" | "runtime-configured" | "resolved";
|
||||
attemptUsage?: NormalizedUsage;
|
||||
promptCache?: ContextEnginePromptCacheInfo;
|
||||
contextBudgetStatus?: SessionContextBudgetStatus;
|
||||
|
||||
@@ -44,6 +44,7 @@ export type EmbeddedAgentMeta = {
|
||||
provider: string;
|
||||
model: string;
|
||||
contextTokens?: number;
|
||||
contextTokensSource?: "runtime" | "runtime-configured" | "resolved";
|
||||
agentHarnessId?: string;
|
||||
fallbackAttempts?: FallbackAttempt[];
|
||||
cliSessionBinding?: CliSessionBinding;
|
||||
|
||||
@@ -430,6 +430,29 @@ describe("sessions-list-tool", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves the context window already projected by the Gateway", async () => {
|
||||
mocks.gatewayCall.mockResolvedValue({
|
||||
path: "/tmp/sessions.json",
|
||||
sessions: [
|
||||
{
|
||||
key: "agent:main:main",
|
||||
agentId: "main",
|
||||
kind: "direct",
|
||||
classification: "main",
|
||||
model: "gpt-5.6-sol",
|
||||
contextTokens: 1_000_000,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const result = await createSessionsListTool({ config: VALID_CONFIG }).execute(
|
||||
"gateway-context-window",
|
||||
{},
|
||||
);
|
||||
|
||||
expect(getSessionsListDetails(result).sessions?.[0]?.contextTokens).toBe(1_000_000);
|
||||
});
|
||||
|
||||
it("keeps channel discovery but omits delivery routing metadata", async () => {
|
||||
mocks.gatewayCall.mockImplementation(async (opts: unknown) => {
|
||||
const request = opts as { method?: string };
|
||||
|
||||
@@ -397,6 +397,8 @@ export function createSessionsListTool(opts?: {
|
||||
: undefined;
|
||||
const updatedAt = typeof entry.updatedAt === "number" ? entry.updatedAt : undefined;
|
||||
const model = readStringValue(entry.model);
|
||||
// sessions.list owns runtime/context provenance; this tool only filters and
|
||||
// narrows its GatewaySessionListRow without reinterpreting raw session state.
|
||||
const contextTokens =
|
||||
typeof entry.contextTokens === "number" ? entry.contextTokens : undefined;
|
||||
const totalTokens = typeof entry.totalTokens === "number" ? entry.totalTokens : undefined;
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { AdmittedFollowupTurn, FollowupRunnerParams } from "./followup-turn
|
||||
import type { FollowupExecutionResult } from "./followup-turn-execution.js";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
persistRunSessionUsage: vi.fn(async (_params: unknown) => undefined),
|
||||
refreshQueuedFollowupSession: vi.fn(),
|
||||
}));
|
||||
|
||||
@@ -64,7 +65,7 @@ vi.mock("./reply-usage-state.js", () => ({
|
||||
|
||||
vi.mock("./session-run-accounting.js", () => ({
|
||||
incrementRunCompactionCount: vi.fn(async () => undefined),
|
||||
persistRunSessionUsage: vi.fn(async () => undefined),
|
||||
persistRunSessionUsage: (params: unknown) => mocks.persistRunSessionUsage(params),
|
||||
}));
|
||||
|
||||
import { accountFollowupTurn } from "./agent-runner-result-accounting.js";
|
||||
@@ -167,6 +168,32 @@ describe("accountFollowupTurn", () => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("forwards typed runtime context provenance to session persistence", async () => {
|
||||
const params = createParams();
|
||||
const result = params.execution.execution.outcome;
|
||||
if (result.kind !== "settled") {
|
||||
throw new Error("expected settled test execution");
|
||||
}
|
||||
result.result.meta.agentMeta = {
|
||||
sessionId: "session-1",
|
||||
provider: "openai",
|
||||
model: "gpt-4o",
|
||||
agentHarnessId: "codex",
|
||||
contextTokens: 1_000_000,
|
||||
contextTokensSource: "runtime",
|
||||
};
|
||||
|
||||
await accountFollowupTurn(params);
|
||||
|
||||
expect(mocks.persistRunSessionUsage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
agentHarnessId: "codex",
|
||||
contextTokensUsed: 1_000_000,
|
||||
contextTokensSource: "runtime",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: "source-less legacy user pin",
|
||||
|
||||
@@ -240,6 +240,7 @@ export async function accountAgentTurn(context: AgentTurnAccountingContext) {
|
||||
allowAsyncLoad: false,
|
||||
}) ??
|
||||
DEFAULT_CONTEXT_TOKENS;
|
||||
const contextTokensSource = runResult.meta?.agentMeta?.contextTokensSource ?? "resolved";
|
||||
|
||||
await persistRunSessionUsage({
|
||||
storePath,
|
||||
@@ -257,11 +258,13 @@ export async function accountAgentTurn(context: AgentTurnAccountingContext) {
|
||||
modelUsed,
|
||||
providerUsed,
|
||||
contextTokensUsed,
|
||||
contextTokensSource,
|
||||
systemPromptReport: runResult.meta?.systemPromptReport,
|
||||
cliSessionId,
|
||||
cliSessionBinding,
|
||||
clearCliSessionBinding,
|
||||
preserveFreshTotalTokensOnStaleUsage: preflightCompactionApplied,
|
||||
agentHarnessId: runResult.meta?.agentMeta?.agentHarnessId,
|
||||
});
|
||||
if (!isHeartbeat && !preserveUserFacingSessionState && !fallbackExhausted) {
|
||||
// A completed run that executed the persisted selection consumes the
|
||||
|
||||
@@ -133,6 +133,7 @@ describe("resetReplyRunSession", () => {
|
||||
modelProvider: "qwencode",
|
||||
model: "qwen",
|
||||
contextTokens: 123,
|
||||
contextTokensSource: "runtime",
|
||||
contextBudgetStatus: {
|
||||
schemaVersion: 1,
|
||||
source: "pre-prompt-estimate",
|
||||
@@ -207,6 +208,7 @@ describe("resetReplyRunSession", () => {
|
||||
expect(activeSessionEntry?.claudeCliSessionId).toBeUndefined();
|
||||
expect(activeSessionEntry?.model).toBeUndefined();
|
||||
expect(activeSessionEntry?.contextTokens).toBeUndefined();
|
||||
expect(activeSessionEntry?.contextTokensSource).toBeUndefined();
|
||||
expect(activeSessionEntry?.contextBudgetStatus).toBeUndefined();
|
||||
expect(activeSessionEntry?.fallbackNotice).toBeUndefined();
|
||||
expect(activeSessionEntry?.compactionCount).toBe(0);
|
||||
@@ -231,6 +233,7 @@ describe("resetReplyRunSession", () => {
|
||||
|
||||
const persisted = loadSessionEntry({ storePath, sessionKey });
|
||||
expect(persisted?.sessionId).toBe(activeSessionEntry?.sessionId);
|
||||
expect(persisted?.contextTokensSource).toBeUndefined();
|
||||
expect(persisted?.contextBudgetStatus).toBeUndefined();
|
||||
expect(persisted?.fallbackNotice).toBeUndefined();
|
||||
expect(persisted?.compactionCount).toBe(0);
|
||||
|
||||
@@ -96,6 +96,7 @@ export async function resetReplyRunSession(params: {
|
||||
cacheRead: undefined,
|
||||
cacheWrite: undefined,
|
||||
contextTokens: undefined,
|
||||
contextTokensSource: undefined,
|
||||
contextBudgetStatus: undefined,
|
||||
systemPromptReport: undefined,
|
||||
fallbackNotice: undefined,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -157,13 +157,20 @@ function makeFallbackContextStatusArgs({
|
||||
totalTokens: 49_000,
|
||||
totalTokensFresh: true,
|
||||
totalTokensVersion: 1 as const,
|
||||
...(sessionContextTokens === undefined ? {} : { contextTokens: sessionContextTokens }),
|
||||
...(sessionContextTokens === undefined
|
||||
? {}
|
||||
: {
|
||||
contextTokens: sessionContextTokens,
|
||||
contextTokensSource: "runtime" as const,
|
||||
agentHarnessId: "openclaw" as const,
|
||||
}),
|
||||
},
|
||||
sessionKey: "agent:main:main",
|
||||
sessionScope: "per-sender",
|
||||
queue: { mode: "collect", depth: 0 },
|
||||
modelAuth: "api-key",
|
||||
activeModelAuth: "api-key",
|
||||
resolvedHarness: "openclaw",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1846,13 +1853,18 @@ describe("buildStatusMessage", () => {
|
||||
sessionId: params.sessionId,
|
||||
updatedAt: 0,
|
||||
totalTokens: 3,
|
||||
modelProvider: "anthropic",
|
||||
model: "claude-opus-4-6",
|
||||
agentHarnessId: "openclaw",
|
||||
contextTokens: 32_000,
|
||||
contextTokensSource: "runtime",
|
||||
},
|
||||
sessionKey: params.sessionKey,
|
||||
sessionScope: "per-sender",
|
||||
queue: { mode: "collect", depth: 0 },
|
||||
includeTranscriptUsage: true,
|
||||
modelAuth: "api-key",
|
||||
resolvedHarness: "openclaw",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2010,13 +2022,18 @@ describe("buildStatusMessage", () => {
|
||||
sessionId,
|
||||
updatedAt: 0,
|
||||
totalTokens: 5,
|
||||
modelProvider: "anthropic",
|
||||
model: "claude-opus-4-6",
|
||||
agentHarnessId: "openclaw",
|
||||
contextTokens: 32_000,
|
||||
contextTokensSource: "runtime",
|
||||
},
|
||||
// Intentionally omitted: sessionKey
|
||||
sessionScope: "per-sender",
|
||||
queue: { mode: "collect", depth: 0 },
|
||||
includeTranscriptUsage: true,
|
||||
modelAuth: "api-key",
|
||||
resolvedHarness: "openclaw",
|
||||
});
|
||||
|
||||
expect(normalizeTestText(text)).toContain("Context: 1.2k/32k");
|
||||
|
||||
@@ -174,8 +174,9 @@ function repairProviderlessCodexSessionOverride(
|
||||
delete entry.model;
|
||||
delete entry.modelProvider;
|
||||
}
|
||||
if (entry.contextTokens !== undefined) {
|
||||
if (entry.contextTokens !== undefined || entry.contextTokensSource !== undefined) {
|
||||
delete entry.contextTokens;
|
||||
delete entry.contextTokensSource;
|
||||
}
|
||||
if (entry.contextBudgetStatus !== undefined) {
|
||||
delete entry.contextBudgetStatus;
|
||||
|
||||
@@ -3297,6 +3297,7 @@ describe("collectCodexRouteWarnings", () => {
|
||||
authProfileOverride: "openai-codex:default",
|
||||
authProfileOverrideSource: "auto",
|
||||
contextTokens: 64_000,
|
||||
contextTokensSource: "runtime",
|
||||
contextBudgetStatus: {
|
||||
schemaVersion: 1,
|
||||
source: "pre-prompt-estimate",
|
||||
@@ -3336,6 +3337,7 @@ describe("collectCodexRouteWarnings", () => {
|
||||
expect(getSession(store, "main").modelProvider).toBeUndefined();
|
||||
expect(getSession(store, "main").model).toBeUndefined();
|
||||
expect(getSession(store, "main").contextTokens).toBeUndefined();
|
||||
expect(getSession(store, "main").contextTokensSource).toBeUndefined();
|
||||
expect(getSession(store, "main").contextBudgetStatus).toBeUndefined();
|
||||
});
|
||||
|
||||
@@ -3373,6 +3375,8 @@ describe("collectCodexRouteWarnings", () => {
|
||||
agentHarnessId: "openclaw",
|
||||
agentRuntimeOverride: "openclaw",
|
||||
authProfileOverride: "openai:work",
|
||||
contextTokens: 128_000,
|
||||
contextTokensSource: "runtime",
|
||||
},
|
||||
};
|
||||
|
||||
@@ -3386,6 +3390,8 @@ describe("collectCodexRouteWarnings", () => {
|
||||
expect(getSession(store, "main").agentHarnessId).toBe("openclaw");
|
||||
expect(getSession(store, "main").agentRuntimeOverride).toBe("openclaw");
|
||||
expect(getSession(store, "main").authProfileOverride).toBe("openai:work");
|
||||
expect(getSession(store, "main").contextTokens).toBe(128_000);
|
||||
expect(getSession(store, "main").contextTokensSource).toBe("runtime");
|
||||
});
|
||||
|
||||
it("repairs legacy routes without probing OAuth readiness", () => {
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
// Sessions ACP runtime metadata tests cover session-owned runtime overlays.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveModelAgentRuntimeMetadata } from "../agents/agent-runtime-metadata.js";
|
||||
import {
|
||||
resolveCurrentSessionAgentRuntimeMetadata,
|
||||
resolveModelAgentRuntimeMetadata,
|
||||
} from "../agents/agent-runtime-metadata.js";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import { parseAgentSessionKey } from "../routing/session-key.js";
|
||||
|
||||
@@ -93,4 +96,43 @@ describe("session ACP runtime metadata", () => {
|
||||
|
||||
expect(agentRuntime).toEqual({ id: "codex", source: "session" });
|
||||
});
|
||||
|
||||
it("reports current model policy instead of an unlocked historical producer", () => {
|
||||
const agentRuntime = resolveCurrentSessionAgentRuntimeMetadata({
|
||||
cfg: {
|
||||
agents: {
|
||||
defaults: {
|
||||
models: {
|
||||
"openai/gpt-5.6-sol": { agentRuntime: { id: "codex" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
agentId: "main",
|
||||
provider: "openai",
|
||||
model: "gpt-5.6-sol",
|
||||
sessionKey: NON_ACP_SESSION_KEY,
|
||||
sessionEntry: {
|
||||
agentHarnessId: "openclaw",
|
||||
},
|
||||
});
|
||||
|
||||
expect(agentRuntime).toEqual({ id: "codex", source: "model" });
|
||||
});
|
||||
|
||||
it("keeps an explicit compatible runtime override", () => {
|
||||
const agentRuntime = resolveCurrentSessionAgentRuntimeMetadata({
|
||||
cfg: buildConfigWithoutAgentRuntimePolicy(),
|
||||
agentId: "main",
|
||||
provider: "openai",
|
||||
model: "gpt-5.6-sol",
|
||||
sessionKey: NON_ACP_SESSION_KEY,
|
||||
sessionEntry: {
|
||||
agentHarnessId: "openclaw",
|
||||
agentRuntimeOverride: "codex",
|
||||
},
|
||||
});
|
||||
|
||||
expect(agentRuntime).toEqual({ id: "codex", source: "session-key" });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -24,6 +24,7 @@ type SessionsJsonPayload = {
|
||||
modelProvider?: string | null;
|
||||
model?: string | null;
|
||||
agentRuntime?: { id: string; source: string };
|
||||
contextTokens?: number | null;
|
||||
}>;
|
||||
};
|
||||
|
||||
@@ -200,4 +201,153 @@ describe("sessionsCommand model resolution", () => {
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("projects current runtime and context after a same-model harness change", async () => {
|
||||
setMockSessionsConfig(() => ({
|
||||
agents: {
|
||||
defaults: {
|
||||
model: { primary: "openai/gpt-5.6-sol" },
|
||||
models: {
|
||||
"openai/gpt-5.6-sol": { agentRuntime: { id: "codex" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
models: {
|
||||
providers: {
|
||||
openai: {
|
||||
models: [{ id: "gpt-5.6-sol", contextTokens: 1_000_000, contextWindow: 1_050_000 }],
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
await withSqliteStore(
|
||||
"sessions-current-runtime-context",
|
||||
{
|
||||
"agent:main:main": {
|
||||
sessionId: "stale-openclaw-window",
|
||||
updatedAt: Date.now() - 60_000,
|
||||
modelProvider: "openai",
|
||||
model: "gpt-5.6-sol",
|
||||
agentHarnessId: "openclaw",
|
||||
contextTokens: 272_000,
|
||||
contextTokensSource: "runtime",
|
||||
},
|
||||
},
|
||||
async (store) => {
|
||||
const payload = await runSessionsJson<SessionsJsonPayload>(sessionsCommand, store);
|
||||
const session = payload.sessions?.find((row) => row.key === "agent:main:main");
|
||||
|
||||
expect(session?.agentRuntime).toEqual({ id: "codex", source: "model" });
|
||||
expect(session?.contextTokens).toBe(1_000_000);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps matching runtime telemetry below a higher native window", async () => {
|
||||
setMockSessionsConfig(() => ({
|
||||
agents: {
|
||||
defaults: {
|
||||
model: { primary: "openai/gpt-5.6-sol" },
|
||||
models: {
|
||||
"openai/gpt-5.6-sol": { agentRuntime: { id: "codex" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
models: {
|
||||
providers: {
|
||||
openai: { models: [{ id: "gpt-5.6-sol", contextWindow: 1_000_000 }] },
|
||||
},
|
||||
},
|
||||
}));
|
||||
await withSqliteStore(
|
||||
"sessions-matching-runtime-context",
|
||||
{
|
||||
"agent:main:main": {
|
||||
sessionId: "matching-codex-window",
|
||||
updatedAt: Date.now() - 60_000,
|
||||
modelProvider: "openai",
|
||||
model: "gpt-5.6-sol",
|
||||
agentHarnessId: "codex",
|
||||
contextTokens: 272_000,
|
||||
contextTokensSource: "runtime",
|
||||
},
|
||||
},
|
||||
async (store) => {
|
||||
const payload = await runSessionsJson<SessionsJsonPayload>(sessionsCommand, store);
|
||||
expect(payload.sessions?.[0]?.contextTokens).toBe(272_000);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps no-snapshot context resolution scoped to the selected provider", async () => {
|
||||
setMockSessionsConfig(() => ({
|
||||
agents: {
|
||||
defaults: {
|
||||
model: { primary: "provider-a/shared-model" },
|
||||
models: {
|
||||
"provider-a/shared-model": {},
|
||||
"provider-b/shared-model": {},
|
||||
},
|
||||
},
|
||||
},
|
||||
models: {
|
||||
providers: {
|
||||
"provider-a": { models: [{ id: "shared-model", contextTokens: 128_000 }] },
|
||||
"provider-b": { models: [{ id: "shared-model", contextTokens: 900_000 }] },
|
||||
},
|
||||
},
|
||||
}));
|
||||
await withSqliteStore(
|
||||
"sessions-provider-scoped-context",
|
||||
{
|
||||
"agent:main:main": {
|
||||
sessionId: "provider-a-context",
|
||||
updatedAt: Date.now() - 60_000,
|
||||
modelProvider: "provider-a",
|
||||
model: "shared-model",
|
||||
},
|
||||
},
|
||||
async (store) => {
|
||||
const payload = await runSessionsJson<SessionsJsonPayload>(sessionsCommand, store);
|
||||
expect(payload.sessions?.[0]?.contextTokens).toBe(128_000);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves a locked runtime window above current configuration", async () => {
|
||||
setMockSessionsConfig(() => ({
|
||||
agents: {
|
||||
defaults: {
|
||||
model: { primary: "openai/gpt-5.6-sol" },
|
||||
models: {
|
||||
"openai/gpt-5.6-sol": { agentRuntime: { id: "openclaw" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
models: {
|
||||
providers: {
|
||||
openai: { models: [{ id: "gpt-5.6-sol", contextTokens: 272_000 }] },
|
||||
},
|
||||
},
|
||||
}));
|
||||
await withSqliteStore(
|
||||
"sessions-locked-runtime-context",
|
||||
{
|
||||
"agent:main:main": {
|
||||
sessionId: "locked-codex-window",
|
||||
updatedAt: Date.now() - 60_000,
|
||||
modelProvider: "openai",
|
||||
model: "gpt-5.6-sol",
|
||||
agentHarnessId: "codex",
|
||||
contextTokens: 1_000_000,
|
||||
modelSelectionLocked: true,
|
||||
},
|
||||
},
|
||||
async (store) => {
|
||||
const payload = await runSessionsJson<SessionsJsonPayload>(sessionsCommand, store);
|
||||
expect(payload.sessions?.[0]?.agentRuntime).toEqual({ id: "codex", source: "session" });
|
||||
expect(payload.sessions?.[0]?.contextTokens).toBe(1_000_000);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -148,6 +148,52 @@ describe("sessionsCommand", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("renders current context after a same-model runtime change", async () => {
|
||||
setMockSessionsConfig(() => ({
|
||||
agents: {
|
||||
defaults: {
|
||||
model: { primary: "openai/gpt-5.6-sol" },
|
||||
models: {
|
||||
"openai/gpt-5.6-sol": { agentRuntime: { id: "codex" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
models: {
|
||||
providers: {
|
||||
openai: {
|
||||
models: [{ id: "gpt-5.6-sol", contextTokens: 1_000_000, contextWindow: 1_050_000 }],
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
const store = await writeStore(
|
||||
{
|
||||
"agent:main:main": {
|
||||
sessionId: "stale-openclaw-window",
|
||||
updatedAt: Date.now() - 60_000,
|
||||
modelProvider: "openai",
|
||||
model: "gpt-5.6-sol",
|
||||
agentHarnessId: "openclaw",
|
||||
contextTokens: 272_000,
|
||||
contextTokensSource: "runtime",
|
||||
totalTokens: 11,
|
||||
totalTokensFresh: true,
|
||||
totalTokensVersion: 1,
|
||||
},
|
||||
},
|
||||
"sessions-current-runtime-table",
|
||||
);
|
||||
|
||||
const { runtime, logs } = makeRuntime();
|
||||
await sessionsCommand({ store }, runtime);
|
||||
cleanupStore(store);
|
||||
|
||||
const row = logs.find((line) => line.includes("agent:main:main")) ?? "";
|
||||
expect(row).toContain("OpenAI Codex");
|
||||
expect(row).toContain("0.0k/1000k (0%)");
|
||||
expect(row).not.toContain("272k");
|
||||
});
|
||||
|
||||
it("shows placeholder rows when tokens are missing", async () => {
|
||||
const store = await writeStore({
|
||||
"agent:main:quietchat:group:demo": {
|
||||
|
||||
+49
-32
@@ -11,7 +11,8 @@ import {
|
||||
} from "@openclaw/normalization-core/string-coerce";
|
||||
import { isRich, theme } from "../../packages/terminal-core/src/theme.js";
|
||||
import { readAcpSessionMetaBatch } from "../acp/runtime/session-meta.js";
|
||||
import { resolveModelAgentRuntimeMetadata } from "../agents/agent-runtime-metadata.js";
|
||||
import { resolveCurrentSessionAgentRuntimeMetadata } from "../agents/agent-runtime-metadata.js";
|
||||
import { resolveAuthoredModelContextTokens } from "../agents/context-resolution.js";
|
||||
import { DEFAULT_CONTEXT_TOKENS } from "../agents/defaults.js";
|
||||
import {
|
||||
prepareCliProviderClassifier,
|
||||
@@ -21,6 +22,7 @@ import { resolveRuntimePolicySessionKey } from "../auto-reply/reply/runtime-poli
|
||||
import { normalizeChatType } from "../channels/chat-type.js";
|
||||
import { getRuntimeConfig } from "../config/config.js";
|
||||
import { resolveFreshSessionTotalTokens, resolveSessionTotalTokens } from "../config/sessions.js";
|
||||
import { resolveProjectedSessionContextTokens } from "../config/sessions/context-token-provenance.js";
|
||||
import { listSessionEntriesReadOnly } from "../config/sessions/session-accessor.js";
|
||||
import { resolveSqliteTargetFromSessionStorePath } from "../config/sessions/session-sqlite-target.js";
|
||||
import type { SessionEntry } from "../config/sessions/types.js";
|
||||
@@ -57,7 +59,7 @@ import {
|
||||
type SessionRow = SessionDisplayRow & {
|
||||
agentId: string;
|
||||
kind: SessionKind;
|
||||
agentRuntime: ReturnType<typeof resolveModelAgentRuntimeMetadata>;
|
||||
agentRuntime: ReturnType<typeof resolveCurrentSessionAgentRuntimeMetadata>;
|
||||
runtimeLabel: string;
|
||||
/** Carry the prepared identity into JSON/table emission without re-resolving plugin metadata. */
|
||||
displayModelRef: { provider: string; model: string };
|
||||
@@ -178,11 +180,6 @@ const formatTokensCell = (
|
||||
return colorByPct(padded, pct, rich);
|
||||
};
|
||||
|
||||
async function lookupContextTokensForDisplay(model: string): Promise<number | undefined> {
|
||||
const { lookupContextTokens } = await contextLookupRuntimeLoader.load();
|
||||
return lookupContextTokens(model, { allowAsyncLoad: false });
|
||||
}
|
||||
|
||||
const formatKindCell = (kind: SessionRow["kind"], rich: boolean) => {
|
||||
const label = kind.padEnd(KIND_PAD);
|
||||
if (!rich) {
|
||||
@@ -203,7 +200,7 @@ const formatKindCell = (kind: SessionRow["kind"], rich: boolean) => {
|
||||
function resolveSessionRuntimeLabel(params: {
|
||||
cfg: OpenClawConfig;
|
||||
entry: SessionEntry;
|
||||
agentRuntime: ReturnType<typeof resolveModelAgentRuntimeMetadata>;
|
||||
agentRuntime: ReturnType<typeof resolveCurrentSessionAgentRuntimeMetadata>;
|
||||
modelProvider: string;
|
||||
classifyCliProvider: CliProviderClassifier;
|
||||
}): string {
|
||||
@@ -322,8 +319,10 @@ export async function sessionsCommand(
|
||||
const aggregateAgents = opts.allAgents === true;
|
||||
const cfg = getRuntimeConfig();
|
||||
const displayDefaults = resolveSessionDisplayDefaults(cfg);
|
||||
const { lookupContextTokens, resolveContextTokensForModel } =
|
||||
await contextLookupRuntimeLoader.load();
|
||||
const configContextTokens =
|
||||
(await lookupContextTokensForDisplay(displayDefaults.model)) ?? DEFAULT_CONTEXT_TOKENS;
|
||||
lookupContextTokens(displayDefaults.model, { allowAsyncLoad: false }) ?? DEFAULT_CONTEXT_TOKENS;
|
||||
const targets = resolveSessionStoreTargetsOrExit({
|
||||
cfg,
|
||||
opts: {
|
||||
@@ -392,7 +391,7 @@ export async function sessionsCommand(
|
||||
acpSessionKey,
|
||||
acpRuntime,
|
||||
);
|
||||
const agentRuntime = resolveModelAgentRuntimeMetadata({
|
||||
const agentRuntime = resolveCurrentSessionAgentRuntimeMetadata({
|
||||
cfg,
|
||||
agentId,
|
||||
sessionEntry: entry,
|
||||
@@ -402,10 +401,37 @@ export async function sessionsCommand(
|
||||
acpRuntime,
|
||||
acpBackend: acpMeta?.backend,
|
||||
});
|
||||
const hasPersistedContextTokens =
|
||||
typeof entry.contextTokens === "number" && entry.contextTokens > 0;
|
||||
// CLI-backed rows can store a canonical display provider that does not own
|
||||
// the runtime's context policy, so retain their model-only offline fallback.
|
||||
const usesCliContextFallback =
|
||||
!hasPersistedContextTokens && classifyCliProvider(agentRuntime.id);
|
||||
const resolvedContextTokens = usesCliContextFallback
|
||||
? lookupContextTokens(modelRef.model, { allowAsyncLoad: false })
|
||||
: resolveContextTokensForModel({
|
||||
cfg,
|
||||
provider: modelRef.provider,
|
||||
model: modelRef.model,
|
||||
allowAsyncLoad: false,
|
||||
});
|
||||
const contextTokens = resolveProjectedSessionContextTokens({
|
||||
entry,
|
||||
provider: modelRef.provider,
|
||||
model: modelRef.model,
|
||||
agentHarnessId: agentRuntime.id,
|
||||
resolvedContextTokens,
|
||||
authoredContextTokens: resolveAuthoredModelContextTokens({
|
||||
cfg,
|
||||
provider: modelRef.provider,
|
||||
model: modelRef.model,
|
||||
}),
|
||||
});
|
||||
return Object.assign({}, row, {
|
||||
agentId,
|
||||
acpRuntime,
|
||||
agentRuntime,
|
||||
contextTokens,
|
||||
displayModelRef: modelRef,
|
||||
kind: classifySessionKind(row.key, entry),
|
||||
runtimePolicySessionKey: resolveDisplayRuntimePolicySessionKey({
|
||||
@@ -444,26 +470,18 @@ export async function sessionsCommand(
|
||||
limitApplied: limit ?? null,
|
||||
hasMore,
|
||||
activeMinutes: activeMinutes ?? null,
|
||||
sessions: await Promise.all(
|
||||
rows.map(async (row) => {
|
||||
const r = toJsonSessionRow(row);
|
||||
const modelRef = row.displayModelRef;
|
||||
return {
|
||||
...r,
|
||||
totalTokens: resolveSessionTotalTokens(r) ?? null,
|
||||
totalTokensFresh: resolveFreshSessionTotalTokens(r) !== undefined,
|
||||
// Prefer row-level context tokens, then config/model lookup, so JSON
|
||||
// mirrors the terminal percentage calculation.
|
||||
contextTokens:
|
||||
r.contextTokens ??
|
||||
(await lookupContextTokensForDisplay(modelRef.model)) ??
|
||||
configContextTokens ??
|
||||
null,
|
||||
modelProvider: modelRef.provider,
|
||||
model: modelRef.model,
|
||||
};
|
||||
}),
|
||||
),
|
||||
sessions: rows.map((row) => {
|
||||
const r = toJsonSessionRow(row);
|
||||
const modelRef = row.displayModelRef;
|
||||
return {
|
||||
...r,
|
||||
totalTokens: resolveSessionTotalTokens(r) ?? null,
|
||||
totalTokensFresh: resolveFreshSessionTotalTokens(r) !== undefined,
|
||||
contextTokens: r.contextTokens ?? configContextTokens ?? null,
|
||||
modelProvider: modelRef.provider,
|
||||
model: modelRef.model,
|
||||
};
|
||||
}),
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -508,8 +526,7 @@ export async function sessionsCommand(
|
||||
|
||||
for (const row of rows) {
|
||||
const model = row.displayModelRef.model;
|
||||
const contextTokens =
|
||||
row.contextTokens ?? (await lookupContextTokensForDisplay(model)) ?? configContextTokens;
|
||||
const contextTokens = row.contextTokens ?? configContextTokens;
|
||||
const total = resolveSessionTotalTokens(row);
|
||||
const freshTotal = resolveFreshSessionTotalTokens(row);
|
||||
|
||||
|
||||
@@ -4,10 +4,10 @@ import { ANTHROPIC_CONTEXT_1M_TOKENS } from "../agents/context-resolution.js";
|
||||
import { migratePersistedImplicitMainRoster } from "../config/legacy.roster.js";
|
||||
import { statusSummaryRuntime } from "../status/summary.runtime.js";
|
||||
|
||||
function resolveSessionRuntimeLabel(
|
||||
params: Parameters<typeof statusSummaryRuntime.resolveSessionRuntimeLabel>[0],
|
||||
function resolveSessionRuntime(
|
||||
params: Parameters<typeof statusSummaryRuntime.resolveSessionRuntime>[0],
|
||||
) {
|
||||
return statusSummaryRuntime.resolveSessionRuntimeLabel({
|
||||
return statusSummaryRuntime.resolveSessionRuntime({
|
||||
...params,
|
||||
cfg: migratePersistedImplicitMainRoster(params.cfg).config as never,
|
||||
});
|
||||
@@ -181,10 +181,10 @@ describe("statusSummaryRuntime.classifySessionKey", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("statusSummaryRuntime.resolveSessionRuntimeLabel", () => {
|
||||
describe("statusSummaryRuntime.resolveSessionRuntime", () => {
|
||||
it("uses the shared /status runtime label for the implicit OpenAI Codex route", () => {
|
||||
expect(
|
||||
resolveSessionRuntimeLabel({
|
||||
resolveSessionRuntime({
|
||||
cfg: {} as never,
|
||||
entry: {
|
||||
sessionId: "session-1",
|
||||
@@ -194,12 +194,12 @@ describe("statusSummaryRuntime.resolveSessionRuntimeLabel", () => {
|
||||
model: "gpt-5.5",
|
||||
sessionKey: "agent:main:main",
|
||||
}),
|
||||
).toBe("OpenAI Codex");
|
||||
).toEqual({ id: "codex", label: "OpenAI Codex" });
|
||||
});
|
||||
|
||||
it("preserves configured default model CLI runtimes", () => {
|
||||
expect(
|
||||
resolveSessionRuntimeLabel({
|
||||
resolveSessionRuntime({
|
||||
cfg: {
|
||||
agents: {
|
||||
defaults: {
|
||||
@@ -217,12 +217,12 @@ describe("statusSummaryRuntime.resolveSessionRuntimeLabel", () => {
|
||||
model: "claude-sonnet-4-6",
|
||||
sessionKey: "agent:main:main",
|
||||
}),
|
||||
).toBe("Claude CLI");
|
||||
).toEqual({ id: "claude-cli", label: "Claude CLI" });
|
||||
});
|
||||
|
||||
it("preserves configured agent model runtimes before harness selection", () => {
|
||||
expect(
|
||||
resolveSessionRuntimeLabel({
|
||||
resolveSessionRuntime({
|
||||
cfg: {
|
||||
agents: {
|
||||
defaults: {
|
||||
@@ -249,12 +249,36 @@ describe("statusSummaryRuntime.resolveSessionRuntimeLabel", () => {
|
||||
agentId: "research",
|
||||
sessionKey: "agent:research:main",
|
||||
}),
|
||||
).toBe("OpenAI Codex");
|
||||
).toEqual({ id: "codex", label: "OpenAI Codex" });
|
||||
});
|
||||
|
||||
it("does not treat an unlocked producing harness as the current runtime", () => {
|
||||
expect(
|
||||
resolveSessionRuntime({
|
||||
cfg: {
|
||||
agents: {
|
||||
defaults: {
|
||||
models: {
|
||||
"openai/gpt-5.5": { agentRuntime: { id: "codex" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
entry: {
|
||||
sessionId: "openclaw-produced-session",
|
||||
updatedAt: 0,
|
||||
agentHarnessId: "openclaw",
|
||||
},
|
||||
provider: "openai",
|
||||
model: "gpt-5.5",
|
||||
sessionKey: "agent:main:main",
|
||||
}),
|
||||
).toEqual({ id: "codex", label: "OpenAI Codex" });
|
||||
});
|
||||
|
||||
it("reports the owning Codex harness for a locked session with stale OpenClaw metadata", () => {
|
||||
expect(
|
||||
resolveSessionRuntimeLabel({
|
||||
resolveSessionRuntime({
|
||||
cfg: {
|
||||
agents: {
|
||||
defaults: {
|
||||
@@ -275,7 +299,7 @@ describe("statusSummaryRuntime.resolveSessionRuntimeLabel", () => {
|
||||
model: "gpt-5.5",
|
||||
sessionKey: "agent:main:main",
|
||||
}),
|
||||
).toBe("OpenAI Codex");
|
||||
).toEqual({ id: "codex", label: "OpenAI Codex" });
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
/** Shared status-summary cases for session runtime and context-window projection. */
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { SESSION_TOTAL_TOKENS_VERSION } from "../config/sessions/types.js";
|
||||
|
||||
type GetStatusSummary = typeof import("../status/summary.js").getStatusSummary;
|
||||
type StatusSummaryRuntime = typeof import("../status/summary.runtime.js").statusSummaryRuntime;
|
||||
type SessionStore = Record<string, Record<string, unknown>>;
|
||||
|
||||
export function registerStatusSummarySessionRowCases(params: {
|
||||
getStatusSummary: () => ReturnType<GetStatusSummary>;
|
||||
getStatusSummaryRuntime: () => StatusSummaryRuntime;
|
||||
rejectProviderStaticModel: (error: Error) => void;
|
||||
setSessions: (store: SessionStore) => void;
|
||||
}): void {
|
||||
describe("status summary session rows", () => {
|
||||
it("keeps status available when static catalog lookup fails", async () => {
|
||||
vi.mocked(
|
||||
params.getStatusSummaryRuntime().resolveConfiguredStatusModelRef,
|
||||
).mockReturnValueOnce({
|
||||
provider: "broken-provider",
|
||||
model: "broken-model",
|
||||
});
|
||||
params.rejectProviderStaticModel(new Error("static catalog unavailable"));
|
||||
|
||||
await expect(params.getStatusSummary()).resolves.toMatchObject({
|
||||
sessions: {
|
||||
defaults: {
|
||||
model: "broken-model",
|
||||
contextTokens: 200_000,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("includes the selected agent runtime on recent sessions", async () => {
|
||||
vi.mocked(params.getStatusSummaryRuntime().resolveSessionRuntime).mockReturnValue({
|
||||
id: "codex",
|
||||
label: "OpenAI Codex",
|
||||
});
|
||||
params.setSessions({
|
||||
"agent:main:main": {
|
||||
sessionId: "session-1",
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
});
|
||||
|
||||
const summary = await params.getStatusSummary();
|
||||
|
||||
expect(summary.sessions.recent[0]?.runtime).toBe("OpenAI Codex");
|
||||
});
|
||||
|
||||
it("rejects a stale runtime window after a same-model harness change", async () => {
|
||||
vi.mocked(params.getStatusSummaryRuntime().resolveContextTokensForModel).mockReturnValue(
|
||||
1_000_000,
|
||||
);
|
||||
vi.mocked(params.getStatusSummaryRuntime().resolveSessionRuntime).mockReturnValue({
|
||||
id: "codex",
|
||||
label: "OpenAI Codex",
|
||||
});
|
||||
params.setSessions({
|
||||
"agent:main:main": {
|
||||
sessionId: "same-model-runtime-change",
|
||||
updatedAt: Date.now(),
|
||||
modelProvider: "openai",
|
||||
model: "gpt-5.5",
|
||||
agentHarnessId: "openclaw",
|
||||
contextTokens: 272_000,
|
||||
contextTokensSource: "runtime",
|
||||
totalTokens: 11,
|
||||
totalTokensFresh: true,
|
||||
totalTokensVersion: SESSION_TOTAL_TOKENS_VERSION,
|
||||
},
|
||||
});
|
||||
|
||||
const summary = await params.getStatusSummary();
|
||||
|
||||
expect(summary.sessions.recent[0]).toMatchObject({
|
||||
runtime: "OpenAI Codex",
|
||||
contextTokens: 1_000_000,
|
||||
remainingTokens: 999_989,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps telemetry from the matching runtime producer", async () => {
|
||||
vi.mocked(params.getStatusSummaryRuntime().resolveContextTokensForModel).mockReturnValue(
|
||||
1_000_000,
|
||||
);
|
||||
params.setSessions({
|
||||
"agent:main:main": {
|
||||
sessionId: "matching-runtime-window",
|
||||
updatedAt: Date.now(),
|
||||
modelProvider: "openai",
|
||||
model: "gpt-5.5",
|
||||
agentHarnessId: "openclaw",
|
||||
contextTokens: 272_000,
|
||||
contextTokensSource: "runtime",
|
||||
totalTokens: 11,
|
||||
totalTokensFresh: true,
|
||||
totalTokensVersion: SESSION_TOTAL_TOKENS_VERSION,
|
||||
},
|
||||
});
|
||||
|
||||
const summary = await params.getStatusSummary();
|
||||
|
||||
expect(summary.sessions.recent[0]?.contextTokens).toBe(272_000);
|
||||
});
|
||||
|
||||
it("replaces matching runtime telemetry with a newly authored effective cap", async () => {
|
||||
vi.mocked(params.getStatusSummaryRuntime().resolveAuthoredModelContextTokens).mockReturnValue(
|
||||
1_000_000,
|
||||
);
|
||||
vi.mocked(params.getStatusSummaryRuntime().resolveContextTokensForModel).mockReturnValue(
|
||||
1_000_000,
|
||||
);
|
||||
params.setSessions({
|
||||
"agent:main:main": {
|
||||
sessionId: "authored-context-cap",
|
||||
updatedAt: Date.now(),
|
||||
modelProvider: "openai",
|
||||
model: "gpt-5.5",
|
||||
agentHarnessId: "openclaw",
|
||||
contextTokens: 272_000,
|
||||
contextTokensSource: "runtime",
|
||||
},
|
||||
});
|
||||
|
||||
const summary = await params.getStatusSummary();
|
||||
|
||||
expect(summary.sessions.recent[0]?.contextTokens).toBe(1_000_000);
|
||||
});
|
||||
|
||||
it("preserves the native window owned by a locked legacy session", async () => {
|
||||
vi.mocked(params.getStatusSummaryRuntime().resolveContextTokensForModel).mockReturnValue(
|
||||
272_000,
|
||||
);
|
||||
vi.mocked(params.getStatusSummaryRuntime().resolveSessionRuntime).mockReturnValue({
|
||||
id: "codex",
|
||||
label: "OpenAI Codex",
|
||||
});
|
||||
params.setSessions({
|
||||
"agent:main:main": {
|
||||
sessionId: "locked-legacy-window",
|
||||
updatedAt: Date.now(),
|
||||
modelSelectionLocked: true,
|
||||
contextTokens: 1_000_000,
|
||||
},
|
||||
});
|
||||
|
||||
const summary = await params.getStatusSummary();
|
||||
|
||||
expect(summary.sessions.recent[0]?.contextTokens).toBe(1_000_000);
|
||||
});
|
||||
|
||||
it("caps matching unlocked runtime telemetry to the lower current window", async () => {
|
||||
vi.mocked(params.getStatusSummaryRuntime().resolveContextTokensForModel).mockReturnValue(
|
||||
272_000,
|
||||
);
|
||||
vi.mocked(params.getStatusSummaryRuntime().resolveSessionRuntime).mockReturnValue({
|
||||
id: "codex",
|
||||
label: "OpenAI Codex",
|
||||
});
|
||||
params.setSessions({
|
||||
"agent:main:main": {
|
||||
sessionId: "unlocked-runtime-window",
|
||||
updatedAt: Date.now(),
|
||||
modelProvider: "openai",
|
||||
model: "gpt-5.5",
|
||||
agentHarnessId: "codex",
|
||||
contextTokens: 1_000_000,
|
||||
contextTokensSource: "runtime",
|
||||
},
|
||||
});
|
||||
|
||||
const summary = await params.getStatusSummary();
|
||||
|
||||
expect(summary.sessions.recent[0]?.contextTokens).toBe(272_000);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -27,3 +27,4 @@ export * from "./sessions/delivery-info.js";
|
||||
export * from "./sessions/disk-budget.js";
|
||||
export * from "./sessions/targets.js";
|
||||
export * from "./sessions/cleanup-service.js";
|
||||
export * from "./sessions/context-token-provenance.js";
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
resolveProjectedSessionContextTokens,
|
||||
resolveTrustedSessionContextTokens,
|
||||
} from "./context-token-provenance.js";
|
||||
|
||||
const currentSelection = {
|
||||
provider: "openai",
|
||||
model: "gpt-5.6-sol",
|
||||
agentHarnessId: "codex",
|
||||
};
|
||||
|
||||
describe("resolveTrustedSessionContextTokens", () => {
|
||||
it("trusts only runtime telemetry from the exact producing selection", () => {
|
||||
expect(
|
||||
resolveTrustedSessionContextTokens({
|
||||
entry: {
|
||||
modelProvider: "OpenAI",
|
||||
model: "GPT-5.6-SOL",
|
||||
agentHarnessId: "Codex",
|
||||
contextTokens: 272_000,
|
||||
contextTokensSource: "runtime",
|
||||
},
|
||||
...currentSelection,
|
||||
}),
|
||||
).toBe(272_000);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ name: "missing source", patch: { contextTokensSource: undefined } },
|
||||
{ name: "resolved source", patch: { contextTokensSource: "resolved" as const } },
|
||||
{
|
||||
name: "runtime-configured source",
|
||||
patch: { contextTokensSource: "runtime-configured" as const },
|
||||
},
|
||||
{ name: "missing harness", patch: { agentHarnessId: undefined } },
|
||||
{ name: "different harness", patch: { agentHarnessId: "openclaw" } },
|
||||
{ name: "different provider", patch: { modelProvider: "openrouter" } },
|
||||
{ name: "different model", patch: { model: "gpt-5.5" } },
|
||||
])("rejects $name", ({ patch }) => {
|
||||
expect(
|
||||
resolveTrustedSessionContextTokens({
|
||||
entry: {
|
||||
modelProvider: "openai",
|
||||
model: "gpt-5.6-sol",
|
||||
agentHarnessId: "codex",
|
||||
contextTokens: 272_000,
|
||||
contextTokensSource: "runtime",
|
||||
...patch,
|
||||
},
|
||||
...currentSelection,
|
||||
}),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("preserves the native window owned by a locked legacy session", () => {
|
||||
expect(
|
||||
resolveTrustedSessionContextTokens({
|
||||
entry: {
|
||||
modelSelectionLocked: true,
|
||||
contextTokens: 272_000,
|
||||
},
|
||||
...currentSelection,
|
||||
}),
|
||||
).toBe(272_000);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ name: "provider", patch: { modelProvider: "openrouter" } },
|
||||
{ name: "model", patch: { model: "gpt-5.5" } },
|
||||
])("rejects a locked window owned by a different $name", ({ patch }) => {
|
||||
expect(
|
||||
resolveTrustedSessionContextTokens({
|
||||
entry: {
|
||||
modelProvider: "openai",
|
||||
model: "gpt-5.6-sol",
|
||||
modelSelectionLocked: true,
|
||||
contextTokens: 272_000,
|
||||
...patch,
|
||||
},
|
||||
...currentSelection,
|
||||
}),
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveProjectedSessionContextTokens", () => {
|
||||
const matchingRuntimeEntry = {
|
||||
modelProvider: "openai",
|
||||
model: "gpt-5.6-sol",
|
||||
agentHarnessId: "codex",
|
||||
contextTokens: 272_000,
|
||||
contextTokensSource: "runtime" as const,
|
||||
};
|
||||
|
||||
it("uses an authored effective cap instead of older matching telemetry", () => {
|
||||
expect(
|
||||
resolveProjectedSessionContextTokens({
|
||||
entry: matchingRuntimeEntry,
|
||||
...currentSelection,
|
||||
resolvedContextTokens: 1_000_000,
|
||||
authoredContextTokens: 1_000_000,
|
||||
}),
|
||||
).toBe(1_000_000);
|
||||
});
|
||||
|
||||
it("keeps matching runtime telemetry below a higher native window", () => {
|
||||
expect(
|
||||
resolveProjectedSessionContextTokens({
|
||||
entry: matchingRuntimeEntry,
|
||||
...currentSelection,
|
||||
resolvedContextTokens: 1_000_000,
|
||||
}),
|
||||
).toBe(272_000);
|
||||
});
|
||||
|
||||
it("falls back to current resolution when producer provenance differs", () => {
|
||||
expect(
|
||||
resolveProjectedSessionContextTokens({
|
||||
entry: { ...matchingRuntimeEntry, agentHarnessId: "openclaw" },
|
||||
...currentSelection,
|
||||
resolvedContextTokens: 1_000_000,
|
||||
}),
|
||||
).toBe(1_000_000);
|
||||
});
|
||||
|
||||
it("preserves a locked native window ahead of current configuration", () => {
|
||||
expect(
|
||||
resolveProjectedSessionContextTokens({
|
||||
entry: {
|
||||
modelSelectionLocked: true,
|
||||
contextTokens: 1_000_000,
|
||||
},
|
||||
...currentSelection,
|
||||
resolvedContextTokens: 272_000,
|
||||
authoredContextTokens: 272_000,
|
||||
}),
|
||||
).toBe(1_000_000);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce";
|
||||
import type { SessionEntry } from "./types.js";
|
||||
|
||||
type SessionContextTokenOwner = Pick<
|
||||
SessionEntry,
|
||||
| "agentHarnessId"
|
||||
| "contextTokens"
|
||||
| "contextTokensSource"
|
||||
| "model"
|
||||
| "modelProvider"
|
||||
| "modelSelectionLocked"
|
||||
>;
|
||||
|
||||
function resolvePositiveContextTokens(value: number | null | undefined): number | undefined {
|
||||
return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : undefined;
|
||||
}
|
||||
|
||||
/** Returns persisted telemetry only when it belongs to the current producing selection. */
|
||||
export function resolveTrustedSessionContextTokens(params: {
|
||||
entry: SessionContextTokenOwner | undefined;
|
||||
provider: string | null | undefined;
|
||||
model: string | null | undefined;
|
||||
agentHarnessId: string | null | undefined;
|
||||
}): number | undefined {
|
||||
const contextTokens = resolvePositiveContextTokens(params.entry?.contextTokens);
|
||||
if (contextTokens === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
const entryProvider = normalizeLowercaseStringOrEmpty(params.entry?.modelProvider);
|
||||
const entryModel = normalizeLowercaseStringOrEmpty(params.entry?.model);
|
||||
const currentProvider = normalizeLowercaseStringOrEmpty(params.provider);
|
||||
const currentModel = normalizeLowercaseStringOrEmpty(params.model);
|
||||
// Locked sessions own their native window, including rows created before
|
||||
// context-window provenance was persisted. A known selection mismatch is a
|
||||
// different owner, while missing identity remains a supported legacy state.
|
||||
if (params.entry?.modelSelectionLocked === true) {
|
||||
if (
|
||||
(entryProvider && currentProvider && entryProvider !== currentProvider) ||
|
||||
(entryModel && currentModel && entryModel !== currentModel)
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
return contextTokens;
|
||||
}
|
||||
if (params.entry?.contextTokensSource !== "runtime") {
|
||||
return undefined;
|
||||
}
|
||||
const entryHarness = normalizeLowercaseStringOrEmpty(params.entry.agentHarnessId);
|
||||
const currentHarness = normalizeLowercaseStringOrEmpty(params.agentHarnessId);
|
||||
if (
|
||||
!entryProvider ||
|
||||
!entryModel ||
|
||||
!entryHarness ||
|
||||
!currentProvider ||
|
||||
!currentModel ||
|
||||
!currentHarness
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
return entryProvider === currentProvider &&
|
||||
entryModel === currentModel &&
|
||||
entryHarness === currentHarness
|
||||
? contextTokens
|
||||
: undefined;
|
||||
}
|
||||
|
||||
/** Projects the context window owned by the current session selection. */
|
||||
export function resolveProjectedSessionContextTokens(params: {
|
||||
entry: SessionContextTokenOwner | undefined;
|
||||
provider: string | null | undefined;
|
||||
model: string | null | undefined;
|
||||
agentHarnessId: string | null | undefined;
|
||||
resolvedContextTokens: number | null | undefined;
|
||||
authoredContextTokens?: number | null | undefined;
|
||||
}): number | undefined {
|
||||
const resolvedContextTokens = resolvePositiveContextTokens(params.resolvedContextTokens);
|
||||
const authoredContextTokens = resolvePositiveContextTokens(params.authoredContextTokens);
|
||||
const trustedContextTokens = resolveTrustedSessionContextTokens(params);
|
||||
// An authored effective cap owns the current selection. Otherwise current
|
||||
// model capacity only constrains telemetry from that exact producer tuple.
|
||||
const currentContextTokens =
|
||||
authoredContextTokens !== undefined
|
||||
? resolvedContextTokens
|
||||
: trustedContextTokens !== undefined && resolvedContextTokens !== undefined
|
||||
? Math.min(trustedContextTokens, resolvedContextTokens)
|
||||
: (trustedContextTokens ?? resolvedContextTokens);
|
||||
return params.entry?.modelSelectionLocked === true
|
||||
? (trustedContextTokens ?? currentContextTokens)
|
||||
: currentContextTokens;
|
||||
}
|
||||
@@ -86,6 +86,7 @@ async function createSession(options: { activeLeafTarget?: string } = {}) {
|
||||
cliSessionIds: { "claude-cli": "claude-conversation" },
|
||||
compactionCount: 2,
|
||||
contextTokens: 100_000,
|
||||
contextTokensSource: "runtime",
|
||||
createdVia: "operator",
|
||||
createdActor: { type: "human", id: "profile-1" },
|
||||
createdAt: 1_000,
|
||||
@@ -488,6 +489,7 @@ describe("SQLite session message cuts", () => {
|
||||
cliSessionIds: undefined,
|
||||
compactionCount: undefined,
|
||||
contextTokens: undefined,
|
||||
contextTokensSource: undefined,
|
||||
createdVia: "operator",
|
||||
createdActor: { type: "human", id: "profile-1" },
|
||||
createdAt: 1_000,
|
||||
|
||||
@@ -524,6 +524,7 @@ function cloneMessageCutSessionEntry(params: {
|
||||
// A rotated transcript cannot resume provider/runtime identity from the old tail.
|
||||
// Clear transcript-derived accounting too so the next turn rebuilds canonical state.
|
||||
contextTokens: undefined,
|
||||
contextTokensSource: undefined,
|
||||
contextBudgetStatus: undefined,
|
||||
compactionCount: undefined,
|
||||
compactionCheckpoints: undefined,
|
||||
|
||||
@@ -167,6 +167,7 @@ describe("session snapshot merge", () => {
|
||||
reason: "rate_limit",
|
||||
},
|
||||
contextTokens: 100_000,
|
||||
contextTokensSource: "resolved",
|
||||
contextBudgetStatus: {
|
||||
schemaVersion: 1,
|
||||
source: "pre-prompt-estimate",
|
||||
@@ -195,6 +196,7 @@ describe("session snapshot merge", () => {
|
||||
model: undefined,
|
||||
fallbackNotice: undefined,
|
||||
contextTokens: undefined,
|
||||
contextTokensSource: undefined,
|
||||
contextBudgetStatus: undefined,
|
||||
};
|
||||
const current: SessionEntry = {
|
||||
@@ -208,6 +210,7 @@ describe("session snapshot merge", () => {
|
||||
activeModel: "openai/gpt-5.4-nano",
|
||||
},
|
||||
contextTokens: 80_000,
|
||||
contextTokensSource: "runtime",
|
||||
};
|
||||
|
||||
const merged = mergeSessionSnapshotChanges({ initial: initialOverride, next, current });
|
||||
@@ -221,6 +224,7 @@ describe("session snapshot merge", () => {
|
||||
expect(merged.model).toBeUndefined();
|
||||
expect(merged.fallbackNotice).toBeUndefined();
|
||||
expect(merged.contextTokens).toBeUndefined();
|
||||
expect(merged.contextTokensSource).toBeUndefined();
|
||||
expect(merged.contextBudgetStatus).toBeUndefined();
|
||||
});
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ const MODEL_OVERRIDE_RUNTIME_FIELDS = [
|
||||
"model",
|
||||
"fallbackNotice",
|
||||
"contextTokens",
|
||||
"contextTokensSource",
|
||||
"contextBudgetStatus",
|
||||
] as const satisfies ReadonlyArray<keyof SessionEntry>;
|
||||
const MODEL_OVERRIDE_RUNTIME_FIELD_SET = new Set<keyof SessionEntry>(MODEL_OVERRIDE_RUNTIME_FIELDS);
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -5,10 +5,15 @@ import {
|
||||
} from "@openclaw/normalization-core/number-coercion";
|
||||
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
|
||||
import { hasAcceptedSessionSpawn } from "../../agents/accepted-session-spawn.js";
|
||||
import { resolveAuthoredModelContextTokens } from "../../agents/context-resolution.js";
|
||||
import { hasCommittedMessagingToolDeliveryEvidence } from "../../agents/embedded-agent-runner/delivery-evidence.js";
|
||||
import { deriveContextPromptTokens } from "../../agents/usage.js";
|
||||
import { isSilentReplyPayloadText } from "../../auto-reply/tokens.js";
|
||||
import { SESSION_TOTAL_TOKENS_VERSION } from "../../config/sessions.js";
|
||||
import {
|
||||
resolveProjectedSessionContextTokens,
|
||||
resolveTrustedSessionContextTokens,
|
||||
} from "../../config/sessions/context-token-provenance.js";
|
||||
import { emitTrustedDiagnosticEvent, isDiagnosticsEnabled } from "../../infra/diagnostic-events.js";
|
||||
import {
|
||||
createChildDiagnosticTraceContext,
|
||||
@@ -26,13 +31,16 @@ import { resolveCronChannelOutputPolicy } from "./channel-output-policy.js";
|
||||
import { resolveCronPayloadOutcome } from "./helpers.js";
|
||||
import { buildCronDeliveryTrace, loadCronDeliveryRuntime } from "./run-delivery-trace.js";
|
||||
import type { PreparedCronRunContext } from "./run-prepare.js";
|
||||
import { adoptCronRunSessionMetadata } from "./run-session-state.js";
|
||||
import {
|
||||
adoptCronRunSessionMetadata,
|
||||
setCronSessionAgentHarnessId,
|
||||
setCronSessionRuntimeModel,
|
||||
} from "./run-session-state.js";
|
||||
import {
|
||||
DEFAULT_CONTEXT_TOKENS,
|
||||
deriveSessionTotalTokens,
|
||||
hasNonzeroUsage,
|
||||
isCliProvider,
|
||||
setSessionRuntimeModel,
|
||||
} from "./run.runtime.js";
|
||||
import type { RunCronAgentTurnResult } from "./run.types.js";
|
||||
import { cleanupCronRunSessionAfterRun } from "./session-cleanup.js";
|
||||
@@ -91,22 +99,62 @@ export async function finalizeCronRun(params: {
|
||||
finalRunResult.meta?.agentMeta?.provider ??
|
||||
execution.fallbackProvider ??
|
||||
execution.liveSelection.provider;
|
||||
const contextTokens =
|
||||
(await cronContextRuntimeLoader.load()).resolveContextTokensForModel({
|
||||
cfg: prepared.cfgWithAgentDefaults,
|
||||
provider: providerUsed,
|
||||
model: modelUsed,
|
||||
allowAsyncLoad: false,
|
||||
}) ??
|
||||
resolvePositiveContextTokens(prepared.cronSession.sessionEntry.contextTokens) ??
|
||||
DEFAULT_CONTEXT_TOKENS;
|
||||
const runtimeContextTokens = resolvePositiveContextTokens(
|
||||
finalRunResult.meta?.agentMeta?.contextTokens,
|
||||
);
|
||||
const modelContextTokens = (await cronContextRuntimeLoader.load()).resolveContextTokensForModel({
|
||||
cfg: prepared.cfgWithAgentDefaults,
|
||||
provider: providerUsed,
|
||||
model: modelUsed,
|
||||
allowAsyncLoad: false,
|
||||
});
|
||||
const agentHarnessId = normalizeOptionalString(finalRunResult.meta?.agentMeta?.agentHarnessId);
|
||||
const authoredContextTokens = resolveAuthoredModelContextTokens({
|
||||
cfg: prepared.cfgWithAgentDefaults,
|
||||
provider: providerUsed,
|
||||
model: modelUsed,
|
||||
});
|
||||
const retainedRuntimeContextTokens = resolveTrustedSessionContextTokens({
|
||||
entry: prepared.cronSession.sessionEntry,
|
||||
provider: providerUsed,
|
||||
model: modelUsed,
|
||||
agentHarnessId,
|
||||
});
|
||||
const projectedContextTokens = resolveProjectedSessionContextTokens({
|
||||
entry: prepared.cronSession.sessionEntry,
|
||||
provider: providerUsed,
|
||||
model: modelUsed,
|
||||
agentHarnessId,
|
||||
resolvedContextTokens: modelContextTokens,
|
||||
authoredContextTokens,
|
||||
});
|
||||
const contextTokens = runtimeContextTokens ?? projectedContextTokens ?? DEFAULT_CONTEXT_TOKENS;
|
||||
// Preserve persisted provenance only when the projector selected that owner;
|
||||
// a current/authored clamp stays resolved so removed caps cannot stick.
|
||||
const projectedUsesPersistedContext =
|
||||
retainedRuntimeContextTokens !== undefined &&
|
||||
(prepared.cronSession.sessionEntry.modelSelectionLocked === true ||
|
||||
(authoredContextTokens === undefined &&
|
||||
projectedContextTokens === retainedRuntimeContextTokens));
|
||||
const contextTokensSource =
|
||||
runtimeContextTokens !== undefined
|
||||
? (finalRunResult.meta?.agentMeta?.contextTokensSource ?? "resolved")
|
||||
: projectedUsesPersistedContext
|
||||
? prepared.cronSession.sessionEntry.contextTokensSource
|
||||
: "resolved";
|
||||
|
||||
if (!params.isAborted()) {
|
||||
setSessionRuntimeModel(prepared.cronSession.sessionEntry, {
|
||||
setCronSessionRuntimeModel({
|
||||
entry: prepared.cronSession.sessionEntry,
|
||||
provider: providerUsed,
|
||||
model: modelUsed,
|
||||
});
|
||||
setCronSessionAgentHarnessId({
|
||||
entry: prepared.cronSession.sessionEntry,
|
||||
agentHarnessId,
|
||||
});
|
||||
prepared.cronSession.sessionEntry.contextTokens = contextTokens;
|
||||
prepared.cronSession.sessionEntry.contextTokensSource = contextTokensSource;
|
||||
if (isCliProvider(providerUsed, prepared.cfgWithAgentDefaults)) {
|
||||
const cliSessionBinding = finalRunResult.meta?.agentMeta?.cliSessionBinding;
|
||||
const cliSessionId = finalRunResult.meta?.agentMeta?.sessionId?.trim();
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
CronSessionLifecycleClaimError,
|
||||
createCronRunContinuationSession,
|
||||
createPersistCronSessionEntry,
|
||||
markCronSessionPreRun,
|
||||
resolveCronLifecycleRevisionIdentity,
|
||||
syncCronSessionLiveSelection,
|
||||
type MutableCronSession,
|
||||
@@ -66,7 +67,69 @@ function makeGuardedPersistSessionEntry(persistedStore: Record<string, SessionEn
|
||||
);
|
||||
}
|
||||
|
||||
describe("markCronSessionPreRun", () => {
|
||||
it("clears model-derived state when the selected model changes", () => {
|
||||
const entry = makeSessionEntry({
|
||||
modelProvider: "openai",
|
||||
model: "gpt-5.3",
|
||||
contextTokens: 272_000,
|
||||
contextTokensSource: "runtime",
|
||||
contextBudgetStatus: {} as NonNullable<SessionEntry["contextBudgetStatus"]>,
|
||||
});
|
||||
|
||||
markCronSessionPreRun({ entry, provider: "openai", model: "gpt-5.4" });
|
||||
|
||||
expect(entry.modelProvider).toBe("openai");
|
||||
expect(entry.model).toBe("gpt-5.4");
|
||||
expect(entry.contextTokens).toBeUndefined();
|
||||
expect(entry.contextTokensSource).toBeUndefined();
|
||||
expect(entry.contextBudgetStatus).toBeUndefined();
|
||||
});
|
||||
|
||||
it("preserves model-derived state when the selected model is unchanged", () => {
|
||||
const contextBudgetStatus = {} as NonNullable<SessionEntry["contextBudgetStatus"]>;
|
||||
const entry = makeSessionEntry({
|
||||
modelProvider: "openai",
|
||||
model: "gpt-5.4",
|
||||
contextTokens: 272_000,
|
||||
contextTokensSource: "runtime",
|
||||
contextBudgetStatus,
|
||||
});
|
||||
|
||||
markCronSessionPreRun({ entry, provider: "openai", model: "gpt-5.4" });
|
||||
|
||||
expect(entry.contextTokens).toBe(272_000);
|
||||
expect(entry.contextTokensSource).toBe("runtime");
|
||||
expect(entry.contextBudgetStatus).toBe(contextBudgetStatus);
|
||||
});
|
||||
});
|
||||
|
||||
describe("syncCronSessionLiveSelection", () => {
|
||||
it("clears model-derived state when only the agent runtime changes", () => {
|
||||
const entry = makeSessionEntry({
|
||||
modelProvider: "openai",
|
||||
model: "gpt-5.6-luna",
|
||||
agentRuntimeOverride: "openclaw",
|
||||
contextTokens: 272_000,
|
||||
contextTokensSource: "runtime",
|
||||
contextBudgetStatus: {} as NonNullable<SessionEntry["contextBudgetStatus"]>,
|
||||
});
|
||||
|
||||
syncCronSessionLiveSelection({
|
||||
entry,
|
||||
liveSelection: {
|
||||
provider: "openai",
|
||||
model: "gpt-5.6-luna",
|
||||
agentRuntimeOverride: "codex",
|
||||
},
|
||||
});
|
||||
|
||||
expect(entry.agentRuntimeOverride).toBe("codex");
|
||||
expect(entry.contextTokens).toBeUndefined();
|
||||
expect(entry.contextTokensSource).toBeUndefined();
|
||||
expect(entry.contextBudgetStatus).toBeUndefined();
|
||||
});
|
||||
|
||||
it("stamps a source-less live profile as a user pin", () => {
|
||||
const entry = makeSessionEntry({
|
||||
compactionCount: 4,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/** Mutates and persists isolated cron session state around one run. */
|
||||
import { isDeepStrictEqual } from "node:util";
|
||||
import { normalizeOptionalAgentRuntimeId } from "../../agents/agent-runtime-id.js";
|
||||
import { clearBootstrapSnapshotOnSessionBoundary } from "../../agents/bootstrap-cache.js";
|
||||
import type { LiveSessionModelSelection } from "../../agents/live-model-switch.js";
|
||||
import type { SessionEntry } from "../../config/sessions.js";
|
||||
@@ -18,10 +19,17 @@ import type {
|
||||
CronScheduledToolCallerOrigin,
|
||||
CronScheduledToolPolicy,
|
||||
} from "../scheduled-tool-policy.js";
|
||||
import { setSessionRuntimeModel } from "./run.runtime.js";
|
||||
import type { resolveCronSession } from "./session.js";
|
||||
|
||||
type MutableSessionStore = Record<string, SessionEntry>;
|
||||
|
||||
function clearCronContextOwnerState(entry: SessionEntry) {
|
||||
delete entry.contextTokens;
|
||||
delete entry.contextTokensSource;
|
||||
delete entry.contextBudgetStatus;
|
||||
}
|
||||
|
||||
/** Mutable cron session entry updated by an isolated run before persistence. */
|
||||
type MutableCronSessionEntry = SessionEntry;
|
||||
/** Resolved cron session plus its mutable backing store and active entry. */
|
||||
@@ -380,14 +388,50 @@ export async function persistCronSkillsSnapshotIfChanged(params: {
|
||||
await params.persistSessionEntry();
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the cron selection and drops facts produced by the previous model.
|
||||
* Keeping those facts after the owner tuple changes lets a later run relabel stale telemetry.
|
||||
*/
|
||||
export function setCronSessionRuntimeModel(params: {
|
||||
entry: MutableCronSessionEntry;
|
||||
provider: string;
|
||||
model: string;
|
||||
}) {
|
||||
const provider = params.provider.trim();
|
||||
const model = params.model.trim();
|
||||
if (!provider || !model) {
|
||||
return false;
|
||||
}
|
||||
const selectionChanged =
|
||||
params.entry.modelProvider?.trim() !== provider || params.entry.model?.trim() !== model;
|
||||
if (selectionChanged) {
|
||||
clearCronContextOwnerState(params.entry);
|
||||
}
|
||||
setSessionRuntimeModel(params.entry, { provider, model });
|
||||
return selectionChanged;
|
||||
}
|
||||
|
||||
/** Updates the producing harness and drops context facts owned by the previous runtime. */
|
||||
export function setCronSessionAgentHarnessId(params: {
|
||||
entry: MutableCronSessionEntry;
|
||||
agentHarnessId: string | undefined;
|
||||
}) {
|
||||
const previousRuntime = normalizeOptionalAgentRuntimeId(params.entry.agentHarnessId);
|
||||
const nextRuntime = normalizeOptionalAgentRuntimeId(params.agentHarnessId);
|
||||
if (previousRuntime !== nextRuntime) {
|
||||
clearCronContextOwnerState(params.entry);
|
||||
}
|
||||
params.entry.agentHarnessId = params.agentHarnessId;
|
||||
return previousRuntime !== nextRuntime;
|
||||
}
|
||||
|
||||
/** Records the selected provider/model before a cron run starts. */
|
||||
export function markCronSessionPreRun(params: {
|
||||
entry: MutableCronSessionEntry;
|
||||
provider: string;
|
||||
model: string;
|
||||
}) {
|
||||
params.entry.modelProvider = params.provider;
|
||||
params.entry.model = params.model;
|
||||
setCronSessionRuntimeModel(params);
|
||||
params.entry.systemSent = true;
|
||||
}
|
||||
|
||||
@@ -396,8 +440,16 @@ export function syncCronSessionLiveSelection(params: {
|
||||
entry: MutableCronSessionEntry;
|
||||
liveSelection: CronLiveSelection;
|
||||
}) {
|
||||
params.entry.modelProvider = params.liveSelection.provider;
|
||||
params.entry.model = params.liveSelection.model;
|
||||
const previousRuntime = normalizeOptionalAgentRuntimeId(params.entry.agentRuntimeOverride);
|
||||
const nextRuntime = normalizeOptionalAgentRuntimeId(params.liveSelection.agentRuntimeOverride);
|
||||
setCronSessionRuntimeModel({
|
||||
entry: params.entry,
|
||||
provider: params.liveSelection.provider,
|
||||
model: params.liveSelection.model,
|
||||
});
|
||||
if (previousRuntime !== nextRuntime) {
|
||||
clearCronContextOwnerState(params.entry);
|
||||
}
|
||||
if (params.liveSelection.agentRuntimeOverride) {
|
||||
params.entry.agentRuntimeOverride = params.liveSelection.agentRuntimeOverride;
|
||||
} else {
|
||||
|
||||
@@ -329,6 +329,11 @@ describe("runCronIsolatedAgentTurn — LiveSessionModelSwitchError retry (#57206
|
||||
model: "gpt-5.6-luna",
|
||||
modelProvider: "openai",
|
||||
agentRuntimeOverride: "openclaw",
|
||||
contextTokens: 272_000,
|
||||
contextTokensSource: "runtime",
|
||||
contextBudgetStatus: {} as NonNullable<
|
||||
ReturnType<typeof makeCronSessionEntry>["contextBudgetStatus"]
|
||||
>,
|
||||
}),
|
||||
isNewSession: false,
|
||||
});
|
||||
@@ -374,6 +379,9 @@ describe("runCronIsolatedAgentTurn — LiveSessionModelSwitchError retry (#57206
|
||||
expect(requireEmbeddedAgentCall(0).agentHarnessRuntimeOverride).toBe("openclaw");
|
||||
expect(requireEmbeddedAgentCall(1).agentHarnessRuntimeOverride).toBe("codex");
|
||||
expect(cronSession.sessionEntry.agentRuntimeOverride).toBe("codex");
|
||||
expect(cronSession.sessionEntry.contextTokens).toBe(128_000);
|
||||
expect(cronSession.sessionEntry.contextTokensSource).toBe("resolved");
|
||||
expect(cronSession.sessionEntry.contextBudgetStatus).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns error (not infinite loop) when LiveSessionModelSwitchError is thrown repeatedly", async () => {
|
||||
|
||||
@@ -402,25 +402,270 @@ describe("runCronIsolatedAgentTurn — skill filter", () => {
|
||||
});
|
||||
|
||||
describe("context token fallback", () => {
|
||||
it("prefers the harness-reported runtime window and provenance", async () => {
|
||||
const session = makeCronSession({
|
||||
sessionEntry: makeCronSessionEntry({
|
||||
agentHarnessId: "openclaw",
|
||||
contextTokens: 222_000,
|
||||
contextTokensSource: "resolved",
|
||||
}),
|
||||
});
|
||||
resolveCronSessionMock.mockReturnValue(session);
|
||||
resolveContextTokensForModelMock.mockReturnValue(512_000);
|
||||
runWithModelFallbackMock.mockResolvedValueOnce({
|
||||
result: {
|
||||
payloads: [{ text: "test output" }],
|
||||
meta: {
|
||||
agentMeta: {
|
||||
provider: "openai",
|
||||
model: "gpt-5.4",
|
||||
agentHarnessId: "codex",
|
||||
contextTokens: 1_000_000,
|
||||
contextTokensSource: "runtime",
|
||||
},
|
||||
},
|
||||
},
|
||||
provider: "openai",
|
||||
model: "gpt-5.4",
|
||||
});
|
||||
|
||||
const result = await runSkillFilterCase();
|
||||
|
||||
expect(result.status).toBe("ok");
|
||||
expect(session.sessionEntry.agentHarnessId).toBe("codex");
|
||||
expect(session.sessionEntry.contextTokens).toBe(1_000_000);
|
||||
expect(session.sessionEntry.contextTokensSource).toBe("runtime");
|
||||
});
|
||||
|
||||
it("preserves existing session contextTokens when no configured or cached model window is loaded", async () => {
|
||||
const session = makeCronSession({
|
||||
sessionEntry: makeCronSessionEntry({
|
||||
modelProvider: "openai",
|
||||
model: "gpt-5.4",
|
||||
agentHarnessId: "codex",
|
||||
contextTokens: 222_000,
|
||||
contextTokensSource: "runtime",
|
||||
}),
|
||||
});
|
||||
resolveCronSessionMock.mockReturnValue(session);
|
||||
resolveContextTokensForModelMock.mockReturnValue(undefined);
|
||||
runWithModelFallbackMock.mockResolvedValueOnce({
|
||||
result: {
|
||||
payloads: [{ text: "test output" }],
|
||||
meta: {
|
||||
agentMeta: {
|
||||
provider: "openai",
|
||||
model: "gpt-5.4",
|
||||
agentHarnessId: "codex",
|
||||
},
|
||||
},
|
||||
},
|
||||
provider: "openai",
|
||||
model: "gpt-5.4",
|
||||
});
|
||||
|
||||
const result = await runSkillFilterCase();
|
||||
|
||||
expect(result.status).toBe("ok");
|
||||
expect(session.sessionEntry.contextTokens).toBe(222_000);
|
||||
expect(session.sessionEntry.contextTokensSource).toBe("runtime");
|
||||
});
|
||||
|
||||
it("preserves a matching lower runtime window when current model capacity is higher", async () => {
|
||||
const session = makeCronSession({
|
||||
sessionEntry: makeCronSessionEntry({
|
||||
modelProvider: "openai",
|
||||
model: "gpt-5.4",
|
||||
agentHarnessId: "codex",
|
||||
contextTokens: 222_000,
|
||||
contextTokensSource: "runtime",
|
||||
}),
|
||||
});
|
||||
resolveCronSessionMock.mockReturnValue(session);
|
||||
resolveContextTokensForModelMock.mockReturnValue(512_000);
|
||||
runWithModelFallbackMock.mockResolvedValueOnce({
|
||||
result: {
|
||||
payloads: [{ text: "test output" }],
|
||||
meta: {
|
||||
agentMeta: {
|
||||
provider: "openai",
|
||||
model: "gpt-5.4",
|
||||
agentHarnessId: "codex",
|
||||
},
|
||||
},
|
||||
},
|
||||
provider: "openai",
|
||||
model: "gpt-5.4",
|
||||
});
|
||||
|
||||
const result = await runSkillFilterCase();
|
||||
|
||||
expect(result.status).toBe("ok");
|
||||
expect(session.sessionEntry.contextTokens).toBe(222_000);
|
||||
expect(session.sessionEntry.contextTokensSource).toBe("runtime");
|
||||
});
|
||||
|
||||
it("preserves a locked session window when current model capacity differs", async () => {
|
||||
const session = makeCronSession({
|
||||
sessionEntry: makeCronSessionEntry({
|
||||
modelProvider: "openai",
|
||||
model: "gpt-5.4",
|
||||
agentHarnessId: "codex",
|
||||
contextTokens: 222_000,
|
||||
modelSelectionLocked: true,
|
||||
}),
|
||||
});
|
||||
resolveCronSessionMock.mockReturnValue(session);
|
||||
resolveContextTokensForModelMock.mockReturnValue(512_000);
|
||||
runWithModelFallbackMock.mockResolvedValueOnce({
|
||||
result: {
|
||||
payloads: [{ text: "test output" }],
|
||||
meta: {
|
||||
agentMeta: {
|
||||
provider: "openai",
|
||||
model: "gpt-5.4",
|
||||
agentHarnessId: "codex",
|
||||
},
|
||||
},
|
||||
},
|
||||
provider: "openai",
|
||||
model: "gpt-5.4",
|
||||
});
|
||||
|
||||
const result = await runSkillFilterCase();
|
||||
|
||||
expect(result.status).toBe("ok");
|
||||
expect(session.sessionEntry.contextTokens).toBe(222_000);
|
||||
expect(session.sessionEntry.contextTokensSource).toBeUndefined();
|
||||
});
|
||||
|
||||
it("prefers a current authored cap over matching older runtime telemetry", async () => {
|
||||
const session = makeCronSession({
|
||||
sessionEntry: makeCronSessionEntry({
|
||||
modelProvider: "openai",
|
||||
model: "gpt-5.4",
|
||||
agentHarnessId: "codex",
|
||||
contextTokens: 222_000,
|
||||
contextTokensSource: "runtime",
|
||||
}),
|
||||
});
|
||||
resolveCronSessionMock.mockReturnValue(session);
|
||||
resolveContextTokensForModelMock.mockReturnValue(512_000);
|
||||
runWithModelFallbackMock.mockResolvedValueOnce({
|
||||
result: {
|
||||
payloads: [{ text: "test output" }],
|
||||
meta: {
|
||||
agentMeta: {
|
||||
provider: "openai",
|
||||
model: "gpt-5.4",
|
||||
agentHarnessId: "codex",
|
||||
},
|
||||
},
|
||||
},
|
||||
provider: "openai",
|
||||
model: "gpt-5.4",
|
||||
});
|
||||
|
||||
const result = await runSkillFilterCase({
|
||||
cfg: {
|
||||
models: {
|
||||
providers: {
|
||||
openai: {
|
||||
models: [{ id: "gpt-5.4", contextTokens: 512_000 }],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.status).toBe("ok");
|
||||
expect(session.sessionEntry.contextTokens).toBe(512_000);
|
||||
expect(session.sessionEntry.contextTokensSource).toBe("resolved");
|
||||
});
|
||||
|
||||
it("does not relabel a previous harness window when the current run reports none", async () => {
|
||||
const session = makeCronSession({
|
||||
sessionEntry: makeCronSessionEntry({
|
||||
modelProvider: "openai",
|
||||
model: "gpt-5.4",
|
||||
agentHarnessId: "openclaw",
|
||||
contextTokens: 222_000,
|
||||
contextTokensSource: "runtime",
|
||||
contextBudgetStatus: {} as NonNullable<
|
||||
ReturnType<typeof makeCronSessionEntry>["contextBudgetStatus"]
|
||||
>,
|
||||
}),
|
||||
});
|
||||
resolveCronSessionMock.mockReturnValue(session);
|
||||
resolveContextTokensForModelMock.mockReturnValue(undefined);
|
||||
runWithModelFallbackMock.mockResolvedValueOnce({
|
||||
result: {
|
||||
payloads: [{ text: "test output" }],
|
||||
meta: {
|
||||
agentMeta: {
|
||||
provider: "openai",
|
||||
model: "gpt-5.4",
|
||||
agentHarnessId: "codex",
|
||||
},
|
||||
},
|
||||
},
|
||||
provider: "openai",
|
||||
model: "gpt-5.4",
|
||||
});
|
||||
|
||||
const result = await runSkillFilterCase();
|
||||
|
||||
expect(result.status).toBe("ok");
|
||||
expect(session.sessionEntry.agentHarnessId).toBe("codex");
|
||||
expect(session.sessionEntry.contextTokens).toBe(128_000);
|
||||
expect(session.sessionEntry.contextTokensSource).toBe("resolved");
|
||||
expect(session.sessionEntry.contextBudgetStatus).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not relabel a previous model window when the harness stays the same", async () => {
|
||||
const session = makeCronSession({
|
||||
sessionEntry: makeCronSessionEntry({
|
||||
modelProvider: "openai",
|
||||
model: "gpt-5.3",
|
||||
agentHarnessId: "codex",
|
||||
contextTokens: 222_000,
|
||||
contextTokensSource: "runtime",
|
||||
contextBudgetStatus: {} as NonNullable<
|
||||
ReturnType<typeof makeCronSessionEntry>["contextBudgetStatus"]
|
||||
>,
|
||||
}),
|
||||
});
|
||||
resolveCronSessionMock.mockReturnValue(session);
|
||||
resolveContextTokensForModelMock.mockReturnValue(undefined);
|
||||
runWithModelFallbackMock.mockResolvedValueOnce({
|
||||
result: {
|
||||
payloads: [{ text: "test output" }],
|
||||
meta: {
|
||||
agentMeta: {
|
||||
provider: "openai",
|
||||
model: "gpt-5.4",
|
||||
agentHarnessId: "codex",
|
||||
},
|
||||
},
|
||||
},
|
||||
provider: "openai",
|
||||
model: "gpt-5.4",
|
||||
});
|
||||
|
||||
const result = await runSkillFilterCase();
|
||||
|
||||
expect(result.status).toBe("ok");
|
||||
expect(session.sessionEntry.model).toBe("gpt-5.4");
|
||||
expect(session.sessionEntry.contextTokens).toBe(128_000);
|
||||
expect(session.sessionEntry.contextTokensSource).toBe("resolved");
|
||||
expect(session.sessionEntry.contextBudgetStatus).toBeUndefined();
|
||||
});
|
||||
|
||||
it("prefers sync-configured model contextTokens over the previous session value", async () => {
|
||||
const session = makeCronSession({
|
||||
sessionEntry: makeCronSessionEntry({
|
||||
contextTokens: 222_000,
|
||||
contextTokensSource: "runtime",
|
||||
}),
|
||||
});
|
||||
resolveCronSessionMock.mockReturnValue(session);
|
||||
@@ -430,6 +675,7 @@ describe("runCronIsolatedAgentTurn — skill filter", () => {
|
||||
|
||||
expect(result.status).toBe("ok");
|
||||
expect(session.sessionEntry.contextTokens).toBe(512_000);
|
||||
expect(session.sessionEntry.contextTokensSource).toBe("resolved");
|
||||
expect(resolveContextTokensForModelMock).toHaveBeenCalledWith({
|
||||
cfg: expect.any(Object),
|
||||
provider: "openai",
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -423,6 +423,7 @@ describe("resolveCronSession", () => {
|
||||
cacheRead: 4,
|
||||
cacheWrite: 5,
|
||||
contextTokens: 200_000,
|
||||
contextTokensSource: "runtime",
|
||||
compactionCount: 9,
|
||||
memoryFlush: { kind: "succeeded", compactionCount: 9 },
|
||||
abortCutoffMessageSid: "old-message",
|
||||
@@ -508,6 +509,7 @@ describe("resolveCronSession", () => {
|
||||
expect(result.sessionEntry.cacheRead).toBeUndefined();
|
||||
expect(result.sessionEntry.cacheWrite).toBeUndefined();
|
||||
expect(result.sessionEntry.contextTokens).toBeUndefined();
|
||||
expect(result.sessionEntry.contextTokensSource).toBeUndefined();
|
||||
expect(result.sessionEntry.compactionCount).toBeUndefined();
|
||||
expect(result.sessionEntry.memoryFlush).toBeUndefined();
|
||||
expect(result.sessionEntry.abortCutoffMessageSid).toBeUndefined();
|
||||
|
||||
@@ -1123,7 +1123,9 @@ describe("gateway server chat", () => {
|
||||
modelOverride: "gpt-5",
|
||||
modelProvider: "openai",
|
||||
model: "gpt-5",
|
||||
agentHarnessId: "openclaw",
|
||||
contextTokens: 128_000,
|
||||
contextTokensSource: "runtime",
|
||||
});
|
||||
await writeMainSessionTranscript([
|
||||
createTextTranscriptEvent("user", "persisted metadata", { timestamp: updatedAt }),
|
||||
|
||||
@@ -909,7 +909,9 @@ test("sessions.changed mutation events include live usage metadata", async () =>
|
||||
modelOverride: "gpt-5.3-codex-spark",
|
||||
modelProvider: "openai",
|
||||
model: "gpt-5.3-codex-spark",
|
||||
agentHarnessId: "openclaw",
|
||||
contextTokens: 123_456,
|
||||
contextTokensSource: "runtime",
|
||||
totalTokens: 0,
|
||||
totalTokensFresh: false,
|
||||
}),
|
||||
|
||||
@@ -1768,7 +1768,9 @@ describe("session.message websocket events", () => {
|
||||
modelOverride: "gpt-5.4",
|
||||
modelProvider: "openai",
|
||||
model: "gpt-5.4",
|
||||
agentHarnessId: "openclaw",
|
||||
contextTokens: 123_456,
|
||||
contextTokensSource: "runtime",
|
||||
totalTokens: 0,
|
||||
totalTokensFresh: false,
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1117,6 +1117,407 @@ describe("gateway session utils", () => {
|
||||
expect(row.agentRuntime).toEqual({ id: "codex", source: "session" });
|
||||
});
|
||||
|
||||
test.each([true, false])(
|
||||
"projects current context for a stale different-runtime producer (lightweight=%s)",
|
||||
(lightweightListRow) => {
|
||||
const cfg = {
|
||||
agents: {
|
||||
defaults: {
|
||||
model: { primary: "openai/gpt-5.6-sol" },
|
||||
models: { "openai/gpt-5.6-sol": { agentRuntime: { id: "codex" } } },
|
||||
},
|
||||
},
|
||||
models: {
|
||||
providers: {
|
||||
openai: {
|
||||
models: [{ id: "gpt-5.6-sol", contextTokens: 1_000_000 }],
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as OpenClawConfig;
|
||||
|
||||
const row = buildGatewaySessionRow({
|
||||
cfg,
|
||||
storePath: "",
|
||||
store: {},
|
||||
key: "agent:main:main",
|
||||
entry: {
|
||||
sessionId: "stale-openclaw",
|
||||
modelProvider: "openai",
|
||||
model: "gpt-5.6-sol",
|
||||
agentHarnessId: "openclaw",
|
||||
contextTokens: 272_000,
|
||||
contextTokensSource: "runtime",
|
||||
} as SessionEntry,
|
||||
lightweightListRow,
|
||||
});
|
||||
|
||||
expect(row.agentRuntime?.id).toBe("codex");
|
||||
expect(row.contextTokens).toBe(1_000_000);
|
||||
},
|
||||
);
|
||||
|
||||
test.each([true, false])(
|
||||
"projects current Codex context when producer provenance is missing (lightweight=%s)",
|
||||
(lightweightListRow) => {
|
||||
const cfg = {
|
||||
agents: {
|
||||
defaults: {
|
||||
model: { primary: "openai/gpt-5.6-sol" },
|
||||
models: { "openai/gpt-5.6-sol": { agentRuntime: { id: "codex" } } },
|
||||
},
|
||||
},
|
||||
models: {
|
||||
providers: {
|
||||
openai: {
|
||||
models: [{ id: "gpt-5.6-sol", contextTokens: 1_000_000 }],
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as OpenClawConfig;
|
||||
|
||||
const row = buildGatewaySessionRow({
|
||||
cfg,
|
||||
storePath: "",
|
||||
store: {},
|
||||
key: "agent:main:main",
|
||||
entry: {
|
||||
sessionId: "missing-provenance",
|
||||
modelProvider: "openai",
|
||||
model: "gpt-5.6-sol",
|
||||
contextTokens: 272_000,
|
||||
} as SessionEntry,
|
||||
lightweightListRow,
|
||||
});
|
||||
|
||||
expect(row.agentRuntime?.id).toBe("codex");
|
||||
expect(row.contextTokens).toBe(1_000_000);
|
||||
},
|
||||
);
|
||||
|
||||
test.each([true, false])(
|
||||
"projects a changed explicit cap for the same runtime and model (lightweight=%s)",
|
||||
(lightweightListRow) => {
|
||||
const cfg = {
|
||||
agents: {
|
||||
defaults: {
|
||||
model: { primary: "openai/gpt-5.6-sol" },
|
||||
models: { "openai/gpt-5.6-sol": { agentRuntime: { id: "codex" } } },
|
||||
},
|
||||
},
|
||||
models: {
|
||||
providers: {
|
||||
openai: {
|
||||
models: [{ id: "gpt-5.6-sol", contextTokens: 1_000_000 }],
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as OpenClawConfig;
|
||||
|
||||
const row = buildGatewaySessionRow({
|
||||
cfg,
|
||||
storePath: "",
|
||||
store: {},
|
||||
key: "agent:main:main",
|
||||
entry: {
|
||||
sessionId: "stale-cap",
|
||||
modelProvider: "openai",
|
||||
model: "gpt-5.6-sol",
|
||||
agentHarnessId: "codex",
|
||||
contextTokens: 272_000,
|
||||
contextTokensSource: "runtime",
|
||||
} as SessionEntry,
|
||||
lightweightListRow,
|
||||
});
|
||||
|
||||
expect(row.contextTokens).toBe(1_000_000);
|
||||
},
|
||||
);
|
||||
|
||||
test.each([true, false])(
|
||||
"projects an authored contextWindow cap below matching runtime telemetry (lightweight=%s)",
|
||||
(lightweightListRow) => {
|
||||
const cfg = {
|
||||
agents: {
|
||||
defaults: {
|
||||
model: { primary: "openai/gpt-5.6-sol" },
|
||||
models: { "openai/gpt-5.6-sol": { agentRuntime: { id: "codex" } } },
|
||||
},
|
||||
},
|
||||
models: {
|
||||
providers: {
|
||||
openai: {
|
||||
models: [{ id: "gpt-5.6-sol", contextWindow: 128_000 }],
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as OpenClawConfig;
|
||||
|
||||
const row = buildGatewaySessionRow({
|
||||
cfg,
|
||||
storePath: "",
|
||||
store: {},
|
||||
key: "agent:main:main",
|
||||
entry: {
|
||||
sessionId: "authored-window-cap",
|
||||
modelProvider: "openai",
|
||||
model: "gpt-5.6-sol",
|
||||
agentHarnessId: "codex",
|
||||
contextTokens: 272_000,
|
||||
contextTokensSource: "runtime",
|
||||
} as SessionEntry,
|
||||
lightweightListRow,
|
||||
});
|
||||
|
||||
expect(row.agentRuntime?.id).toBe("codex");
|
||||
expect(row.contextTokens).toBe(128_000);
|
||||
},
|
||||
);
|
||||
|
||||
test.each([true, false])(
|
||||
"clamps an authored effective cap to a smaller authored contextWindow (lightweight=%s)",
|
||||
(lightweightListRow) => {
|
||||
const cfg = {
|
||||
agents: {
|
||||
defaults: {
|
||||
model: { primary: "openai/gpt-5.6-sol" },
|
||||
models: { "openai/gpt-5.6-sol": { agentRuntime: { id: "openclaw" } } },
|
||||
},
|
||||
},
|
||||
models: {
|
||||
providers: {
|
||||
openai: {
|
||||
models: [
|
||||
{
|
||||
id: "gpt-5.6-sol",
|
||||
contextTokens: 1_000_000,
|
||||
contextWindow: 128_000,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as OpenClawConfig;
|
||||
|
||||
const row = buildGatewaySessionRow({
|
||||
cfg,
|
||||
storePath: "",
|
||||
store: {},
|
||||
key: "agent:main:main",
|
||||
entry: {
|
||||
sessionId: "authored-effective-above-native",
|
||||
modelProvider: "openai",
|
||||
model: "gpt-5.6-sol",
|
||||
contextTokens: 272_000,
|
||||
} as SessionEntry,
|
||||
lightweightListRow,
|
||||
});
|
||||
|
||||
expect(row.contextTokens).toBe(128_000);
|
||||
},
|
||||
);
|
||||
|
||||
test.each([true, false])(
|
||||
"keeps matching runtime telemetry below a higher authored contextWindow (lightweight=%s)",
|
||||
(lightweightListRow) => {
|
||||
const cfg = {
|
||||
agents: {
|
||||
defaults: {
|
||||
model: { primary: "openai/gpt-5.6-sol" },
|
||||
models: { "openai/gpt-5.6-sol": { agentRuntime: { id: "codex" } } },
|
||||
},
|
||||
},
|
||||
models: {
|
||||
providers: {
|
||||
openai: {
|
||||
models: [{ id: "gpt-5.6-sol", contextWindow: 1_000_000 }],
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as OpenClawConfig;
|
||||
|
||||
const row = buildGatewaySessionRow({
|
||||
cfg,
|
||||
storePath: "",
|
||||
store: {},
|
||||
key: "agent:main:main",
|
||||
entry: {
|
||||
sessionId: "runtime-window-below-native-cap",
|
||||
modelProvider: "openai",
|
||||
model: "gpt-5.6-sol",
|
||||
agentHarnessId: "codex",
|
||||
contextTokens: 272_000,
|
||||
contextTokensSource: "runtime",
|
||||
} as SessionEntry,
|
||||
lightweightListRow,
|
||||
});
|
||||
|
||||
expect(row.agentRuntime?.id).toBe("codex");
|
||||
expect(row.contextTokens).toBe(272_000);
|
||||
},
|
||||
);
|
||||
|
||||
test.each([
|
||||
{
|
||||
name: "a locked Codex session under OpenClaw config",
|
||||
configuredRuntime: "openclaw",
|
||||
expectedRuntime: "codex",
|
||||
entry: {
|
||||
agentHarnessId: "codex",
|
||||
contextTokens: 1_000_000,
|
||||
modelSelectionLocked: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "locked legacy telemetry without harness provenance",
|
||||
configuredRuntime: "openclaw",
|
||||
expectedRuntime: "openclaw",
|
||||
entry: {
|
||||
contextTokens: 1_000_000,
|
||||
modelSelectionLocked: true,
|
||||
},
|
||||
},
|
||||
])("preserves $name", ({ configuredRuntime, entry, expectedRuntime }) => {
|
||||
const cfg = {
|
||||
agents: {
|
||||
defaults: {
|
||||
model: { primary: "openai/gpt-5.6-sol" },
|
||||
models: {
|
||||
"openai/gpt-5.6-sol": { agentRuntime: { id: configuredRuntime } },
|
||||
},
|
||||
},
|
||||
},
|
||||
models: {
|
||||
providers: {
|
||||
openai: {
|
||||
models: [{ id: "gpt-5.6-sol", contextWindow: 272_000 }],
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as OpenClawConfig;
|
||||
|
||||
const row = buildGatewaySessionRow({
|
||||
cfg,
|
||||
storePath: "",
|
||||
store: {},
|
||||
key: "agent:main:main",
|
||||
entry: {
|
||||
sessionId: "native-window",
|
||||
modelProvider: "openai",
|
||||
model: "gpt-5.6-sol",
|
||||
...entry,
|
||||
} as SessionEntry,
|
||||
});
|
||||
|
||||
expect(row.agentRuntime?.id).toBe(expectedRuntime);
|
||||
expect(row.contextTokens).toBe(1_000_000);
|
||||
});
|
||||
|
||||
test.each([true, false])(
|
||||
"does not reuse stale transcript context after an OpenClaw to Codex change (lightweight=%s)",
|
||||
async (lightweightListRow) => {
|
||||
await withStateDirEnv("session-utils-stale-transcript-context-", async ({ stateDir }) => {
|
||||
const sessionId = "stale-transcript-context";
|
||||
const sessionKey = "agent:main:main";
|
||||
const storePath = path.join(stateDir, "agents", "main", "sessions", "sessions.json");
|
||||
const entry = {
|
||||
sessionId,
|
||||
updatedAt: 1,
|
||||
modelProvider: "openai",
|
||||
model: "gpt-5.5",
|
||||
agentHarnessId: "openclaw",
|
||||
} as SessionEntry;
|
||||
await seedSessionEntries(storePath, { [sessionKey]: entry });
|
||||
appendTranscriptMessages({
|
||||
sessionId,
|
||||
sessionKey,
|
||||
storePath,
|
||||
messages: [
|
||||
{
|
||||
role: "assistant",
|
||||
content: "old OpenClaw turn",
|
||||
provider: "openai",
|
||||
model: "gpt-5.5",
|
||||
usage: { input: 1, output: 1 },
|
||||
},
|
||||
],
|
||||
});
|
||||
const cfg = {
|
||||
agents: {
|
||||
defaults: {
|
||||
model: { primary: "openai/gpt-5.6-sol" },
|
||||
models: { "openai/gpt-5.6-sol": { agentRuntime: { id: "codex" } } },
|
||||
},
|
||||
},
|
||||
models: {
|
||||
providers: {
|
||||
openai: {
|
||||
models: [
|
||||
{ id: "gpt-5.5", contextWindow: 272_000 },
|
||||
{ id: "gpt-5.6-sol", contextWindow: 1_000_000 },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as OpenClawConfig;
|
||||
|
||||
const row = buildGatewaySessionRow({
|
||||
cfg,
|
||||
storePath,
|
||||
store: { [sessionKey]: entry },
|
||||
key: sessionKey,
|
||||
entry,
|
||||
lightweightListRow,
|
||||
});
|
||||
|
||||
expect(row.agentRuntime?.id).toBe("codex");
|
||||
expect(row.model).toBe("gpt-5.6-sol");
|
||||
expect(row.contextTokens).toBe(1_000_000);
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
test.each(["resolved", "runtime-configured"] as const)(
|
||||
"invalidates a persisted %s cap after the cap is removed",
|
||||
(contextTokensSource) => {
|
||||
const cfg = {
|
||||
agents: {
|
||||
defaults: {
|
||||
model: { primary: "openai/gpt-5.6-sol" },
|
||||
models: { "openai/gpt-5.6-sol": { agentRuntime: { id: "codex" } } },
|
||||
},
|
||||
},
|
||||
models: {
|
||||
providers: {
|
||||
openai: {
|
||||
models: [{ id: "gpt-5.6-sol", contextWindow: 1_000_000 }],
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as OpenClawConfig;
|
||||
|
||||
for (const lightweightListRow of [true, false]) {
|
||||
const row = buildGatewaySessionRow({
|
||||
cfg,
|
||||
storePath: "",
|
||||
store: {},
|
||||
key: "agent:main:main",
|
||||
entry: {
|
||||
sessionId: "removed-cap",
|
||||
modelProvider: "openai",
|
||||
model: "gpt-5.6-sol",
|
||||
agentHarnessId: "codex",
|
||||
contextTokens: 272_000,
|
||||
contextTokensSource,
|
||||
} as SessionEntry,
|
||||
lightweightListRow,
|
||||
});
|
||||
|
||||
expect(row.contextTokens).toBe(1_000_000);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
test.each(["xhigh", "max"] as const)(
|
||||
"preserves catalog-less persisted %s in session change projections",
|
||||
(thinkingLevel) => {
|
||||
|
||||
@@ -319,6 +319,11 @@ describe("plugin session extension SessionEntry projection", () => {
|
||||
description: "reserved custom icon",
|
||||
sessionEntrySlotKey: "icon",
|
||||
});
|
||||
api.registerSessionExtension({
|
||||
namespace: "context-window-source",
|
||||
description: "reserved context window provenance",
|
||||
sessionEntrySlotKey: "contextTokensSource",
|
||||
});
|
||||
api.registerSessionExtension({
|
||||
namespace: "pending-final-text",
|
||||
description: "retired pending-final field",
|
||||
@@ -355,6 +360,10 @@ describe("plugin session extension SessionEntry projection", () => {
|
||||
pluginId: "slot-collision",
|
||||
message: "sessionEntrySlotKey is reserved by SessionEntry: icon",
|
||||
},
|
||||
{
|
||||
pluginId: "slot-collision",
|
||||
message: "sessionEntrySlotKey is reserved by SessionEntry: contextTokensSource",
|
||||
},
|
||||
{
|
||||
pluginId: "slot-collision",
|
||||
message: "sessionEntrySlotKey is reserved by SessionEntry: pendingFinalDeliveryText",
|
||||
|
||||
@@ -149,6 +149,7 @@ const SESSION_ENTRY_RESERVED_SLOT_KEY_LIST = [
|
||||
"agentHarnessId",
|
||||
"fallbackNotice",
|
||||
"contextTokens",
|
||||
"contextTokensSource",
|
||||
"contextBudgetStatus",
|
||||
"compactionCount",
|
||||
"compactionCheckpoints",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -7,10 +7,13 @@ import {
|
||||
normalizeOptionalLowercaseString,
|
||||
} from "@openclaw/normalization-core/string-coerce";
|
||||
import { readAcpSessionMeta } from "../acp/runtime/session-meta.js";
|
||||
import { resolveModelAgentRuntimeMetadata } from "../agents/agent-runtime-metadata.js";
|
||||
import { resolveCurrentSessionAgentRuntimeMetadata } from "../agents/agent-runtime-metadata.js";
|
||||
import { resolveAgentConfig } from "../agents/agent-scope-config.js";
|
||||
import { resolveConfiguredProviderFallback } from "../agents/configured-provider-fallback.js";
|
||||
import { resolveContextTokensForModelFromCache as resolveContextTokensForModel } from "../agents/context-resolution.js";
|
||||
import {
|
||||
resolveAuthoredModelContextTokens,
|
||||
resolveContextTokensForModelFromCache as resolveContextTokensForModel,
|
||||
} from "../agents/context-resolution.js";
|
||||
import { waitForContextWindowCacheLoad } from "../agents/context.js";
|
||||
import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "../agents/defaults.js";
|
||||
import { parseModelRef, resolvePersistedSelectedModelRef } from "../agents/model-selection.js";
|
||||
@@ -192,14 +195,14 @@ function resolveSessionModelRef(
|
||||
);
|
||||
}
|
||||
|
||||
function resolveSessionRuntimeLabel(params: {
|
||||
function resolveSessionRuntime(params: {
|
||||
cfg: OpenClawConfig;
|
||||
entry?: SessionEntry;
|
||||
provider: string;
|
||||
model: string;
|
||||
agentId?: string;
|
||||
sessionKey: string;
|
||||
}): string {
|
||||
}): { id: string | undefined; label: string } {
|
||||
const acpSessionKey = params.agentId
|
||||
? resolveStoredSessionKeyForAgentStore({
|
||||
cfg: params.cfg,
|
||||
@@ -208,33 +211,37 @@ function resolveSessionRuntimeLabel(params: {
|
||||
})
|
||||
: params.sessionKey;
|
||||
const acpMeta = readAcpSessionMeta({ sessionKey: acpSessionKey });
|
||||
const runtime = resolveModelAgentRuntimeMetadata({
|
||||
const runtime = resolveCurrentSessionAgentRuntimeMetadata({
|
||||
cfg: params.cfg,
|
||||
agentId: params.agentId ?? "",
|
||||
sessionEntry: params.entry,
|
||||
provider: params.provider,
|
||||
model: params.model,
|
||||
sessionKey: acpSessionKey,
|
||||
sessionEntry: params.entry,
|
||||
acpRuntime: acpMeta != null,
|
||||
acpBackend: acpMeta?.backend,
|
||||
});
|
||||
const id = normalizeOptionalLowercaseString(runtime.id);
|
||||
// OpenClaw/auto are generic labels; concrete harness ids give better operator signal.
|
||||
const resolvedHarness = id && id !== "openclaw" && id !== "auto" ? id : undefined;
|
||||
return resolveAgentRuntimeLabel({
|
||||
config: params.cfg,
|
||||
sessionEntry: params.entry,
|
||||
resolvedHarness,
|
||||
fallbackProvider: params.provider,
|
||||
});
|
||||
return {
|
||||
id,
|
||||
label: resolveAgentRuntimeLabel({
|
||||
config: params.cfg,
|
||||
sessionEntry: params.entry,
|
||||
resolvedHarness,
|
||||
fallbackProvider: params.provider,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export const statusSummaryRuntime = {
|
||||
waitForContextWindowCacheLoad,
|
||||
resolveAuthoredModelContextTokens,
|
||||
resolveContextTokensForModel,
|
||||
classifySessionKey: classifySessionKind,
|
||||
resolveSessionModelRef,
|
||||
resolveSessionRuntimeLabel,
|
||||
resolveSessionRuntime,
|
||||
resolveConfiguredStatusModelRef,
|
||||
resolveStatusModelLookupRef,
|
||||
resolveStatusModelComparisonLabel,
|
||||
|
||||
+21
-47
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user