fix(gateway): cache session list thinking enrichment

This commit is contained in:
Peter Steinberger
2026-05-04 02:47:35 +01:00
parent 36f8a8603d
commit 18bd7b60e4
3 changed files with 121 additions and 6 deletions

View File

@@ -45,6 +45,7 @@ Docs: https://docs.openclaw.ai
- Release validation: install the cross-OS TypeScript harness through Windows-safe Node/npm shims so native Windows package checks reach the OpenClaw smoke suites instead of exiting before artifact capture. Thanks @vincentkoc.
- Release validation: let Windows packaged-upgrade checks continue after the shipped 2026.5.2 updater hits its native-module swap cleanup fallback, verifying the fallback-installed candidate through package metadata and downstream smoke instead of crashing on the immediate update-status probe. Thanks @vincentkoc.
- Doctor/plugins: skip channel-derived official plugin installs when another configured plugin is the effective owner for the same channel, so `doctor --repair` does not reinstall `feishu` while `openclaw-lark` handles `channels.feishu`. Fixes #76623. Thanks @fuyizheng3120.
- Gateway/sessions: memoize repeated thinking-option enrichment and skip unused cost fallback checks while listing sessions, reducing per-row work on large multi-agent stores. Fixes #76931.
- Agents/tools: use config-only runtime snapshots for plugin tool registration and live runtime config getters, avoiding expensive full secrets snapshot clones on the core-plugin-tools prep path. Fixes #76295.
- Agents/tools: honor the effective tool denylist before constructing optional PDF/media tool factories, so `tools.deny: ["pdf"]` skips PDF setup before later policy filtering. Fixes #76997.
- Agents/bootstrap: keep pending `BOOTSTRAP.md` and bootstrap truncation notices in system-prompt Project Context instead of copying setup text or raw warning diagnostics into WebChat user/runtime context. Fixes #76946.

View File

