From 18bd7b60e4fe1c08af504ec692bec7c750301fe1 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 4 May 2026 02:47:35 +0100 Subject: [PATCH] fix(gateway): cache session list thinking enrichment --- CHANGELOG.md | 1 + src/gateway/session-utils.test.ts | 89 ++++++++++++++++++++++++++++++- src/gateway/session-utils.ts | 37 +++++++++++-- 3 files changed, 121 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6f5d5996d83..3d153a1056e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/src/gateway/session-utils.test.ts b/src/gateway/session-utils.test.ts index 3b69e28a787..421a3efd0e8 100644 --- a/src/gateway/session-utils.test.ts +++ b/src/gateway/session-utils.test.ts @@ -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 = [ diff --git a/src/gateway/session-utils.ts b/src/gateway/session-utils.ts index c71569f44f6..a3412a78f8b 100644 --- a/src/gateway/session-utils.ts +++ b/src/gateway/session-utils.ts @@ -372,6 +372,7 @@ function shouldKeepStoreOnlyChildLink(entry: SessionEntry, now: number): boolean type SessionListRowContext = { subagentRuns: ReturnType; storeChildSessionsByKey: Map; + thinkingLevelsByModelRef: Map>; }; 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 { + 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 }) : [];