Skip to content

Commit 026b688

Browse files
steipeteLeon-SK668
andauthored
refactor(session-catalog): normalize search at gateway (#108240)
Co-authored-by: Leon-SK668 <[email protected]>
1 parent 54f70c5 commit 026b688

7 files changed

Lines changed: 54 additions & 108 deletions

File tree

extensions/acpx/src/pi-session-catalog-plugin.ts

Lines changed: 2 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,6 @@ import type {
1919
SessionsCatalogReadResult,
2020
} from "openclaw/plugin-sdk/session-catalog";
2121
import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime";
22-
import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime";
2322
import {
2423
listLocalPiSessionPage,
2524
optionalPiString,
@@ -37,7 +36,6 @@ const LOCAL_HOST_ID = "gateway";
3736
const MAX_PAGE_LIMIT = 100;
3837
const MAX_HOSTS = 100;
3938
const MAX_CURSOR_LENGTH = 128;
40-
const MAX_SEARCH_LENGTH = 500;
4139
const NODE_TIMEOUT_MS = 20_000;
4240
const SESSION_ID_PATTERN = /^(?!-)[A-Za-z0-9._:-]{1,256}$/u;
4341
const TRANSCRIPT_ITEM_TYPES = new Set([
@@ -251,9 +249,7 @@ async function listPiNodeHost(
251249
command: PI_SESSIONS_LIST_COMMAND,
252250
params: {
253251
...(query.limitPerHost ? { limit: query.limitPerHost } : {}),
254-
...(query.search?.trim()
255-
? { searchTerm: truncateUtf16Safe(query.search.trim(), MAX_SEARCH_LENGTH) }
256-
: {}),
252+
...(query.search ? { searchTerm: query.search } : {}),
257253
...(query.cursors?.[hostId] ? { cursor: query.cursors[hostId] } : {}),
258254
},
259255
timeoutMs: NODE_TIMEOUT_MS,
@@ -321,9 +317,6 @@ async function listPiHosts(
321317
query: Parameters<SessionCatalogProvider["list"]>[0],
322318
): Promise<SessionCatalogHost[]> {
323319
const requested = query.hostIds ? new Set(query.hostIds) : undefined;
324-
const searchTerm = query.search
325-
? truncateUtf16Safe(query.search.trim(), MAX_SEARCH_LENGTH) || undefined
326-
: undefined;
327320
const hosts: SessionCatalogHost[] = [];
328321
if ((!requested || requested.has(LOCAL_HOST_ID)) && piSessionStoreAvailable(process.env)) {
329322
try {
@@ -334,7 +327,7 @@ async function listPiHosts(
334327
connected: true,
335328
...(await listLocalPiSessionPage({
336329
limit: query.limitPerHost,
337-
...(searchTerm ? { searchTerm } : {}),
330+
...(query.search ? { searchTerm: query.search } : {}),
338331
cursor: query.cursors?.[LOCAL_HOST_ID],
339332
}).then((page) =>
340333
setTerminalCapability(

extensions/acpx/src/pi-session-catalog.test.ts

Lines changed: 4 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -39,10 +39,6 @@ import { piSessionStore } from "./pi-session-paths.js";
3939
const PI_SESSIONS_LIST_COMMAND = "acpx.pi.sessions.list.v1";
4040
const PI_SESSION_READ_COMMAND = "acpx.pi.sessions.read.v1";
4141
const PI_TERMINAL_RESUME_COMMAND = "acpx.pi.terminal.resume.v1";
42-
const UTF16_SEARCH_PREFIX = "x".repeat(499);
43-
const UTF16_BOUNDARY_SEARCH = `${UTF16_SEARCH_PREFIX}😀`;
44-
const LONE_SURROGATE_PATTERN =
45-
/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/u;
4642

4743
const temporaryDirectories: string[] = [];
4844
const originalSessionDir = process.env.PI_CODING_AGENT_SESSION_DIR;
@@ -282,38 +278,9 @@ describe("Pi session catalog", () => {
282278
await expect(
283279
provider!.read({ hostId: "gateway", threadId: "pi-session", limit: 2 }),
284280
).resolves.toMatchObject({ threadId: "pi-session", items: expect.any(Array) });
285-
await expect(provider!.list({ search: " " })).resolves.toEqual([
281+
await expect(provider!.list({})).resolves.toEqual([
286282
expect.objectContaining({ hostId: "gateway", sessions: [expect.any(Object)] }),
287283
]);
288-
await expect(provider!.list({ search: "x".repeat(501) })).resolves.toEqual([
289-
expect.objectContaining({ hostId: "gateway", sessions: [] }),
290-
]);
291-
});
292-
293-
it("keeps local search needles UTF-16 safe at the length limit", async () => {
294-
await createPiStore("hi", UTF16_SEARCH_PREFIX);
295-
let provider: Parameters<OpenClawPluginApi["registerSessionCatalog"]>[0] | undefined;
296-
registerPiSessionCatalog({
297-
pluginConfig: {},
298-
runtime: { nodes: { list: vi.fn().mockResolvedValue({ nodes: [] }) } },
299-
registerSessionCatalog: (value: NonNullable<typeof provider>) => {
300-
provider = value;
301-
},
302-
registerNodeHostCommand: vi.fn(),
303-
registerNodeInvokePolicy: vi.fn(),
304-
} as unknown as OpenClawPluginApi);
305-
306-
await expect(
307-
provider!.list({ hostIds: ["gateway"], search: UTF16_BOUNDARY_SEARCH }),
308-
).resolves.toEqual([
309-
expect.objectContaining({
310-
hostId: "gateway",
311-
sessions: [expect.objectContaining({ threadId: "pi-session", name: UTF16_SEARCH_PREFIX })],
312-
}),
313-
]);
314-
await expect(
315-
provider!.list({ hostIds: ["gateway"], search: `${"y".repeat(499)}😀` }),
316-
).resolves.toEqual([expect.objectContaining({ hostId: "gateway", sessions: [] })]);
317284
});
318285

319286
it("summarizes and pages a large session within transport limits", async () => {
@@ -775,24 +742,18 @@ describe("Pi session catalog", () => {
775742
registerNodeInvokePolicy: vi.fn(),
776743
} as unknown as OpenClawPluginApi);
777744

778-
await expect(
779-
provider!.list({ hostIds: ["node:node-1"], search: UTF16_BOUNDARY_SEARCH }),
780-
).resolves.toEqual([
745+
await expect(provider!.list({ hostIds: ["node:node-1"], search: "remote" })).resolves.toEqual([
781746
expect.objectContaining({
782747
sessions: [expect.objectContaining({ threadId: "pi-remote", canOpenTerminal: true })],
783748
}),
784749
]);
785750
expect(invoke).toHaveBeenNthCalledWith(1, {
786751
nodeId: "node-1",
787752
command: PI_SESSIONS_LIST_COMMAND,
788-
params: { searchTerm: UTF16_SEARCH_PREFIX },
753+
params: { searchTerm: "remote" },
789754
timeoutMs: 20_000,
790755
scopes: ["operator.write"],
791756
});
792-
const firstRequest = invoke.mock.calls[0]?.[0] as
793-
| { params?: { searchTerm?: string } }
794-
| undefined;
795-
expect(firstRequest?.params?.searchTerm).not.toMatch(LONE_SURROGATE_PATTERN);
796757
await expect(
797758
provider!.openTerminal!({ hostId: "node:node-1", threadId: "pi-remote" }),
798759
).resolves.toEqual({
@@ -873,7 +834,7 @@ describe("Pi session catalog", () => {
873834
registerPiSessionCatalog(api);
874835
const catalog = provider;
875836
expect(catalog).toBeDefined();
876-
await catalog!.list({ hostIds: ["node:node-1"], search: " " });
837+
await catalog!.list({ hostIds: ["node:node-1"] });
877838
await catalog!.read({ hostId: "node:node-1", threadId: "pi-remote" });
878839

879840
expect(invoke).toHaveBeenNthCalledWith(1, {

extensions/opencode/session-catalog-plugin.ts

Lines changed: 2 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,6 @@ import type {
1616
SessionsCatalogReadResult,
1717
} from "openclaw/plugin-sdk/session-catalog";
1818
import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime";
19-
import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime";
2019
import {
2120
OPENCODE_LOCAL_SESSION_HOST_ID as LOCAL_HOST_ID,
2221
OPENCODE_NODE_INVOKE_TIMEOUT_MS as NODE_TIMEOUT_MS,
@@ -46,7 +45,6 @@ export {
4645

4746
const MAX_HOSTS = 100;
4847
const MAX_CURSOR_LENGTH = 128;
49-
const MAX_SEARCH_LENGTH = 500;
5048
const TRANSCRIPT_ITEM_TYPES = new Set([
5149
"userMessage",
5250
"agentMessage",
@@ -244,9 +242,7 @@ async function listOpenCodeNodeHost(
244242
command: OPENCODE_SESSIONS_LIST_COMMAND,
245243
params: {
246244
...(query.limitPerHost ? { limit: query.limitPerHost } : {}),
247-
...(query.search?.trim()
248-
? { searchTerm: truncateUtf16Safe(query.search.trim(), MAX_SEARCH_LENGTH) }
249-
: {}),
245+
...(query.search ? { searchTerm: query.search } : {}),
250246
...(query.cursors?.[hostId] ? { cursor: query.cursors[hostId] } : {}),
251247
},
252248
timeoutMs: NODE_TIMEOUT_MS,
@@ -317,9 +313,6 @@ async function listOpenCodeHosts(
317313
query: Parameters<SessionCatalogProvider["list"]>[0],
318314
): Promise<SessionCatalogHost[]> {
319315
const requested = query.hostIds ? new Set(query.hostIds) : undefined;
320-
const searchTerm = query.search
321-
? truncateUtf16Safe(query.search.trim(), MAX_SEARCH_LENGTH) || undefined
322-
: undefined;
323316
const hosts: SessionCatalogHost[] = [];
324317
if (
325318
(!requested || requested.has(LOCAL_HOST_ID)) &&
@@ -337,7 +330,7 @@ async function listOpenCodeHosts(
337330
connected: true,
338331
...(await listLocalOpenCodeSessionPage({
339332
limit: query.limitPerHost,
340-
...(searchTerm ? { searchTerm } : {}),
333+
...(query.search ? { searchTerm: query.search } : {}),
341334
cursor: query.cursors?.[LOCAL_HOST_ID],
342335
}).then((page) => setTerminalCapability(page, true))),
343336
});

extensions/opencode/session-catalog.test.ts

Lines changed: 4 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -46,10 +46,6 @@ import {
4646
readLocalOpenCodeTranscriptPage,
4747
} from "./session-catalog.js";
4848

49-
const UTF16_SEARCH_PREFIX = "x".repeat(499);
50-
const UTF16_BOUNDARY_SEARCH = `${UTF16_SEARCH_PREFIX}😀`;
51-
const LONE_SURROGATE_PATTERN =
52-
/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/u;
5349
const temporaryDirectories: string[] = [];
5450
const originalPath = process.env.PATH;
5551
const originalUnrelatedEnv = process.env.CATALOG_UNRELATED_ENV;
@@ -201,41 +197,9 @@ describe("OpenCode session catalog", () => {
201197
await expect(
202198
provider!.read({ hostId: "gateway", threadId: "ses_test", limit: 2 }),
203199
).resolves.toMatchObject({ threadId: "ses_test", items: expect.any(Array) });
204-
await expect(provider!.list({ search: " " })).resolves.toEqual([
200+
await expect(provider!.list({})).resolves.toEqual([
205201
expect.objectContaining({ hostId: "gateway", sessions: [expect.any(Object)] }),
206202
]);
207-
await expect(provider!.list({ search: "x".repeat(501) })).resolves.toEqual([
208-
expect.objectContaining({ hostId: "gateway", sessions: [] }),
209-
]);
210-
},
211-
);
212-
213-
it.runIf(process.platform !== "win32")(
214-
"keeps local search needles UTF-16 safe at the length limit",
215-
async () => {
216-
await installFakeOpenCode("hi", UTF16_SEARCH_PREFIX);
217-
let provider: Parameters<OpenClawPluginApi["registerSessionCatalog"]>[0] | undefined;
218-
registerOpenCodeSessionCatalog({
219-
pluginConfig: {},
220-
runtime: { nodes: { list: vi.fn().mockResolvedValue({ nodes: [] }) } },
221-
registerSessionCatalog: (value: NonNullable<typeof provider>) => {
222-
provider = value;
223-
},
224-
registerNodeHostCommand: vi.fn(),
225-
registerNodeInvokePolicy: vi.fn(),
226-
} as unknown as OpenClawPluginApi);
227-
228-
await expect(
229-
provider!.list({ hostIds: ["gateway"], search: UTF16_BOUNDARY_SEARCH }),
230-
).resolves.toEqual([
231-
expect.objectContaining({
232-
hostId: "gateway",
233-
sessions: [expect.objectContaining({ threadId: "ses_test", name: UTF16_SEARCH_PREFIX })],
234-
}),
235-
]);
236-
await expect(
237-
provider!.list({ hostIds: ["gateway"], search: `${"y".repeat(499)}😀` }),
238-
).resolves.toEqual([expect.objectContaining({ hostId: "gateway", sessions: [] })]);
239203
},
240204
);
241205

@@ -411,24 +375,18 @@ describe("OpenCode session catalog", () => {
411375
registerNodeInvokePolicy: vi.fn(),
412376
} as unknown as OpenClawPluginApi);
413377

414-
await expect(
415-
provider!.list({ hostIds: ["node:node-1"], search: UTF16_BOUNDARY_SEARCH }),
416-
).resolves.toEqual([
378+
await expect(provider!.list({ hostIds: ["node:node-1"], search: "remote" })).resolves.toEqual([
417379
expect.objectContaining({
418380
sessions: [expect.objectContaining({ threadId: "ses_remote", canOpenTerminal: true })],
419381
}),
420382
]);
421383
expect(invoke).toHaveBeenNthCalledWith(1, {
422384
nodeId: "node-1",
423385
command: OPENCODE_SESSIONS_LIST_COMMAND,
424-
params: { searchTerm: UTF16_SEARCH_PREFIX },
386+
params: { searchTerm: "remote" },
425387
timeoutMs: 35_000,
426388
scopes: ["operator.write"],
427389
});
428-
const firstRequest = invoke.mock.calls[0]?.[0] as
429-
| { params?: { searchTerm?: string } }
430-
| undefined;
431-
expect(firstRequest?.params?.searchTerm).not.toMatch(LONE_SURROGATE_PATTERN);
432390
await expect(
433391
provider!.openTerminal!({ hostId: "node:node-1", threadId: "ses_remote" }),
434392
).resolves.toEqual({
@@ -510,7 +468,7 @@ describe("OpenCode session catalog", () => {
510468
registerOpenCodeSessionCatalog(api);
511469
const catalog = provider;
512470
expect(catalog).toBeDefined();
513-
await catalog!.list({ hostIds: ["node:node-1"], search: " " });
471+
await catalog!.list({ hostIds: ["node:node-1"] });
514472
await catalog!.read({ hostId: "node:node-1", threadId: "ses_remote" });
515473

516474
expect(invoke).toHaveBeenNthCalledWith(1, {

src/gateway/server-methods/session-catalog.test.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,35 @@ describe("session catalog Gateway methods", () => {
9393
});
9494
});
9595

96+
it("normalizes search once before dispatching every provider", async () => {
97+
const alphaList = vi.fn(async () => []);
98+
const zetaList = vi.fn(async () => []);
99+
hoisted.activeRegistry.sessionCatalogs = [
100+
{ provider: provider("zeta", { list: zetaList }) },
101+
{ provider: provider("alpha", { list: alphaList }) },
102+
];
103+
104+
await call("sessions.catalog.list", { search: " " });
105+
expect(alphaList).toHaveBeenLastCalledWith(expect.objectContaining({ search: undefined }));
106+
expect(zetaList).toHaveBeenLastCalledWith(expect.objectContaining({ search: undefined }));
107+
108+
const crossingPair = `${"x".repeat(499)}😀tail`;
109+
await call("sessions.catalog.list", { search: ` ${crossingPair} ` });
110+
expect(alphaList).toHaveBeenLastCalledWith(
111+
expect.objectContaining({ search: "x".repeat(499) }),
112+
);
113+
expect(zetaList).toHaveBeenLastCalledWith(expect.objectContaining({ search: "x".repeat(499) }));
114+
115+
const completePair = `${"y".repeat(498)}😀tail`;
116+
await call("sessions.catalog.list", { search: completePair });
117+
expect(alphaList).toHaveBeenLastCalledWith(
118+
expect.objectContaining({ search: `${"y".repeat(498)}😀` }),
119+
);
120+
expect(zetaList).toHaveBeenLastCalledWith(
121+
expect.objectContaining({ search: `${"y".repeat(498)}😀` }),
122+
);
123+
});
124+
96125
it("advertises terminal opening only for providers that implement it", async () => {
97126
hoisted.activeRegistry.sessionCatalogs = [
98127
{

src/gateway/server-methods/session-catalog.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
2+
import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice";
23
import {
34
ErrorCodes,
45
errorShape,
@@ -25,6 +26,15 @@ import { resolveAgentIdOrRespondError } from "./agent-id-shared.js";
2526
import type { GatewayRequestHandlers, RespondFn } from "./types.js";
2627
import { assertValidParams } from "./validation.js";
2728

29+
const SESSION_CATALOG_SEARCH_MAX_UTF16_UNITS = 500;
30+
31+
function normalizeSessionCatalogSearch(search: string | undefined): string | undefined {
32+
const normalized = normalizeOptionalString(search);
33+
return normalized
34+
? truncateUtf16Safe(normalized, SESSION_CATALOG_SEARCH_MAX_UTF16_UNITS)
35+
: undefined;
36+
}
37+
2838
function catalogError(error: unknown): { code: string; message: string } {
2939
const record =
3040
error && typeof error === "object" ? (error as Record<string, unknown>) : undefined;
@@ -178,13 +188,14 @@ export const sessionCatalogHandlers: GatewayRequestHandlers = {
178188
if (!resolvedAgent) {
179189
return;
180190
}
191+
const search = normalizeSessionCatalogSearch(request.search);
181192
const catalogList = await Promise.all(
182193
selected.map(async (provider): Promise<SessionCatalog> => {
183194
const createTarget = resolveProviderCreateTarget(provider, resolvedAgent.agentId);
184195
const createSession = createTarget.ok ? { model: createTarget.target.model } : undefined;
185196
try {
186197
const hosts = await provider.list({
187-
search: request.search,
198+
search,
188199
limitPerHost: request.limitPerHost,
189200
hostIds: request.hostIds,
190201
...("cursors" in request ? { cursors: request.cursors } : {}),

src/plugins/session-catalog.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import type {
77
} from "../../packages/gateway-protocol/src/schema/sessions-catalog.js";
88

99
export type SessionCatalogListProviderParams = {
10+
/** Trimmed, non-empty search capped at 500 UTF-16 code units by the gateway. */
1011
search?: string;
1112
limitPerHost?: number;
1213
hostIds?: string[];

0 commit comments

Comments
 (0)