From 4e2303ee5f5ed4bc7678a8aa4ad077d5820ad42e Mon Sep 17 00:00:00 2001 From: Zeeshan Ahmad Date: Thu, 9 Jul 2026 20:25:58 +0500 Subject: [PATCH 1/2] fix(api): honour limit query param on /sessions and /replay/sessions (#1022) Signed-off-by: Zeeshan Ahmad --- src/triggers/api.ts | 31 +++++++++++++++++++++++-- test/sessions-limit.test.ts | 46 +++++++++++++++++++++++++++++++++++++ 2 files changed, 75 insertions(+), 2 deletions(-) create mode 100644 test/sessions-limit.test.ts diff --git a/src/triggers/api.ts b/src/triggers/api.ts index 7b6c2bf2b..e87df1909 100644 --- a/src/triggers/api.ts +++ b/src/triggers/api.ts @@ -502,7 +502,21 @@ export function registerApiTriggers( sessions.sort((a, b) => (b.startedAt || "").localeCompare(a.startedAt || ""), ); - return { status_code: 200, body: { success: true, sessions } }; + // Honour ?limit=N (>=1) on the replay list endpoint so the + // real-time viewer can page through the most recent N sessions + // without forcing the engine to ship the full table. Companion + // fix to #1022 (which targeted /agentmemory/sessions); the + // replay endpoint had the same gap. + const rawLimit = req.query_params?.["limit"]; + const parsedLimit = + typeof rawLimit === "string" && rawLimit.length > 0 + ? parseInt(rawLimit, 10) + : NaN; + const limited = + Number.isFinite(parsedLimit) && parsedLimit > 0 + ? sessions.slice(0, parsedLimit) + : sessions; + return { status_code: 200, body: { success: true, sessions: limited } }; }, ); sdk.registerTrigger({ @@ -831,7 +845,20 @@ export function registerApiTriggers( const withSummary = filtered.map((s, i) => summaries[i] ? { ...s, summary: summaries[i] } : s, ); - return { status_code: 200, body: { sessions: withSummary } }; + // Honour ?limit=N (>=1) on the HTTP list endpoint so callers don't + // have to materialize the full session table just to paginate. Fixes + // #1022. Negative or non-numeric values fall back to "no limit" + // (return everything) so a bad query never produces an empty result. + const rawLimit = req.query_params?.["limit"]; + const parsedLimit = + typeof rawLimit === "string" && rawLimit.length > 0 + ? parseInt(rawLimit, 10) + : NaN; + const limited = + Number.isFinite(parsedLimit) && parsedLimit > 0 + ? withSummary.slice(0, parsedLimit) + : withSummary; + return { status_code: 200, body: { sessions: limited } }; }, ); sdk.registerTrigger({ diff --git a/test/sessions-limit.test.ts b/test/sessions-limit.test.ts new file mode 100644 index 000000000..54c665e21 --- /dev/null +++ b/test/sessions-limit.test.ts @@ -0,0 +1,46 @@ +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). +// +// These are source-level assertions because the HTTP endpoint is +// defined inside a single big registerFunction call and we don't want +// to spin up the iii engine just to test a query param. A future +// integration test under test/integration.test.ts can verify the +// end-to-end shape. +describe("sessions endpoint limit query param (#1022)", () => { + const api = readFileSync("src/triggers/api.ts", "utf-8"); + + it("api::sessions reads the limit query param", () => { + expect(api).toMatch( + /sdk\.registerFunction\(\s*"api::sessions"[\s\S]*?query_params\?\.\["limit"\]/, + ); + }); + + it("api::sessions slices the response by limit", () => { + expect(api).toMatch( + /sdk\.registerFunction\(\s*"api::sessions"[\s\S]*?withSummary\.slice\(0,\s*parsedLimit\)/, + ); + }); + + it("api::sessions falls back to full list when limit is missing or invalid", () => { + // The fix must not regress the no-limit behaviour: an absent or + // non-numeric ?limit must return the full filtered list, never an + // empty result. + expect(api).toMatch( + /sdk\.registerFunction\(\s*"api::sessions"[\s\S]*?parsedLimit\s*>\s*0/, + ); + }); + + it("api::replay::sessions also honours limit (companion fix)", () => { + expect(api).toMatch( + /sdk\.registerFunction\(\s*"api::replay::sessions"[\s\S]*?query_params\?\.\["limit"\]/, + ); + expect(api).toMatch( + /sdk\.registerFunction\(\s*"api::replay::sessions"[\s\S]*?sessions\.slice\(0,\s*parsedLimit\)/, + ); + }); +}); From 020f897bb9c3441300dc2898ee0bc4e59a13ec58 Mon Sep 17 00:00:00 2001 From: Zeeshan Ahmad Date: Thu, 9 Jul 2026 21:29:56 +0500 Subject: [PATCH 2/2] refactor(api): extract parsePositiveLimit helper, slice before I/O, address review feedback (#1022) Signed-off-by: Zeeshan Ahmad --- src/triggers/api.ts | 42 +++++++++++-------------------------- test/sessions-limit.test.ts | 42 +++++++++++++++++++++++-------------- 2 files changed, 38 insertions(+), 46 deletions(-) diff --git a/src/triggers/api.ts b/src/triggers/api.ts index e87df1909..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,20 +507,8 @@ export function registerApiTriggers( sessions.sort((a, b) => (b.startedAt || "").localeCompare(a.startedAt || ""), ); - // Honour ?limit=N (>=1) on the replay list endpoint so the - // real-time viewer can page through the most recent N sessions - // without forcing the engine to ship the full table. Companion - // fix to #1022 (which targeted /agentmemory/sessions); the - // replay endpoint had the same gap. - const rawLimit = req.query_params?.["limit"]; - const parsedLimit = - typeof rawLimit === "string" && rawLimit.length > 0 - ? parseInt(rawLimit, 10) - : NaN; - const limited = - Number.isFinite(parsedLimit) && parsedLimit > 0 - ? sessions.slice(0, parsedLimit) - : 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 } }; }, ); @@ -837,28 +830,17 @@ 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, ); - // Honour ?limit=N (>=1) on the HTTP list endpoint so callers don't - // have to materialize the full session table just to paginate. Fixes - // #1022. Negative or non-numeric values fall back to "no limit" - // (return everything) so a bad query never produces an empty result. - const rawLimit = req.query_params?.["limit"]; - const parsedLimit = - typeof rawLimit === "string" && rawLimit.length > 0 - ? parseInt(rawLimit, 10) - : NaN; - const limited = - Number.isFinite(parsedLimit) && parsedLimit > 0 - ? withSummary.slice(0, parsedLimit) - : withSummary; - return { status_code: 200, body: { sessions: limited } }; + return { status_code: 200, body: { sessions: withSummary } }; }, ); sdk.registerTrigger({ diff --git a/test/sessions-limit.test.ts b/test/sessions-limit.test.ts index 54c665e21..dc9e2e879 100644 --- a/test/sessions-limit.test.ts +++ b/test/sessions-limit.test.ts @@ -6,41 +6,51 @@ import { readFileSync } from "node:fs"; // large session tables without forcing the engine to ship every // record on every request (#1022). // -// These are source-level assertions because the HTTP endpoint is -// defined inside a single big registerFunction call and we don't want -// to spin up the iii engine just to test a query param. A future -// integration test under test/integration.test.ts can verify the -// end-to-end shape. +// 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 reads the limit query param", () => { + it("api::sessions uses parsePositiveLimit for the limit query param", () => { expect(api).toMatch( - /sdk\.registerFunction\(\s*"api::sessions"[\s\S]*?query_params\?\.\["limit"\]/, + /sdk\.registerFunction\(\s*"api::sessions"[\s\S]*?parsePositiveLimit\(req\.query_params\?\.\["limit"\]\)/, ); }); - it("api::sessions slices the response by limit", () => { + it("api::sessions slices filtered before summary lookups", () => { expect(api).toMatch( - /sdk\.registerFunction\(\s*"api::sessions"[\s\S]*?withSummary\.slice\(0,\s*parsedLimit\)/, + /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", () => { - // The fix must not regress the no-limit behaviour: an absent or - // non-numeric ?limit must return the full filtered list, never an - // empty result. expect(api).toMatch( - /sdk\.registerFunction\(\s*"api::sessions"[\s\S]*?parsedLimit\s*>\s*0/, + /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]*?query_params\?\.\["limit"\]/, + /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*parsedLimit\)/, + /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