@@ -1,7 +1,7 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, test } from "vitest";
import { afterEach, describe, expect, test, vi } from "vitest";
import { resetConfigRuntimeState, setRuntimeConfigSnapshot } from "../config/config.js";
import type { OpenClawConfig } from "../config/config.js";
import type { SessionEntry } from "../config/sessions.js";
@@ -201,6 +201,93 @@ describe("gateway session utils", () => {
expect(row.thinkingDefault).toBe("medium");
});
test("session list memoizes repeated thinking enrichment per provider model", async () => {
const resolveThinkingProfile = vi.fn(() => ({
levels: [{ id: "off" as const }, { id: "medium" as const }],
defaultLevel: "medium" as const,
}));
const registry = createEmptyPluginRegistry();
registry.providers.push({
pluginId: "test",
source: "test",
provider: {
id: "openai-codex",
label: "OpenAI Codex",
auth: [],
resolveThinkingProfile,
},
});
setActivePluginRegistry(registry);
const cfg = createModelDefaultsConfig({ primary: "openai-codex/gpt-5.5" });
const store = Object.fromEntries(
Array.from({ length: 5 }, (_value, index) => [
`session-${index}`,
{
sessionId: `session-${index}`,
modelProvider: "openai-codex",
model: "gpt-5.5",
updatedAt: Date.now() - index,
} satisfies SessionEntry,
]),
);
const result = await listSessionsFromStoreAsync({
cfg,
storePath: "",
store,
opts: {},
});
expect(result.sessions).toHaveLength(5);
expect(resolveThinkingProfile).toHaveBeenCalledTimes(3);
});
test("session list thinking cache preserves case-distinct model catalog entries", async () => {
const cfg = createModelDefaultsConfig({ primary: "custom/CaseModel" });
const modelCatalog = [
{
provider: "custom",
id: "CaseModel",
name: "CaseModel",
reasoning: true,
compat: { supportedReasoningEfforts: ["low", "medium", "high", "xhigh"] },
},
{
provider: "custom",
id: "casemodel",
name: "casemodel",
reasoning: true,
compat: { supportedReasoningEfforts: ["low", "medium", "high"] },
},
];
const result = await listSessionsFromStoreAsync({
cfg,
storePath: "",
modelCatalog,
store: {
upper: {
sessionId: "upper",
modelProvider: "custom",
model: "CaseModel",
updatedAt: 2,
} satisfies SessionEntry,
lower: {
sessionId: "lower",
modelProvider: "custom",
model: "casemodel",
updatedAt: 1,
} satisfies SessionEntry,
},
opts: {},
});
const upper = result.sessions.find((session) => session.key === "upper");
const lower = result.sessions.find((session) => session.key === "lower");
expect(upper?.thinkingLevels?.map((level) => level.id)).toContain("xhigh");
expect(lower?.thinkingLevels?.map((level) => level.id)).not.toContain("xhigh");
});
test("session defaults and rows expose xhigh from configured catalog compat", () => {
const cfg = createModelDefaultsConfig({ primary: "gmn/gpt-5.4" });
const catalog = [

View File

@@ -372,6 +372,7 @@ function shouldKeepStoreOnlyChildLink(entry: SessionEntry, now: number): boolean
type SessionListRowContext = {
subagentRuns: ReturnType<typeof buildSubagentRunReadIndex>;
storeChildSessionsByKey: Map<string, string[]>;
thinkingLevelsByModelRef: Map<string, ReturnType<typeof listThinkingLevelOptions>>;
};
function resolveRuntimeChildSessionKeys(
@@ -488,9 +489,33 @@ function buildSessionListRowContext(params: {
return {
subagentRuns,
storeChildSessionsByKey: buildStoreChildSessionIndex(params.store, params.now, subagentRuns),
thinkingLevelsByModelRef: new Map(),
};
}
function createSessionRowModelCacheKey(provider: string | undefined, model: string | undefined) {
return `${normalizeLowercaseStringOrEmpty(provider)}\0${normalizeOptionalString(model) ?? ""}`;
}
function resolveSessionRowThinkingLevels(params: {
provider: string;
model: string;
modelCatalog?: ModelCatalogEntry[];
rowContext?: SessionListRowContext;
}): ReturnType<typeof listThinkingLevelOptions> {
if (!params.rowContext) {
return listThinkingLevelOptions(params.provider, params.model, params.modelCatalog);
}
const key = createSessionRowModelCacheKey(params.provider, params.model);
const cached = params.rowContext.thinkingLevelsByModelRef.get(key);
if (cached) {
return cached;
}
const levels = listThinkingLevelOptions(params.provider, params.model, params.modelCatalog);
params.rowContext.thinkingLevelsByModelRef.set(key, levels);
return levels;
}
function mergeChildSessionKeys(
runtimeChildSessions: string[] | undefined,
storeChildSessions: string[] | undefined,
@@ -1530,6 +1555,7 @@ export function buildGatewaySessionRow(params: {
resolvePositiveNumber(resolveFreshSessionTotalTokens(entry)) === undefined;
const needsTranscriptContextTokens = resolvePositiveNumber(entry?.contextTokens) === undefined;
const needsTranscriptEstimatedCostUsd =
!skipTranscriptUsage &&
resolveEstimatedSessionCostUsd({
cfg,
provider: resolvedModel.provider,
@@ -1635,11 +1661,12 @@ export function buildGatewaySessionRow(params: {
const thinkingProvider = rowModelProvider ?? DEFAULT_PROVIDER;
const thinkingModel = rowModel ?? DEFAULT_MODEL;
const thinkingLevels = listThinkingLevelOptions(
thinkingProvider,
thinkingModel,
params.modelCatalog,
);
const thinkingLevels = resolveSessionRowThinkingLevels({
provider: thinkingProvider,
model: thinkingModel,
modelCatalog: params.modelCatalog,
rowContext,
});
const pluginExtensions =
!lightweight && entry ? projectPluginSessionExtensionsSync({ sessionKey: key, entry }) : [];