diff --git a/clients/web/src/test/core/mcp/state/managedRequestorTasksState.test.ts b/clients/web/src/test/core/mcp/state/managedRequestorTasksState.test.ts index 2a7d3c8e..349e5296 100644 --- a/clients/web/src/test/core/mcp/state/managedRequestorTasksState.test.ts +++ b/clients/web/src/test/core/mcp/state/managedRequestorTasksState.test.ts @@ -101,6 +101,27 @@ describe("ManagedRequestorTasksState", () => { expect(client.listRequestorTasks).toHaveBeenCalledTimes(3); }); + it("refresh walks past an empty-string cursor instead of stopping at page one", async () => { + // A cursor is opaque and `""` is a legal `nextCursor` (#2220). Under the + // truthiness guard this walk used to carry, page one's empty cursor ended + // the loop — so the task list stopped at `t1` against a conforming server, + // with nothing to show for it. The cursor each call receives is asserted + // too, because a walk that stopped correctly but re-requested page one + // would still produce three tasks with the wrong three requests. + client.setStatus("connected"); + client.queueTaskPages( + { tasks: [task("t1")], nextCursor: "" }, + { tasks: [task("t2")], nextCursor: "c2" }, + { tasks: [task("t3")] }, + ); + + const result = await state.refresh(); + expect(result.map((t) => t.taskId)).toEqual(["t1", "t2", "t3"]); + expect(client.listRequestorTasks.mock.calls.map((call) => call[0])).toEqual( + [undefined, "", "c2"], + ); + }); + it("connect event triggers a refresh", async () => { client.setStatus("connected"); client.queueTaskPages({ tasks: [task("t1")] }); diff --git a/core/mcp/__tests__/fakeInspectorClient.ts b/core/mcp/__tests__/fakeInspectorClient.ts index 49232ad6..bf77d19c 100644 --- a/core/mcp/__tests__/fakeInspectorClient.ts +++ b/core/mcp/__tests__/fakeInspectorClient.ts @@ -116,9 +116,12 @@ export class FakeInspectorClient listResourceTemplates = vi.fn( async () => this.resourceTemplatePages.shift() ?? { resourceTemplates: [] }, ); - listRequestorTasks = vi.fn( - async () => this.taskPages.shift() ?? { tasks: [] }, - ); + // Typed by its signature rather than inferred, so `mock.calls` carries the + // cursor a test asserts on — the empty-string pagination case (#2220) turns + // on *which* cursor each call received, not just how many there were. + listRequestorTasks = vi.fn< + (cursor?: string) => Promise> + >(async () => this.taskPages.shift() ?? { tasks: [] }); listSkills = vi.fn(async () => this.skillPages.shift() ?? { skills: [] }); // `skills/get` echoes a minimal entry; tests that care override the mock. getSkill = vi.fn(async (uri: string) => ({ diff --git a/core/mcp/state/managedRequestorTasksState.ts b/core/mcp/state/managedRequestorTasksState.ts index 93b584f1..8b83585c 100644 --- a/core/mcp/state/managedRequestorTasksState.ts +++ b/core/mcp/state/managedRequestorTasksState.ts @@ -181,7 +181,13 @@ export class ManagedRequestorTasksState extends TypedEventTarget= MAX_PAGES) { @@ -189,7 +195,7 @@ export class ManagedRequestorTasksState extends TypedEventTarget