mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
refactor(session-catalog): normalize search at gateway (#108240)
Co-authored-by: Leon-SK668 <0668001470@xydigit.com>
This commit is contained in:
committed by
GitHub
parent
54f70c5d8c
commit
026b688161
@@ -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<SessionCatalogProvider["list"]>[0],
|
||||
): Promise<SessionCatalogHost[]> {
|
||||
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(
|
||||
|
||||
@@ -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])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/u;
|
||||
|
||||
const temporaryDirectories: string[] = [];
|
||||
const originalSessionDir = process.env.PI_CODING_AGENT_SESSION_DIR;
|
||||
@@ -282,38 +278,9 @@ describe("Pi session catalog", () => {
|
||||
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<OpenClawPluginApi["registerSessionCatalog"]>[0] | undefined;
|
||||
registerPiSessionCatalog({
|
||||
pluginConfig: {},
|
||||
runtime: { nodes: { list: vi.fn().mockResolvedValue({ nodes: [] }) } },
|
||||
registerSessionCatalog: (value: NonNullable<typeof provider>) => {
|
||||
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, {
|
||||
|
||||
@@ -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<SessionCatalogProvider["list"]>[0],
|
||||
): Promise<SessionCatalogHost[]> {
|
||||
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))),
|
||||
});
|
||||
|
||||
@@ -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])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/u;
|
||||
const temporaryDirectories: string[] = [];
|
||||
const originalPath = process.env.PATH;
|
||||
const originalUnrelatedEnv = process.env.CATALOG_UNRELATED_ENV;
|
||||
@@ -201,41 +197,9 @@ describe("OpenCode session catalog", () => {
|
||||
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<OpenClawPluginApi["registerSessionCatalog"]>[0] | undefined;
|
||||
registerOpenCodeSessionCatalog({
|
||||
pluginConfig: {},
|
||||
runtime: { nodes: { list: vi.fn().mockResolvedValue({ nodes: [] }) } },
|
||||
registerSessionCatalog: (value: NonNullable<typeof provider>) => {
|
||||
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, {
|
||||
|
||||
@@ -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 = [
|
||||
{
|
||||
|
||||
@@ -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<string, unknown>) : 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<SessionCatalog> => {
|
||||
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 } : {}),
|
||||
|
||||
@@ -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[];
|
||||
|
||||
Reference in New Issue
Block a user