From 026b6881619e489a188f5ffa0e40f8f44b8a8959 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 15 Jul 2026 03:38:05 -0700 Subject: [PATCH] refactor(session-catalog): normalize search at gateway (#108240) Co-authored-by: Leon-SK668 <0668001470@xydigit.com> --- .../acpx/src/pi-session-catalog-plugin.ts | 11 +--- .../acpx/src/pi-session-catalog.test.ts | 47 ++--------------- extensions/opencode/session-catalog-plugin.ts | 11 +--- extensions/opencode/session-catalog.test.ts | 50 ++----------------- .../server-methods/session-catalog.test.ts | 29 +++++++++++ src/gateway/server-methods/session-catalog.ts | 13 ++++- src/plugins/session-catalog.ts | 1 + 7 files changed, 54 insertions(+), 108 deletions(-) diff --git a/extensions/acpx/src/pi-session-catalog-plugin.ts b/extensions/acpx/src/pi-session-catalog-plugin.ts index c8b79d63149f..3f9d90c2340a 100644 --- a/extensions/acpx/src/pi-session-catalog-plugin.ts +++ b/extensions/acpx/src/pi-session-catalog-plugin.ts @@ -19,7 +19,6 @@ import type { SessionsCatalogReadResult, } from "openclaw/plugin-sdk/session-catalog"; import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; -import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime"; import { listLocalPiSessionPage, optionalPiString, @@ -37,7 +36,6 @@ const LOCAL_HOST_ID = "gateway"; const MAX_PAGE_LIMIT = 100; const MAX_HOSTS = 100; const MAX_CURSOR_LENGTH = 128; -const MAX_SEARCH_LENGTH = 500; const NODE_TIMEOUT_MS = 20_000; const SESSION_ID_PATTERN = /^(?!-)[A-Za-z0-9._:-]{1,256}$/u; const TRANSCRIPT_ITEM_TYPES = new Set([ @@ -251,9 +249,7 @@ async function listPiNodeHost( command: PI_SESSIONS_LIST_COMMAND, params: { ...(query.limitPerHost ? { limit: query.limitPerHost } : {}), - ...(query.search?.trim() - ? { searchTerm: truncateUtf16Safe(query.search.trim(), MAX_SEARCH_LENGTH) } - : {}), + ...(query.search ? { searchTerm: query.search } : {}), ...(query.cursors?.[hostId] ? { cursor: query.cursors[hostId] } : {}), }, timeoutMs: NODE_TIMEOUT_MS, @@ -321,9 +317,6 @@ async function listPiHosts( query: Parameters[0], ): Promise { const requested = query.hostIds ? new Set(query.hostIds) : undefined; - const searchTerm = query.search - ? truncateUtf16Safe(query.search.trim(), MAX_SEARCH_LENGTH) || undefined - : undefined; const hosts: SessionCatalogHost[] = []; if ((!requested || requested.has(LOCAL_HOST_ID)) && piSessionStoreAvailable(process.env)) { try { @@ -334,7 +327,7 @@ async function listPiHosts( connected: true, ...(await listLocalPiSessionPage({ limit: query.limitPerHost, - ...(searchTerm ? { searchTerm } : {}), + ...(query.search ? { searchTerm: query.search } : {}), cursor: query.cursors?.[LOCAL_HOST_ID], }).then((page) => setTerminalCapability( diff --git a/extensions/acpx/src/pi-session-catalog.test.ts b/extensions/acpx/src/pi-session-catalog.test.ts index 5b816f4a0b53..74a7fae1eff9 100644 --- a/extensions/acpx/src/pi-session-catalog.test.ts +++ b/extensions/acpx/src/pi-session-catalog.test.ts @@ -39,10 +39,6 @@ import { piSessionStore } from "./pi-session-paths.js"; const PI_SESSIONS_LIST_COMMAND = "acpx.pi.sessions.list.v1"; const PI_SESSION_READ_COMMAND = "acpx.pi.sessions.read.v1"; const PI_TERMINAL_RESUME_COMMAND = "acpx.pi.terminal.resume.v1"; -const UTF16_SEARCH_PREFIX = "x".repeat(499); -const UTF16_BOUNDARY_SEARCH = `${UTF16_SEARCH_PREFIX}πŸ˜€`; -const LONE_SURROGATE_PATTERN = - /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(? { await expect( provider!.read({ hostId: "gateway", threadId: "pi-session", limit: 2 }), ).resolves.toMatchObject({ threadId: "pi-session", items: expect.any(Array) }); - await expect(provider!.list({ search: " " })).resolves.toEqual([ + await expect(provider!.list({})).resolves.toEqual([ expect.objectContaining({ hostId: "gateway", sessions: [expect.any(Object)] }), ]); - await expect(provider!.list({ search: "x".repeat(501) })).resolves.toEqual([ - expect.objectContaining({ hostId: "gateway", sessions: [] }), - ]); - }); - - it("keeps local search needles UTF-16 safe at the length limit", async () => { - await createPiStore("hi", UTF16_SEARCH_PREFIX); - let provider: Parameters[0] | undefined; - registerPiSessionCatalog({ - pluginConfig: {}, - runtime: { nodes: { list: vi.fn().mockResolvedValue({ nodes: [] }) } }, - registerSessionCatalog: (value: NonNullable) => { - provider = value; - }, - registerNodeHostCommand: vi.fn(), - registerNodeInvokePolicy: vi.fn(), - } as unknown as OpenClawPluginApi); - - await expect( - provider!.list({ hostIds: ["gateway"], search: UTF16_BOUNDARY_SEARCH }), - ).resolves.toEqual([ - expect.objectContaining({ - hostId: "gateway", - sessions: [expect.objectContaining({ threadId: "pi-session", name: UTF16_SEARCH_PREFIX })], - }), - ]); - await expect( - provider!.list({ hostIds: ["gateway"], search: `${"y".repeat(499)}πŸ˜€` }), - ).resolves.toEqual([expect.objectContaining({ hostId: "gateway", sessions: [] })]); }); it("summarizes and pages a large session within transport limits", async () => { @@ -775,9 +742,7 @@ describe("Pi session catalog", () => { registerNodeInvokePolicy: vi.fn(), } as unknown as OpenClawPluginApi); - await expect( - provider!.list({ hostIds: ["node:node-1"], search: UTF16_BOUNDARY_SEARCH }), - ).resolves.toEqual([ + await expect(provider!.list({ hostIds: ["node:node-1"], search: "remote" })).resolves.toEqual([ expect.objectContaining({ sessions: [expect.objectContaining({ threadId: "pi-remote", canOpenTerminal: true })], }), @@ -785,14 +750,10 @@ describe("Pi session catalog", () => { expect(invoke).toHaveBeenNthCalledWith(1, { nodeId: "node-1", command: PI_SESSIONS_LIST_COMMAND, - params: { searchTerm: UTF16_SEARCH_PREFIX }, + params: { searchTerm: "remote" }, timeoutMs: 20_000, scopes: ["operator.write"], }); - const firstRequest = invoke.mock.calls[0]?.[0] as - | { params?: { searchTerm?: string } } - | undefined; - expect(firstRequest?.params?.searchTerm).not.toMatch(LONE_SURROGATE_PATTERN); await expect( provider!.openTerminal!({ hostId: "node:node-1", threadId: "pi-remote" }), ).resolves.toEqual({ @@ -873,7 +834,7 @@ describe("Pi session catalog", () => { registerPiSessionCatalog(api); const catalog = provider; expect(catalog).toBeDefined(); - await catalog!.list({ hostIds: ["node:node-1"], search: " " }); + await catalog!.list({ hostIds: ["node:node-1"] }); await catalog!.read({ hostId: "node:node-1", threadId: "pi-remote" }); expect(invoke).toHaveBeenNthCalledWith(1, { diff --git a/extensions/opencode/session-catalog-plugin.ts b/extensions/opencode/session-catalog-plugin.ts index f37b54ee48cb..1cf4c633b7b8 100644 --- a/extensions/opencode/session-catalog-plugin.ts +++ b/extensions/opencode/session-catalog-plugin.ts @@ -16,7 +16,6 @@ import type { SessionsCatalogReadResult, } from "openclaw/plugin-sdk/session-catalog"; import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; -import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime"; import { OPENCODE_LOCAL_SESSION_HOST_ID as LOCAL_HOST_ID, OPENCODE_NODE_INVOKE_TIMEOUT_MS as NODE_TIMEOUT_MS, @@ -46,7 +45,6 @@ export { const MAX_HOSTS = 100; const MAX_CURSOR_LENGTH = 128; -const MAX_SEARCH_LENGTH = 500; const TRANSCRIPT_ITEM_TYPES = new Set([ "userMessage", "agentMessage", @@ -244,9 +242,7 @@ async function listOpenCodeNodeHost( command: OPENCODE_SESSIONS_LIST_COMMAND, params: { ...(query.limitPerHost ? { limit: query.limitPerHost } : {}), - ...(query.search?.trim() - ? { searchTerm: truncateUtf16Safe(query.search.trim(), MAX_SEARCH_LENGTH) } - : {}), + ...(query.search ? { searchTerm: query.search } : {}), ...(query.cursors?.[hostId] ? { cursor: query.cursors[hostId] } : {}), }, timeoutMs: NODE_TIMEOUT_MS, @@ -317,9 +313,6 @@ async function listOpenCodeHosts( query: Parameters[0], ): Promise { const requested = query.hostIds ? new Set(query.hostIds) : undefined; - const searchTerm = query.search - ? truncateUtf16Safe(query.search.trim(), MAX_SEARCH_LENGTH) || undefined - : undefined; const hosts: SessionCatalogHost[] = []; if ( (!requested || requested.has(LOCAL_HOST_ID)) && @@ -337,7 +330,7 @@ async function listOpenCodeHosts( connected: true, ...(await listLocalOpenCodeSessionPage({ limit: query.limitPerHost, - ...(searchTerm ? { searchTerm } : {}), + ...(query.search ? { searchTerm: query.search } : {}), cursor: query.cursors?.[LOCAL_HOST_ID], }).then((page) => setTerminalCapability(page, true))), }); diff --git a/extensions/opencode/session-catalog.test.ts b/extensions/opencode/session-catalog.test.ts index 113529ecd4e3..2883c27c008e 100644 --- a/extensions/opencode/session-catalog.test.ts +++ b/extensions/opencode/session-catalog.test.ts @@ -46,10 +46,6 @@ import { readLocalOpenCodeTranscriptPage, } from "./session-catalog.js"; -const UTF16_SEARCH_PREFIX = "x".repeat(499); -const UTF16_BOUNDARY_SEARCH = `${UTF16_SEARCH_PREFIX}πŸ˜€`; -const LONE_SURROGATE_PATTERN = - /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(? { await expect( provider!.read({ hostId: "gateway", threadId: "ses_test", limit: 2 }), ).resolves.toMatchObject({ threadId: "ses_test", items: expect.any(Array) }); - await expect(provider!.list({ search: " " })).resolves.toEqual([ + await expect(provider!.list({})).resolves.toEqual([ expect.objectContaining({ hostId: "gateway", sessions: [expect.any(Object)] }), ]); - await expect(provider!.list({ search: "x".repeat(501) })).resolves.toEqual([ - expect.objectContaining({ hostId: "gateway", sessions: [] }), - ]); - }, - ); - - it.runIf(process.platform !== "win32")( - "keeps local search needles UTF-16 safe at the length limit", - async () => { - await installFakeOpenCode("hi", UTF16_SEARCH_PREFIX); - let provider: Parameters[0] | undefined; - registerOpenCodeSessionCatalog({ - pluginConfig: {}, - runtime: { nodes: { list: vi.fn().mockResolvedValue({ nodes: [] }) } }, - registerSessionCatalog: (value: NonNullable) => { - provider = value; - }, - registerNodeHostCommand: vi.fn(), - registerNodeInvokePolicy: vi.fn(), - } as unknown as OpenClawPluginApi); - - await expect( - provider!.list({ hostIds: ["gateway"], search: UTF16_BOUNDARY_SEARCH }), - ).resolves.toEqual([ - expect.objectContaining({ - hostId: "gateway", - sessions: [expect.objectContaining({ threadId: "ses_test", name: UTF16_SEARCH_PREFIX })], - }), - ]); - await expect( - provider!.list({ hostIds: ["gateway"], search: `${"y".repeat(499)}πŸ˜€` }), - ).resolves.toEqual([expect.objectContaining({ hostId: "gateway", sessions: [] })]); }, ); @@ -411,9 +375,7 @@ describe("OpenCode session catalog", () => { registerNodeInvokePolicy: vi.fn(), } as unknown as OpenClawPluginApi); - await expect( - provider!.list({ hostIds: ["node:node-1"], search: UTF16_BOUNDARY_SEARCH }), - ).resolves.toEqual([ + await expect(provider!.list({ hostIds: ["node:node-1"], search: "remote" })).resolves.toEqual([ expect.objectContaining({ sessions: [expect.objectContaining({ threadId: "ses_remote", canOpenTerminal: true })], }), @@ -421,14 +383,10 @@ describe("OpenCode session catalog", () => { expect(invoke).toHaveBeenNthCalledWith(1, { nodeId: "node-1", command: OPENCODE_SESSIONS_LIST_COMMAND, - params: { searchTerm: UTF16_SEARCH_PREFIX }, + params: { searchTerm: "remote" }, timeoutMs: 35_000, scopes: ["operator.write"], }); - const firstRequest = invoke.mock.calls[0]?.[0] as - | { params?: { searchTerm?: string } } - | undefined; - expect(firstRequest?.params?.searchTerm).not.toMatch(LONE_SURROGATE_PATTERN); await expect( provider!.openTerminal!({ hostId: "node:node-1", threadId: "ses_remote" }), ).resolves.toEqual({ @@ -510,7 +468,7 @@ describe("OpenCode session catalog", () => { registerOpenCodeSessionCatalog(api); const catalog = provider; expect(catalog).toBeDefined(); - await catalog!.list({ hostIds: ["node:node-1"], search: " " }); + await catalog!.list({ hostIds: ["node:node-1"] }); await catalog!.read({ hostId: "node:node-1", threadId: "ses_remote" }); expect(invoke).toHaveBeenNthCalledWith(1, { diff --git a/src/gateway/server-methods/session-catalog.test.ts b/src/gateway/server-methods/session-catalog.test.ts index a2689b195212..af9dde46fc2d 100644 --- a/src/gateway/server-methods/session-catalog.test.ts +++ b/src/gateway/server-methods/session-catalog.test.ts @@ -93,6 +93,35 @@ describe("session catalog Gateway methods", () => { }); }); + it("normalizes search once before dispatching every provider", async () => { + const alphaList = vi.fn(async () => []); + const zetaList = vi.fn(async () => []); + hoisted.activeRegistry.sessionCatalogs = [ + { provider: provider("zeta", { list: zetaList }) }, + { provider: provider("alpha", { list: alphaList }) }, + ]; + + await call("sessions.catalog.list", { search: " " }); + expect(alphaList).toHaveBeenLastCalledWith(expect.objectContaining({ search: undefined })); + expect(zetaList).toHaveBeenLastCalledWith(expect.objectContaining({ search: undefined })); + + const crossingPair = `${"x".repeat(499)}πŸ˜€tail`; + await call("sessions.catalog.list", { search: ` ${crossingPair} ` }); + expect(alphaList).toHaveBeenLastCalledWith( + expect.objectContaining({ search: "x".repeat(499) }), + ); + expect(zetaList).toHaveBeenLastCalledWith(expect.objectContaining({ search: "x".repeat(499) })); + + const completePair = `${"y".repeat(498)}πŸ˜€tail`; + await call("sessions.catalog.list", { search: completePair }); + expect(alphaList).toHaveBeenLastCalledWith( + expect.objectContaining({ search: `${"y".repeat(498)}πŸ˜€` }), + ); + expect(zetaList).toHaveBeenLastCalledWith( + expect.objectContaining({ search: `${"y".repeat(498)}πŸ˜€` }), + ); + }); + it("advertises terminal opening only for providers that implement it", async () => { hoisted.activeRegistry.sessionCatalogs = [ { diff --git a/src/gateway/server-methods/session-catalog.ts b/src/gateway/server-methods/session-catalog.ts index 86947c33f5ce..f9e088011f6b 100644 --- a/src/gateway/server-methods/session-catalog.ts +++ b/src/gateway/server-methods/session-catalog.ts @@ -1,4 +1,5 @@ import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; +import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice"; import { ErrorCodes, errorShape, @@ -25,6 +26,15 @@ import { resolveAgentIdOrRespondError } from "./agent-id-shared.js"; import type { GatewayRequestHandlers, RespondFn } from "./types.js"; import { assertValidParams } from "./validation.js"; +const SESSION_CATALOG_SEARCH_MAX_UTF16_UNITS = 500; + +function normalizeSessionCatalogSearch(search: string | undefined): string | undefined { + const normalized = normalizeOptionalString(search); + return normalized + ? truncateUtf16Safe(normalized, SESSION_CATALOG_SEARCH_MAX_UTF16_UNITS) + : undefined; +} + function catalogError(error: unknown): { code: string; message: string } { const record = error && typeof error === "object" ? (error as Record) : undefined; @@ -178,13 +188,14 @@ export const sessionCatalogHandlers: GatewayRequestHandlers = { if (!resolvedAgent) { return; } + const search = normalizeSessionCatalogSearch(request.search); const catalogList = await Promise.all( selected.map(async (provider): Promise => { const createTarget = resolveProviderCreateTarget(provider, resolvedAgent.agentId); const createSession = createTarget.ok ? { model: createTarget.target.model } : undefined; try { const hosts = await provider.list({ - search: request.search, + search, limitPerHost: request.limitPerHost, hostIds: request.hostIds, ...("cursors" in request ? { cursors: request.cursors } : {}), diff --git a/src/plugins/session-catalog.ts b/src/plugins/session-catalog.ts index 97d8a5d50865..f6a7a5815077 100644 --- a/src/plugins/session-catalog.ts +++ b/src/plugins/session-catalog.ts @@ -7,6 +7,7 @@ import type { } from "../../packages/gateway-protocol/src/schema/sessions-catalog.js"; export type SessionCatalogListProviderParams = { + /** Trimmed, non-empty search capped at 500 UTF-16 code units by the gateway. */ search?: string; limitPerHost?: number; hostIds?: string[];