diff --git a/apps/server/src/core/ai-chat/tools/ai-chat-tools.cap.spec.ts b/apps/server/src/core/ai-chat/tools/ai-chat-tools.cap.spec.ts new file mode 100644 index 00000000..7f504f48 --- /dev/null +++ b/apps/server/src/core/ai-chat/tools/ai-chat-tools.cap.spec.ts @@ -0,0 +1,160 @@ +import { + wrapInAppToolWithCap, + inAppToolCallCapMs, + type ToolAbortSignalSink, +} from './ai-chat-tools.service'; +import type { Tool, ToolCallOptions } from 'ai'; + +/** + * #487 commit 1 — in-app tool race-on-abort + safe-points + per-call cap. + * + * Tests assert the HONEST observable property the spec names — "after Stop, NO + * new HTTP/WS call STARTS; an already-started single call may take either + * outcome" — against the REAL wrapper mechanism (the composite abort signal it + * publishes on the client + the RACE it runs), NOT a timing-dependent proxy like + * "the write didn't land". + */ + +// A minimal stand-in for the client's `toolAbortSignal` field. In production the +// wrapper publishes the composite here and the client's paginateAll / +// mutatePageContent safe-points read it; the fake "tool" below reads it the same +// way, so this exercises the real contract without a live DB / collab socket. +class FakeClient implements ToolAbortSignalSink { + private signal: AbortSignal | null = null; + setToolAbortSignal(signal: AbortSignal | null): void { + this.signal = signal; + } + getToolAbortSignal(): AbortSignal | null { + return this.signal; + } +} + +// A ToolCallOptions with just the field the wrapper reads (abortSignal). The AI +// SDK passes a fuller object; the wrapper only spreads it and reads abortSignal. +const opts = (abortSignal?: AbortSignal): ToolCallOptions => + ({ toolCallId: 't1', messages: [], abortSignal }) as unknown as ToolCallOptions; + +const tick = (ms = 5) => new Promise((r) => setTimeout(r, ms)); + +describe('#487 wrapInAppToolWithCap — race-on-abort + safe-points', () => { + it('after Stop, no NEW simulated call starts (multi-call tool)', async () => { + const client = new FakeClient(); + const started: number[] = []; + // A multi-call tool that mirrors paginateAll: it consults the client signal + // at a safe-point BEFORE starting each simulated network call. + const multiCall: Tool = { + execute: (async (_args: unknown) => { + for (let i = 0; i < 6; i++) { + // Safe-point: exactly what paginateAll / mutatePageContent do. + client.getToolAbortSignal()?.throwIfAborted(); + started.push(i); + await tick(10); + } + return 'done'; + }) as unknown as Tool['execute'], + } as Tool; + + const wrapped = wrapInAppToolWithCap(multiCall, client, 10_000); + const ac = new AbortController(); + const call = ( + wrapped.execute as (a: unknown, o: ToolCallOptions) => Promise + )({}, opts(ac.signal)); + + // Let one or two calls start, then Stop. + await tick(12); + ac.abort(new Error('user stop')); + + await expect(call).rejects.toThrow(); // wrapper rejects promptly + const startedAtStop = started.length; + + // Give the abandoned loser ample time; its next safe-point must throw because + // the (aborted) composite is still published on the client. + await tick(60); + expect(started.length).toBe(startedAtStop); + // It must NOT have run the whole sequence (that would mean Stop did nothing). + expect(started.length).toBeLessThan(6); + }); + + it('rejects immediately on Stop even if the call never settles (discard loser)', async () => { + const client = new FakeClient(); + let settled = false; + const hang: Tool = { + execute: (async () => { + await new Promise(() => undefined); // never resolves + settled = true; + }) as unknown as Tool['execute'], + } as Tool; + const wrapped = wrapInAppToolWithCap(hang, client, 10_000); + const ac = new AbortController(); + const call = ( + wrapped.execute as (a: unknown, o: ToolCallOptions) => Promise + )({}, opts(ac.signal)); + await tick(5); + ac.abort(); + await expect(call).rejects.toThrow(); + expect(settled).toBe(false); + }); + + it('per-call cap rejects a hung call with no Stop signal', async () => { + const client = new FakeClient(); + const hang: Tool = { + execute: (async () => { + await new Promise(() => undefined); + }) as unknown as Tool['execute'], + } as Tool; + // Tiny cap; no options.abortSignal at all (composite = cap only). + const wrapped = wrapInAppToolWithCap(hang, client, 20); + const start = Date.now(); + await expect( + (wrapped.execute as (a: unknown, o: ToolCallOptions) => Promise)( + {}, + opts(undefined), + ), + ).rejects.toThrow(/per-call cap/); + expect(Date.now() - start).toBeLessThan(2000); + }); + + it('publishes a composite signal on the client for the duration of the call', async () => { + const client = new FakeClient(); + let seenDuringCall: AbortSignal | null = null; + const probe: Tool = { + execute: (async () => { + seenDuringCall = client.getToolAbortSignal(); + return 'ok'; + }) as unknown as Tool['execute'], + } as Tool; + const wrapped = wrapInAppToolWithCap(probe, client, 10_000); + const ac = new AbortController(); + await ( + wrapped.execute as (a: unknown, o: ToolCallOptions) => Promise + )({}, opts(ac.signal)); + expect(seenDuringCall).not.toBeNull(); + // The published composite must reflect the turn's Stop signal. + ac.abort(); + expect((seenDuringCall as unknown as AbortSignal).aborted).toBe(true); + }); + + it('a completed call returns its raw result unchanged', async () => { + const client = new FakeClient(); + const ok: Tool = { + execute: (async () => ({ items: [1, 2, 3] })) as unknown as Tool['execute'], + } as Tool; + const wrapped = wrapInAppToolWithCap(ok, client, 10_000); + const res = await ( + wrapped.execute as (a: unknown, o: ToolCallOptions) => Promise + )({}, opts(new AbortController().signal)); + expect(res).toEqual({ items: [1, 2, 3] }); + }); + + it('cap is env-tunable with a 2-minute default', () => { + const prev = process.env.AI_CHAT_INAPP_TOOL_CALL_CAP_MS; + delete process.env.AI_CHAT_INAPP_TOOL_CALL_CAP_MS; + expect(inAppToolCallCapMs()).toBe(120_000); + process.env.AI_CHAT_INAPP_TOOL_CALL_CAP_MS = '5000'; + expect(inAppToolCallCapMs()).toBe(5000); + process.env.AI_CHAT_INAPP_TOOL_CALL_CAP_MS = 'not-a-number'; + expect(inAppToolCallCapMs()).toBe(120_000); + if (prev === undefined) delete process.env.AI_CHAT_INAPP_TOOL_CALL_CAP_MS; + else process.env.AI_CHAT_INAPP_TOOL_CALL_CAP_MS = prev; + }); +}); diff --git a/apps/server/src/core/ai-chat/tools/ai-chat-tools.service.ts b/apps/server/src/core/ai-chat/tools/ai-chat-tools.service.ts index dc2270fd..58561ea2 100644 --- a/apps/server/src/core/ai-chat/tools/ai-chat-tools.service.ts +++ b/apps/server/src/core/ai-chat/tools/ai-chat-tools.service.ts @@ -1,5 +1,5 @@ import { Injectable, Logger } from '@nestjs/common'; -import { tool, type Tool } from 'ai'; +import { tool, type Tool, type ToolCallOptions } from 'ai'; import { z } from 'zod'; import { User } from '@docmost/db/types/entity.types'; import { TokenService } from '../../auth/services/token.service'; @@ -159,6 +159,129 @@ function __assertClientCallContract(client: DocmostClientLike): void { * existing service-account `/mcp` path already calls loopback successfully, so * this works for single-workspace self-host. */ +/** + * #487: wall-clock cap for a SINGLE in-app tool call, env-tunable via + * `AI_CHAT_INAPP_TOOL_CALL_CAP_MS`. Bounds a read tool that would otherwise + * paginate for minutes and a content write whose collab commit hangs, and is the + * per-call CAP half of the composite abort signal every in-app tool is wrapped + * with (the other half is the turn's Stop signal). Default 2 minutes: generous + * for a legitimate long read/write, tight enough that a stuck call cannot pin the + * turn. The reconcile staleness floor (#487 commit 4) is derived as + * max(2 x this cap, 15 min), so keep this well under that. + */ +export function inAppToolCallCapMs(): number { + const raw = Number(process.env.AI_CHAT_INAPP_TOOL_CALL_CAP_MS); + return Number.isFinite(raw) && raw > 0 ? raw : 120_000; +} + +/** #487: the composite signal's reason as an Error (informative thrown value). */ +function inAppAbortReason(signal: AbortSignal): Error { + const r = signal.reason; + return r instanceof Error + ? r + : new Error(typeof r === 'string' ? r : 'In-app tool call aborted'); +} + +/** + * The client surface {@link wrapInAppToolWithCap} drives (#487). Both methods are + * OPTIONAL: the real loopback DocmostClient implements them (so a Stop/cap reaches + * its pagination / pre-commit safe-points), but a client that omits them still + * gets the OUTER guarantee — the race rejects on abort regardless. This keeps the + * wrapper decoupled from the exact client shape (unit-test doubles need not stub + * the plumbing). + */ +export interface ToolAbortSignalSink { + setToolAbortSignal?(signal: AbortSignal | null): void; + getToolAbortSignal?(): AbortSignal | null; +} + +/** + * #487: wrap an in-app tool so a Stop (the turn's `options.abortSignal`) OR the + * per-call wall-clock cap REJECTS the call immediately, and so that SAME + * composite signal reaches the client's pagination / pre-commit safe-points (via + * `client.setToolAbortSignal`) — making a Stop stop the NEXT HTTP/WS call from + * starting. + * + * Reuses the RACE pattern of `wrapToolWithCallTimeout` (mcp-clients.service.ts): + * the call is raced against the composite signal, so on abort we reject in the + * SAME tick and DISCARD the loser promise. Its network / collab teardown latency + * therefore never blocks the turn — the supersede timeout W=10s (#487 commit 3) + * relies on this abort->settle latency being milliseconds, not a socket teardown. + * Awaiting the client's own signal-into-write path alone would NOT satisfy this + * (the loser could still be tearing down a collab socket). + * + * The composite is SET on the client at entry and deliberately NOT restored on + * unwind: after this wrapper rejects on abort, the ABANDONED loser promise keeps + * running, and its safe-points read the client field — leaving the (aborted) + * composite there is exactly what makes the loser's NEXT call throw and stop. The + * next in-app tool call overwrites the field with its own fresh composite before + * any of its safe-points run, so a stale settled signal never leaks forward. + * SINGLE-WRITER by phase-1 assumption — see DocmostClientContext.toolAbortSignal + * for the parallel-call caveat (#487). + * + * KNOWN LIMITATION (#487): a write tool that issues SEVERAL sequential collab + * commits can be aborted BETWEEN commits, leaving a partially-applied operation. + * Cancel guarantees "no NEW call starts", NOT "the write didn't land". + */ +export function wrapInAppToolWithCap( + toolDef: Tool, + client: ToolAbortSignalSink, + capMs: number, +): Tool { + const original = toolDef.execute; + if (typeof original !== 'function') return toolDef; + const execute = async (args: unknown, options: ToolCallOptions) => { + const capController = new AbortController(); + const timer = setTimeout(() => { + capController.abort( + new Error(`In-app tool call exceeded the ${capMs}ms per-call cap`), + ); + }, capMs); + timer.unref?.(); + const composite = options?.abortSignal + ? AbortSignal.any([options.abortSignal, capController.signal]) + : capController.signal; + // Reject the MOMENT the composite fires, independent of whether `original` + // ever settles (a hung collab write / read would otherwise pin the turn). The + // losing `original` is left pending; Promise.race attaches a rejection + // handler to both inputs so a late rejection is never unhandled. + const aborted = new Promise((_, reject) => { + const fail = () => reject(inAppAbortReason(composite)); + if (composite.aborted) fail(); + else composite.addEventListener('abort', fail, { once: true }); + }); + // Publish the composite so the client's pagination / pre-commit safe-points + // observe it (see the "not restored on unwind" rationale above). Guarded: a + // client without the plumbing still gets the OUTER race guarantee below. + client.setToolAbortSignal?.(composite); + try { + return await Promise.race([ + (original as (a: unknown, o: ToolCallOptions) => Promise)( + args, + { ...options, abortSignal: composite }, + ), + aborted, + ]); + } finally { + clearTimeout(timer); + } + }; + return { ...toolDef, execute } as unknown as Tool; +} + +/** #487: apply {@link wrapInAppToolWithCap} to every tool in a set. */ +export function wrapInAppToolsWithCap( + tools: Record, + client: ToolAbortSignalSink, + capMs: number, +): Record { + const out: Record = {}; + for (const [name, t] of Object.entries(tools)) { + out[name] = wrapInAppToolWithCap(t, client, capMs); + } + return out; +} + @Injectable() export class AiChatToolsService { private readonly logger = new Logger(AiChatToolsService.name); @@ -186,7 +309,12 @@ export class AiChatToolsService { sessionId: string, workspaceId: string, aiChatId: string, - ): Promise { + // #487: the returned client also carries the tool-cancellation plumbing + // (setToolAbortSignal/getToolAbortSignal). These are host plumbing, NOT part + // of the tool-execute surface (DocmostClientMethod), so they are surfaced here + // as an intersection rather than by widening that Pick — keeping the + // positional-call drift-guard (#446) scoped to the actual tool methods. + ): Promise { const apiUrl = process.env.MCP_DOCMOST_API_URL || `http://127.0.0.1:${process.env.PORT || 3000}/api`; @@ -630,7 +758,15 @@ export class AiChatToolsService { // dependency and reuses the CASL enforcement already on `client`. When the // loaded package predates #417 (factory undefined) or the loader is mocked in // a unit test, signalling is a pure no-op and results are byte-identical. - if (!createCommentSignalTracker) return tools; + // #487: wrap every in-app tool with the race-on-abort + per-call cap guard so + // a Stop / cap rejects immediately AND reaches the client's write/pagination + // safe-points. Applied as the OUTERMOST wrapper (over the comment-signal + // wrapper below) so the race governs the whole call. The client carries the + // per-call composite signal via setToolAbortSignal. + const capMs = inAppToolCallCapMs(); + if (!createCommentSignalTracker) { + return wrapInAppToolsWithCap(tools, client, capMs); + } const tracker = createCommentSignalTracker({ probe: async (pageId: string, sinceMs: number) => { @@ -659,7 +795,11 @@ export class AiChatToolsService { }, }); - return wrapToolsWithCommentSignal(tools, tracker); + return wrapInAppToolsWithCap( + wrapToolsWithCommentSignal(tools, tracker), + client, + capMs, + ); } } diff --git a/packages/mcp/src/client/context.ts b/packages/mcp/src/client/context.ts index b65a7b5a..661e204a 100644 --- a/packages/mcp/src/client/context.ts +++ b/packages/mcp/src/client/context.ts @@ -162,6 +162,39 @@ export abstract class DocmostClientContext { // bypassed on a forced refresh (the 401/403 reauth path). null = no token yet. protected collabTokenCache: { token: string; mintedAt: number } | null = null; + // #487: an OPTIONAL abort signal the in-app tool host sets before each tool + // call (a composite of the turn's Stop signal + a per-call wall-clock cap). It + // is checked at safe-points BETWEEN the sequential HTTP calls of a paginated + // read (paginateAll) and just before the atomic collab commit of a write (the + // mutatePage/replacePage/mutateLiveContentUnlocked seams), so a Stop / cap + // stops the NEXT network call from STARTING. An already-started single call may + // still land — a documented limitation (#487). + // + // SINGLE-WRITER by phase-1 assumption: exactly one DocmostClient is built per + // turn and shared by every tool call; the host sets this per call and restores + // the prior value when the call unwinds. If the model emits PARALLEL in-app + // tool calls they share this one field, so the per-call CAP of one call is not + // guaranteed to bound another's in-flight pagination — but every composite the + // host sets carries the SAME turn Stop signal, so a Stop still aborts whichever + // signal is current. #487. + protected toolAbortSignal: AbortSignal | null = null; + + /** + * #487: set (or clear with null) the in-app tool abort signal governing the + * NEXT client call's safe-points. The host wraps each in-app tool call: it sets + * the composite (Stop + per-call cap) here before invoking the tool and + * restores the prior value afterwards. Public so the server-side tool wrapper + * can reach it; harmless (a no-op) when never set. + */ + public setToolAbortSignal(signal: AbortSignal | null): void { + this.toolAbortSignal = signal; + } + + /** #487: the abort signal currently governing this client's safe-points. */ + public getToolAbortSignal(): AbortSignal | null { + return this.toolAbortSignal; + } + // Two construction forms: // - new DocmostClient(config) // discriminated union (current) // - new DocmostClient(baseURL, email, password) // legacy positional creds @@ -563,6 +596,10 @@ export abstract class DocmostClientContext { this.onMetricFn?.("collab_connect_timeouts_total", 1), }); try { + // #487 PRE-COMMIT safe-point (reentrant twin of mutatePageContent): a + // Stop/cap after acquiring the session but before the atomic write skips + // this commit. Same limitation applies (stops the NEXT commit only). + this.toolAbortSignal?.throwIfAborted(); return await session.mutate(transform); } catch (e) { // Drop the session on any failure so the next call reconnects fresh. @@ -594,6 +631,11 @@ export abstract class DocmostClientContext { let truncated = false; for (let page = 0; page < MAX_PAGES; page++) { + // #487 safe-point: a Stop (or the in-app tool per-call cap) that fires + // BETWEEN sequential page fetches must stop the NEXT request from starting + // — a read tool that would otherwise paginate for minutes is interrupted + // here. throwIfAborted() rejects with the signal's reason. + this.toolAbortSignal?.throwIfAborted(); const payload: Record = { ...basePayload, limit: clampedLimit, @@ -701,7 +743,15 @@ export abstract class DocmostClientContext { // #486: on a rejected collab-WS handshake, invalidate + refresh the token and // retry the write once (symmetric to the HTTP-401 reauth path). return this.writeWithCollabAuthRetry(collabToken, (token) => - mutatePageContent(pageUuid, token, apiUrl, transform), + // #487: thread the in-app tool signal to mutatePageContent's pre-commit + // safe-point so a Stop/cap during the connect/lock window skips the write. + mutatePageContent( + pageUuid, + token, + apiUrl, + transform, + this.toolAbortSignal ?? undefined, + ), ); } @@ -725,7 +775,14 @@ export abstract class DocmostClientContext { // #486: on a rejected collab-WS handshake, invalidate + refresh the token and // retry the write once (symmetric to the HTTP-401 reauth path). return this.writeWithCollabAuthRetry(collabToken, (token) => - replacePageContent(pageUuid, doc, token, apiUrl), + // #487: same pre-commit safe-point as mutatePage, for full-document writes. + replacePageContent( + pageUuid, + doc, + token, + apiUrl, + this.toolAbortSignal ?? undefined, + ), ); } diff --git a/packages/mcp/src/lib/collaboration.ts b/packages/mcp/src/lib/collaboration.ts index c7a77c5d..66d1d9ce 100644 --- a/packages/mcp/src/lib/collaboration.ts +++ b/packages/mcp/src/lib/collaboration.ts @@ -254,6 +254,12 @@ export async function mutatePageContent( collabToken: string, baseUrl: string, transform: (liveDoc: any) => any | null, + // #487: optional abort signal carrying the turn's Stop + the in-app tool + // per-call cap. Checked as the PRE-COMMIT safe-point below (after the session + // is acquired, immediately before the atomic read->write), so a Stop that + // arrives during the connect/lock window stops THIS write from landing. See the + // limitation note at the check. + signal?: AbortSignal, ): Promise { return withPageLock(pageId, async () => { if (process.env.DEBUG) { @@ -266,6 +272,13 @@ export async function mutatePageContent( const session = await acquireCollabSession(pageId, collabToken, baseUrl); try { + // #487 PRE-COMMIT safe-point: if the turn was Stopped (or the in-app tool + // per-call cap fired) after we acquired the collab session but before the + // atomic write, throw NOW so this commit never runs. KNOWN LIMITATION + // (#487): this only stops THIS commit — a write tool that already committed + // an EARLIER call this turn leaves that op applied. Cancel guarantees "no + // NEW commit starts", NOT "the write didn't land". + signal?.throwIfAborted(); return await session.mutate(transform); } catch (e) { // Drop the session on any failure so the next call reconnects fresh (this @@ -291,6 +304,8 @@ export async function replacePageContent( prosemirrorDoc: any, collabToken: string, baseUrl: string, + // #487: threaded straight to mutatePageContent's pre-commit safe-point. + signal?: AbortSignal, ): Promise { // Fail fast on a bad document instead of deferring the failure into the // collaboration write (where TiptapTransformer.toYdoc(undefined) used to @@ -307,6 +322,7 @@ export async function replacePageContent( collabToken, baseUrl, () => prosemirrorDoc, + signal, ); } diff --git a/packages/mcp/test/mock/paginate-abort-safepoint.test.mjs b/packages/mcp/test/mock/paginate-abort-safepoint.test.mjs new file mode 100644 index 00000000..af4c4e16 --- /dev/null +++ b/packages/mcp/test/mock/paginate-abort-safepoint.test.mjs @@ -0,0 +1,143 @@ +// #487 commit 1 — the in-app tool cancellation safe-point inside paginateAll. +// +// The in-app tool host sets a composite abort signal on the client +// (setToolAbortSignal) before each tool call; paginateAll checks it at a +// safe-point BEFORE every sequential page fetch, so a Stop that lands mid-read +// stops the NEXT HTTP request from STARTING (a read tool can no longer paginate +// for minutes past a Stop). This pins the HONEST observable property against the +// REAL client + a real HTTP server: "after Stop, no NEW request starts". +import { test, after } from "node:test"; +import assert from "node:assert/strict"; +import http from "node:http"; +import { DocmostClient } from "../../build/client.js"; + +function readBody(req) { + return new Promise((resolve) => { + let raw = ""; + req.on("data", (c) => (raw += c)); + req.on("end", () => resolve(raw)); + }); +} +function sendJson(res, status, obj, extra = {}) { + res.writeHead(status, { "Content-Type": "application/json", ...extra }); + res.end(JSON.stringify(obj)); +} +const openServers = []; +async function spawn(handler) { + const server = await new Promise((resolve) => { + const s = http.createServer(handler); + s.listen(0, "127.0.0.1", () => resolve(s)); + }); + openServers.push(server); + const { port } = server.address(); + return { baseURL: `http://127.0.0.1:${port}/api` }; +} +after(async () => { + await Promise.all(openServers.map((s) => new Promise((r) => s.close(r)))); +}); +function handleLogin(req, res) { + if (req.url === "/api/auth/login") { + sendJson(res, 200, { success: true }, { + "Set-Cookie": "authToken=t; Path=/; HttpOnly", + }); + return true; + } + return false; +} + +// A Stop that lands DURING pagination: the server aborts the client signal as it +// serves page 1 (more pages remain). The loop's next safe-point must throw before +// the page-2 request is sent. +test("paginateAll stops the NEXT request when the signal aborts mid-pagination", async () => { + let requests = 0; + const ac = new AbortController(); + const { baseURL } = await spawn(async (req, res) => { + await readBody(req); + if (handleLogin(req, res)) return; + if (req.url === "/api/spaces") { + requests++; + // Simulate a user Stop that lands while page 1 is in flight. + if (requests === 1) ac.abort(new Error("user stop")); + sendJson(res, 200, { + success: true, + data: { + items: [{ id: `p${requests}` }], + meta: { hasNextPage: true, nextCursor: `c${requests}` }, + }, + }); + return; + } + sendJson(res, 404, {}); + }); + + const client = new DocmostClient(baseURL, "user@example.com", "pw"); + client.setToolAbortSignal(ac.signal); + + await assert.rejects( + () => client.paginateAll("/spaces", {}), + /user stop/, + "the aborted safe-point rejects with the signal's reason", + ); + assert.equal(requests, 1, "page 2 never started after the Stop"); +}); + +// A Stop that is already in effect before the read starts: zero requests fire. +test("paginateAll starts no request when the signal is already aborted", async () => { + let requests = 0; + const { baseURL } = await spawn(async (req, res) => { + await readBody(req); + if (handleLogin(req, res)) return; + if (req.url === "/api/spaces") { + requests++; + sendJson(res, 200, { + success: true, + data: { items: [], meta: { hasNextPage: false, nextCursor: null } }, + }); + return; + } + sendJson(res, 404, {}); + }); + + const client = new DocmostClient(baseURL, "user@example.com", "pw"); + // Warm the auth so ensureAuthenticated does not itself POST after the abort. + await client.ensureAuthenticated(); + const ac = new AbortController(); + ac.abort(new Error("already stopped")); + client.setToolAbortSignal(ac.signal); + + await assert.rejects(() => client.paginateAll("/spaces", {}), /already stopped/); + assert.equal(requests, 0, "no /spaces request started once already aborted"); +}); + +// Without a tool signal (default), pagination is unaffected — the safe-point is a +// pure no-op, so pre-#487 behaviour is byte-identical. +test("paginateAll is unaffected when no tool signal is set", async () => { + let requests = 0; + const PAGES = { + "": { items: [{ id: "a" }], nextCursor: "c1" }, + c1: { items: [{ id: "b" }], nextCursor: null }, + }; + const { baseURL } = await spawn(async (req, res) => { + const raw = await readBody(req); + if (handleLogin(req, res)) return; + if (req.url === "/api/spaces") { + requests++; + const body = JSON.parse(raw || "{}"); + const page = PAGES[body.cursor ?? ""] ?? { items: [], nextCursor: null }; + sendJson(res, 200, { + success: true, + data: { + items: page.items, + meta: { hasNextPage: page.nextCursor != null, nextCursor: page.nextCursor }, + }, + }); + return; + } + sendJson(res, 404, {}); + }); + + const client = new DocmostClient(baseURL, "user@example.com", "pw"); + const all = await client.paginateAll("/spaces", {}); + assert.equal(requests, 2, "both pages fetched with no signal set"); + assert.deepEqual(all.map((p) => p.id), ["a", "b"]); +});