Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 52 additions & 1 deletion plugins/openclaw/openclaw/__tests__/list-pagination.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,21 @@
* Cursor pagination for GET /v1/memories (issue #267).
* The API caps each page at 500 rows and returns next_cursor when more exist.
*/
import { createServer, type Server } from "node:http";
import type { AddressInfo } from "node:net";
import { afterEach, describe, expect, it } from "vitest";
import { MemoriaClient } from "../client.js";
import { buildApiConfig, mockFetch } from "./helpers.js";

const originalFetch = globalThis.fetch;
const servers: Server[] = [];

afterEach(() => {
afterEach(async () => {
globalThis.fetch = originalFetch;
for (const server of servers.splice(0)) {
server.closeAllConnections();
await new Promise<void>((resolve) => server.close(() => resolve()));
}
});

const SERVER_PAGE_MAX = 500;
Expand Down Expand Up @@ -63,6 +70,50 @@ function serveMemories(f: ReturnType<typeof mockFetch>, total: number) {
}

describe("listMemories cursor pagination", () => {
it.each([
{ limit: 1, pages: 1, ids: [2], partial: true },
{ limit: 3, pages: 2, ids: [2, 3, 4], partial: false },
])("filters by type before applying the API page budget: $limit rows", async ({ limit, pages, ids, partial }) => {
const requests: URL[] = [];
const rows = Array.from({ length: 5 }, (_, i) => ({
memory_id: memoryId(i), content: `m${i}`,
memory_type: i < 2 ? "semantic" : "episodic",
}));
const server = createServer((request, response) => {
const url = new URL(request.url!, "http://localhost");
requests.push(url);
const type = url.searchParams.get("memory_type");
const selected = type ? rows.filter((row) => row.memory_type === type) : rows;
const cursor = url.searchParams.get("cursor");
const start = cursor ? selected.findIndex((row) => row.memory_id === cursor) + 1 : 0;
const size = Math.min(2, Number(url.searchParams.get("limit")));
const items = selected.slice(start, start + size);
const more = start + items.length < selected.length;
response.setHeader("Content-Type", "application/json");
response.end(JSON.stringify({ items, next_cursor: more ? items.at(-1)!.memory_id : null }));
});
servers.push(server);
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
const client = new MemoriaClient(buildApiConfig({
apiUrl: `http://127.0.0.1:${(server.address() as AddressInfo).port}`,
maxListPages: pages,
}));
try {
const result = await client.listMemories({ userId: "u", limit, memoryType: "episodic" });
expect(result.items.map((row) => row.memory_id)).toEqual(ids.map(memoryId));
expect(result.count).toBe(ids.length);
expect(result.partial).toBe(partial);
expect(result.limitations).toBeUndefined();
expect(requests).toHaveLength(pages);
expect(requests.every((url) => url.searchParams.get("memory_type") === "episodic")).toBe(true);
expect(requests.map((url) => Number(url.searchParams.get("limit")))).toEqual(
pages === 1 ? [1] : [3, 1],
);
} finally {
client.close();
}
});

it("issue #267 repro: a truncated page with next_cursor is never reported complete", async () => {
const f = mockFetch();
const client = new MemoriaClient(buildApiConfig({ maxListPages: 1 }));
Expand Down
7 changes: 5 additions & 2 deletions plugins/openclaw/openclaw/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -441,14 +441,15 @@ export class MemoriaClient {
sessionId?: string;
includeInactive?: boolean;
}): Promise<MemoriaListMemoriesResponse> {
const needsClientFiltering = Boolean(params.memoryType);
const needsClientFiltering = Boolean(params.memoryType) && this.config.backend !== "api";
const scanLimit = needsClientFiltering
? Math.min(2000, Math.max(params.limit, params.limit * this.config.maxListPages))
: params.limit;

const generation = this.currentGeneration(params.userId);
const text = await this.callToolText(params.userId, "memory_list", {
limit: scanLimit,
...(this.config.backend === "api" && params.memoryType ? { memory_type: params.memoryType } : {}),
});
let items = parseMemoryTextList(text);
const hasMore = parseListHasMore(text) ?? items.length >= scanLimit;
Expand All @@ -457,7 +458,9 @@ export class MemoriaClient {
const limitations: string[] = [];
if (params.memoryType) {
items = items.filter((memory) => memory.memory_type === params.memoryType);
limitations.push("memoryType filtering is applied client-side from a bounded Rust MCP scan.");
if (needsClientFiltering) {
limitations.push("memoryType filtering is applied client-side from a bounded Rust MCP scan.");
}
}
if (params.sessionId) {
limitations.push("Rust Memoria MCP does not expose session_id on memory_list; sessionId was ignored.");
Expand Down
4 changes: 3 additions & 1 deletion plugins/openclaw/openclaw/http-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,13 +155,15 @@ export class MemoriaHttpTransport {
private async list(args: Record<string, unknown>) {
const limit = typeof args.limit === "number" ? args.limit : 100;
const items: unknown[] = [];
const typeFilter = typeof args.memory_type === "string"
? `&memory_type=${encodeURIComponent(args.memory_type)}` : "";
let cursor: string | null = null;
for (let page = 0; page < this.maxListPages && items.length < limit; page++) {
const pageSize = Math.min(limit - items.length, API_LIST_PAGE_MAX);
const query = cursor
? `limit=${pageSize}&cursor=${encodeURIComponent(cursor)}`
: `limit=${pageSize}`;
const data = await this.get(`/v1/memories?${query}`);
const data = await this.get(`/v1/memories?${query}${typeFilter}`);
const rec = this.asRecord(data);
const pageItems = Array.isArray(rec?.items) ? rec!.items : Array.isArray(data) ? data : [];
const next = typeof rec?.next_cursor === "string" && rec.next_cursor ? rec.next_cursor : null;
Expand Down
Loading