diff --git a/extensions/acpx/src/pi-session-catalog-plugin.ts b/extensions/acpx/src/pi-session-catalog-plugin.ts index b2e91e6b77f9..e130e80f9af1 100644 --- a/extensions/acpx/src/pi-session-catalog-plugin.ts +++ b/extensions/acpx/src/pi-session-catalog-plugin.ts @@ -19,6 +19,7 @@ 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, @@ -241,7 +242,7 @@ async function listPiNodeHost( params: { ...(query.limitPerHost ? { limit: query.limitPerHost } : {}), ...(query.search?.trim() - ? { searchTerm: query.search.trim().slice(0, MAX_SEARCH_LENGTH) } + ? { searchTerm: truncateUtf16Safe(query.search.trim(), MAX_SEARCH_LENGTH) } : {}), ...(query.cursors?.[hostId] ? { cursor: query.cursors[hostId] } : {}), }, @@ -310,7 +311,9 @@ async function listPiHosts( query: Parameters[0], ): Promise { const requested = query.hostIds ? new Set(query.hostIds) : undefined; - const searchTerm = query.search?.trim().slice(0, MAX_SEARCH_LENGTH) || 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 { diff --git a/extensions/acpx/src/pi-session-catalog.test.ts b/extensions/acpx/src/pi-session-catalog.test.ts index 73c4cee2d345..f008536fa753 100644 --- a/extensions/acpx/src/pi-session-catalog.test.ts +++ b/extensions/acpx/src/pi-session-catalog.test.ts @@ -32,6 +32,10 @@ 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])|(? { +async function createPiStore( + assistantText = "hi", + sessionName = "Pi catalog session", +): Promise { const directory = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-pi-catalog-")); temporaryDirectories.push(directory); process.env.PI_CODING_AGENT_SESSION_DIR = directory; @@ -94,7 +101,7 @@ async function createPiStore(assistantText = "hi"): Promise { id: "info-1", parentId: "tool-1", timestamp: "2026-07-13T10:00:04.000Z", - name: "Pi catalog session", + name: sessionName, }, ]; await fs.writeFile( @@ -276,6 +283,32 @@ describe("Pi session catalog", () => { ]); }); + 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 () => { await createPiStore("x".repeat(600 * 1024)); const listed = await listLocalPiSessionPage({ limit: 20 }); @@ -735,11 +768,24 @@ describe("Pi session catalog", () => { registerNodeInvokePolicy: vi.fn(), } as unknown as OpenClawPluginApi); - await expect(provider!.list({ hostIds: ["node:node-1"] })).resolves.toEqual([ + await expect( + provider!.list({ hostIds: ["node:node-1"], search: UTF16_BOUNDARY_SEARCH }), + ).resolves.toEqual([ expect.objectContaining({ sessions: [expect.objectContaining({ threadId: "pi-remote", canOpenTerminal: true })], }), ]); + expect(invoke).toHaveBeenNthCalledWith(1, { + nodeId: "node-1", + command: PI_SESSIONS_LIST_COMMAND, + params: { searchTerm: UTF16_SEARCH_PREFIX }, + 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({ diff --git a/extensions/opencode/session-catalog-plugin.ts b/extensions/opencode/session-catalog-plugin.ts index 0901a3c37b33..54a3efb55db1 100644 --- a/extensions/opencode/session-catalog-plugin.ts +++ b/extensions/opencode/session-catalog-plugin.ts @@ -16,6 +16,7 @@ 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, @@ -244,7 +245,7 @@ async function listOpenCodeNodeHost( params: { ...(query.limitPerHost ? { limit: query.limitPerHost } : {}), ...(query.search?.trim() - ? { searchTerm: query.search.trim().slice(0, MAX_SEARCH_LENGTH) } + ? { searchTerm: truncateUtf16Safe(query.search.trim(), MAX_SEARCH_LENGTH) } : {}), ...(query.cursors?.[hostId] ? { cursor: query.cursors[hostId] } : {}), }, @@ -316,7 +317,9 @@ async function listOpenCodeHosts( query: Parameters[0], ): Promise { const requested = query.hostIds ? new Set(query.hostIds) : undefined; - const searchTerm = query.search?.trim().slice(0, MAX_SEARCH_LENGTH) || undefined; + const searchTerm = query.search + ? truncateUtf16Safe(query.search.trim(), MAX_SEARCH_LENGTH) || undefined + : undefined; const hosts: SessionCatalogHost[] = []; if ( (!requested || requested.has(LOCAL_HOST_ID)) && diff --git a/extensions/opencode/session-catalog.test.ts b/extensions/opencode/session-catalog.test.ts index 880baf1d0a01..803dbf774432 100644 --- a/extensions/opencode/session-catalog.test.ts +++ b/extensions/opencode/session-catalog.test.ts @@ -39,17 +39,24 @@ 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])|(? { +async function installFakeOpenCode( + assistantText = "hi", + sessionTitle = "Catalog session", +): Promise { const directory = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-opencode-catalog-")); temporaryDirectories.push(directory); const executable = path.join(directory, "opencode"); const session = { id: "ses_test", - title: "Catalog session", + title: sessionTitle, created: 1_700_000_000_000, updated: 1_700_000_001_000, projectId: "project", @@ -196,6 +203,35 @@ describe("OpenCode session catalog", () => { }, ); + 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: [] })]); + }, + ); + it.runIf(process.platform !== "win32")( "keeps oversized transcript items below the node payload budget", async () => { @@ -368,11 +404,24 @@ describe("OpenCode session catalog", () => { registerNodeInvokePolicy: vi.fn(), } as unknown as OpenClawPluginApi); - await expect(provider!.list({ hostIds: ["node:node-1"] })).resolves.toEqual([ + await expect( + provider!.list({ hostIds: ["node:node-1"], search: UTF16_BOUNDARY_SEARCH }), + ).resolves.toEqual([ expect.objectContaining({ sessions: [expect.objectContaining({ threadId: "ses_remote", canOpenTerminal: true })], }), ]); + expect(invoke).toHaveBeenNthCalledWith(1, { + nodeId: "node-1", + command: OPENCODE_SESSIONS_LIST_COMMAND, + params: { searchTerm: UTF16_SEARCH_PREFIX }, + 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({