diff --git a/src/triggers/api.ts b/src/triggers/api.ts index 7b6c2bf2b..ea79d442c 100644 --- a/src/triggers/api.ts +++ b/src/triggers/api.ts @@ -36,6 +36,11 @@ function parseOptionalInt(raw: unknown): number | undefined { return Number.isFinite(n) ? n : undefined; } +function parsePositiveLimit(raw: unknown): number | undefined { + const n = parseOptionalInt(raw); + return n !== undefined && n > 0 ? n : undefined; +} + function checkAuth( req: ApiRequest, secret: string | undefined, @@ -502,7 +507,9 @@ export function registerApiTriggers( sessions.sort((a, b) => (b.startedAt || "").localeCompare(a.startedAt || ""), ); - return { status_code: 200, body: { success: true, sessions } }; + const limit = parsePositiveLimit(req.query_params?.["limit"]); + const limited = limit !== undefined ? sessions.slice(0, limit) : sessions; + return { status_code: 200, body: { success: true, sessions: limited } }; }, ); sdk.registerTrigger({ @@ -823,12 +830,14 @@ export function registerApiTriggers( const filtered = filterAgentId ? sessions.filter((s) => s.agentId === filterAgentId) : sessions; + const limit = parsePositiveLimit(req.query_params?.["limit"]); + const sliced = limit !== undefined ? filtered.slice(0, limit) : filtered; const summaries = await Promise.all( - filtered.map((s) => + sliced.map((s) => kv.get(KV.summaries, s.id).catch(() => null), ), ); - const withSummary = filtered.map((s, i) => + const withSummary = sliced.map((s, i) => summaries[i] ? { ...s, summary: summaries[i] } : s, ); return { status_code: 200, body: { sessions: withSummary } }; diff --git a/test/sessions-limit.test.ts b/test/sessions-limit.test.ts new file mode 100644 index 000000000..dc9e2e879 --- /dev/null +++ b/test/sessions-limit.test.ts @@ -0,0 +1,56 @@ +import { describe, it, expect } from "vitest"; +import { readFileSync } from "node:fs"; + +// GET /agentmemory/sessions and GET /agentmemory/replay/sessions must +// accept a `limit` query param so the viewer and CLI can page through +// large session tables without forcing the engine to ship every +// record on every request (#1022). +// +// Source-level assertions: the HTTP endpoint is defined inside a +// single big registerFunction call and spinning up the iii engine +// just to test a query param is out of scope for this fix. +describe("sessions endpoint limit query param (#1022)", () => { + const api = readFileSync("src/triggers/api.ts", "utf-8"); + + it("api::sessions uses parsePositiveLimit for the limit query param", () => { + expect(api).toMatch( + /sdk\.registerFunction\(\s*"api::sessions"[\s\S]*?parsePositiveLimit\(req\.query_params\?\.\["limit"\]\)/, + ); + }); + + it("api::sessions slices filtered before summary lookups", () => { + expect(api).toMatch( + /sdk\.registerFunction\(\s*"api::sessions"[\s\S]*?sliced\.map\([\s\S]*?kv\.get/, + ); + }); + + it("api::sessions falls back to full list when limit is missing or invalid", () => { + expect(api).toMatch( + /sdk\.registerFunction\(\s*"api::sessions"[\s\S]*?limit !== undefined \? filtered\.slice/, + ); + }); + + it("api::replay::sessions also honours limit (companion fix)", () => { + expect(api).toMatch( + /sdk\.registerFunction\(\s*"api::replay::sessions"[\s\S]*?parsePositiveLimit\(req\.query_params\?\.\["limit"\]\)/, + ); + expect(api).toMatch( + /sdk\.registerFunction\(\s*"api::replay::sessions"[\s\S]*?sessions\.slice\(0,\s*limit\)/, + ); + }); + + it("api::replay::sessions falls back to full list when limit is missing or invalid", () => { + expect(api).toMatch( + /sdk\.registerFunction\(\s*"api::replay::sessions"[\s\S]*?limit !== undefined \? sessions\.slice/, + ); + }); + + it("parsePositiveLimit helper is defined and delegates to parseOptionalInt", () => { + expect(api).toMatch( + /function parsePositiveLimit\(raw: unknown\): number \| undefined/, + ); + expect(api).toMatch( + /parsePositiveLimit[\s\S]*?parseOptionalInt\(raw\)/, + ); + }); +}); \ No newline at end of file