From 7ece022f1ac8817edf6c6f4bff93acba90bc4f17 Mon Sep 17 00:00:00 2001 From: Hleb Shauchenka Date: Sat, 20 Jun 2026 19:23:23 +0200 Subject: [PATCH 1/2] fix(api): sort memory_sessions by recency, expose limit param api::sessions returns sessions sorted by most-recent activity and accepts a numeric ?limit. When ?limit is absent, all sessions are returned (preserves the viewer dashboard, which consumes the same endpoint). When provided, the value is honored verbatim with a non-negative floor; no upper cap. The MCP memory_sessions path self-defaults to 20 and exposes the same limit field in its tool schema. Pre-fix: REST returned every session in KV order with an N+1 summary fetch on the full set, bloating MCP client context on large stores. The fix lets the caller choose how many sessions they want without imposing an arbitrary ceiling. Both MCP paths (in-process handler and standalone proxy shim) self-default to 20, so neither relies on the REST default. Tests: test/api-sessions-limit.test.ts (4 cases). Signed-off-by: Hleb Shauchenka --- src/mcp/server.ts | 15 +++- src/mcp/tools-registry.ts | 10 ++- src/triggers/api.ts | 17 +++- test/api-sessions-limit.test.ts | 133 ++++++++++++++++++++++++++++++++ 4 files changed, 171 insertions(+), 4 deletions(-) create mode 100644 test/api-sessions-limit.test.ts diff --git a/src/mcp/server.ts b/src/mcp/server.ts index dbca07d9b..0563d0b82 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -253,7 +253,20 @@ export function registerMcpEndpoints( } case "memory_sessions": { - const sessions = await kv.list(KV.sessions); + const rawLimit = Number(args.limit); + const limit = + Number.isFinite(rawLimit) && rawLimit > 0 + ? Math.floor(rawLimit) + : 20; + const allSessions = await kv.list(KV.sessions); + const recencyKey = (s: Session): string => + [s.updatedAt, s.endedAt, s.lastCheckpointAt, s.startedAt].reduce( + (acc: string, t) => (typeof t === "string" && t > acc ? t : acc), + "", + ); + const sessions = allSessions + .sort((a, b) => (recencyKey(a) < recencyKey(b) ? 1 : -1)) + .slice(0, limit); return { status_code: 200, body: { diff --git a/src/mcp/tools-registry.ts b/src/mcp/tools-registry.ts index c4df3499c..6ab01bfd9 100644 --- a/src/mcp/tools-registry.ts +++ b/src/mcp/tools-registry.ts @@ -116,7 +116,15 @@ export const CORE_TOOLS: McpToolDef[] = [ name: "memory_sessions", description: "List recent sessions with their status and observation counts.", - inputSchema: { type: "object", properties: {} }, + inputSchema: { + type: "object", + properties: { + limit: { + type: "number", + description: "Max sessions to return (default 20)", + }, + }, + }, }, { name: "memory_smart_search", diff --git a/src/triggers/api.ts b/src/triggers/api.ts index 7b6c2bf2b..4b6791513 100644 --- a/src/triggers/api.ts +++ b/src/triggers/api.ts @@ -808,6 +808,7 @@ export function registerApiTriggers( async (req: ApiRequest): Promise => { const authErr = checkAuth(req, secret); if (authErr) return authErr; + const rawLimit = parseOptionalInt(req.query_params?.["limit"]); const sessions = await kv.list(KV.sessions); const normalizedAgentId = typeof req.query_params?.["agentId"] === "string" @@ -823,12 +824,24 @@ export function registerApiTriggers( const filtered = filterAgentId ? sessions.filter((s) => s.agentId === filterAgentId) : sessions; + const recencyKey = (s: Session): string => + [s.updatedAt, s.endedAt, s.lastCheckpointAt, s.startedAt].reduce( + (acc: string, t) => (typeof t === "string" && t > acc ? t : acc), + "", + ); + const sorted = filtered.sort((a, b) => + recencyKey(a) < recencyKey(b) ? 1 : -1, + ); + const recent = + rawLimit === undefined + ? sorted + : sorted.slice(0, Math.max(0, rawLimit)); const summaries = await Promise.all( - filtered.map((s) => + recent.map((s) => kv.get(KV.summaries, s.id).catch(() => null), ), ); - const withSummary = filtered.map((s, i) => + const withSummary = recent.map((s, i) => summaries[i] ? { ...s, summary: summaries[i] } : s, ); return { status_code: 200, body: { sessions: withSummary } }; diff --git a/test/api-sessions-limit.test.ts b/test/api-sessions-limit.test.ts new file mode 100644 index 000000000..30dc6bb5e --- /dev/null +++ b/test/api-sessions-limit.test.ts @@ -0,0 +1,133 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("../src/logger.js", () => ({ + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, +})); + +import { registerApiTriggers } from "../src/triggers/api.js"; +import { KV } from "../src/state/schema.js"; +import type { Session } from "../src/types.js"; + +function mockKV() { + const store = new Map>(); + return { + get: async (scope: string, key: string): Promise => + (store.get(scope)?.get(key) as T) ?? null, + set: async (scope: string, key: string, value: T): Promise => { + if (!store.has(scope)) store.set(scope, new Map()); + store.get(scope)!.set(key, value); + return value; + }, + update: async (scope: string, key: string): Promise => + store.get(scope)?.get(key) as T, + delete: async (scope: string, key: string): Promise => { + store.get(scope)?.delete(key); + }, + list: async (scope: string): Promise => { + const entries = store.get(scope); + return entries ? (Array.from(entries.values()) as T[]) : []; + }, + }; +} + +function mockSdk() { + const functions = new Map(); + return { + registerFunction: vi.fn( + (idOrOpts: string | { id: string }, handler: Function) => { + const id = typeof idOrOpts === "string" ? idOrOpts : idOrOpts.id; + functions.set(id, handler); + }, + ), + registerTrigger: vi.fn(), + trigger: vi.fn(), + getFunction: (id: string) => functions.get(id), + }; +} + +function session(id: string, updatedSecond: number): Session { + return { + id, + project: "/tmp/p", + cwd: "/tmp/p", + startedAt: "2026-06-01T00:00:00.000Z", + updatedAt: `2026-06-01T00:00:${String(updatedSecond).padStart(2, "0")}.000Z`, + status: "active", + observationCount: 1, + }; +} + +function reqWithLimit(limit?: string) { + return { + body: undefined, + headers: {}, + query_params: limit === undefined ? {} : { limit }, + }; +} + +type SessionsResponse = { + status_code: number; + body: { sessions: Array<{ id: string }> }; +}; + +describe("api::sessions limit + recency", () => { + let sdk: ReturnType; + let kv: ReturnType; + + async function seed(count: number) { + for (let i = 0; i < count; i++) { + await kv.set(KV.sessions, `s${i}`, session(`s${i}`, i % 60)); + } + } + + beforeEach(() => { + sdk = mockSdk(); + kv = mockKV(); + registerApiTriggers(sdk as never, kv as never); + }); + + it("defaults to all sessions, most-recent first", async () => { + await seed(25); + const fn = sdk.getFunction("api::sessions")!; + const res = (await fn(reqWithLimit())) as SessionsResponse; + + expect(res.status_code).toBe(200); + expect(res.body.sessions).toHaveLength(25); + expect(res.body.sessions[0].id).toBe("s24"); + const ids = new Set(res.body.sessions.map((s) => s.id)); + for (const old of ["s0", "s1", "s2", "s3", "s4"]) { + expect(ids.has(old)).toBe(true); + } + }); + + it("honors an explicit limit", async () => { + await seed(25); + const fn = sdk.getFunction("api::sessions")!; + const res = (await fn(reqWithLimit("5"))) as SessionsResponse; + + expect(res.body.sessions).toHaveLength(5); + expect(res.body.sessions.map((s) => s.id)).toEqual([ + "s24", + "s23", + "s22", + "s21", + "s20", + ]); + }); + + it("honors arbitrarily large limit", async () => { + await seed(205); + const fn = sdk.getFunction("api::sessions")!; + const res = (await fn(reqWithLimit("1000"))) as SessionsResponse; + + expect(res.body.sessions).toHaveLength(205); + }); + + it("treats non-positive limit as zero", async () => { + await seed(5); + const fn = sdk.getFunction("api::sessions")!; + const res = (await fn(reqWithLimit("0"))) as SessionsResponse; + + expect(res.body.sessions).toHaveLength(0); + }); +}); From 09f345c76c091b135f9ef9e949964b336a13f740 Mon Sep 17 00:00:00 2001 From: Hleb Shauchenka Date: Sat, 20 Jun 2026 22:32:43 +0200 Subject: [PATCH 2/2] fix(types,test): declare Session.updatedAt + lastCheckpointAt, cover negative limit recencyKey() in src/triggers/api.ts and src/mcp/server.ts reads these fields, so the Session interface needs to declare them or strict mode emits TS2339. Both are optional in practice (persisted sessions backfill them via checkpoint runs), so they are typed as optional strings. Also extend the 'treats non-positive limit as zero' case to assert -1 in addition to 0, matching the contract the endpoint promises. --- src/types.ts | 2 ++ test/api-sessions-limit.test.ts | 2 ++ 2 files changed, 4 insertions(+) diff --git a/src/types.ts b/src/types.ts index 6797dfaf9..8fe7b7471 100644 --- a/src/types.ts +++ b/src/types.ts @@ -12,6 +12,8 @@ export interface Session { summary?: string; commitShas?: string[]; agentId?: string; + updatedAt?: string; + lastCheckpointAt?: string; } export interface CommitLink { diff --git a/test/api-sessions-limit.test.ts b/test/api-sessions-limit.test.ts index 30dc6bb5e..b910450f1 100644 --- a/test/api-sessions-limit.test.ts +++ b/test/api-sessions-limit.test.ts @@ -129,5 +129,7 @@ describe("api::sessions limit + recency", () => { const res = (await fn(reqWithLimit("0"))) as SessionsResponse; expect(res.body.sessions).toHaveLength(0); + const negative = (await fn(reqWithLimit("-1"))) as SessionsResponse; + expect(negative.body.sessions).toHaveLength(0); }); });