import { expect, it, mock } from "bun:test"; import { createTaskCalculationWaiter } from "../../src/mcp/task-calculation-waiter.js"; const owner = { context: { sessionId: "s", clientSessionId: "c", actorKey: "a", projectKey: "p", projectId: "p", traceId: "t", purpose: "task_execution" as const }, runtimeSessionId: "s" }; const result = (status: string) => ({ content: [], structuredContent: { data: [{ job_id: "job-1", status, poll_after_ms: 1500 }], page: { has_more: false } } }); it("waits in the host across bounded status reads then resumes once for terminal results", async () => { const outputs = [result("queued"), result("running"), result("succeeded"), result("succeeded")]; const query = mock(async () => outputs.shift()!); const pause = mock(async (_ms: number, _signal: AbortSignal) => {}); const wait = createTaskCalculationWaiter({ query }, () => owner, pause); const signal = new AbortController().signal; expect(await wait("s", signal)).toContain("get_calculation_result"); expect(pause).toHaveBeenCalledTimes(2); expect(pause.mock.calls[0]?.[0]).toBe(1500); expect(await wait("s", signal)).toBeNull(); expect(query).toHaveBeenCalledTimes(4); }); it("does not turn unavailable status into a completed task", async () => { const query = mock(async () => ({ content: [], isError: true })); await expect(createTaskCalculationWaiter({ query }, () => owner)("s", new AbortController().signal)).rejects.toThrow("unavailable"); }); it("aborts host waiting without issuing Server cancellation", async () => { const controller = new AbortController(); const names: string[] = []; const query = mock(async (_id: string, _owner: unknown, name: string) => { names.push(name); return result("running"); }); const wait = createTaskCalculationWaiter({ query }, () => owner, async () => { controller.abort(); }); await expect(wait("s", controller.signal)).rejects.toThrow(); expect(names).toEqual(["list_calculation_jobs"]); }); it("resumes recovery for a worker-interrupted job instead of waiting or completing silently", async () => { const query = mock(async () => result("interrupted")); const pause = mock(async (_ms: number, _signal: AbortSignal) => {}); const wait = createTaskCalculationWaiter({ query }, () => owner, pause); const resume = await wait("s", new AbortController().signal); expect(resume).toContain("interrupted"); expect(resume).toContain("不要重复提交"); expect(pause).not.toHaveBeenCalled(); }); for (const failure of [ Object.assign(new TypeError("fetch failed"), { cause: { code: "ECONNRESET" } }), new DOMException("Timed out", "TimeoutError"), Object.assign(new Error("busy"), { status: 429 }), Object.assign(new Error("unavailable"), { status: 503 }), ...["RATE_LIMITED", "QUERY_TIMEOUT", "SOURCE_UNAVAILABLE"].map((code) => ({ content: [], isError: true, structuredContent: { error: { code } } })), ]) { it(`retries transient status failures (${"name" in failure ? failure.name : failure.structuredContent.error.code})`, async () => { let calls = 0; const query = mock(async () => { if (calls++ === 0) { if (failure instanceof Error || failure instanceof DOMException) throw failure; return failure; } return result("succeeded"); }); const pause = mock(async (_ms: number, _signal: AbortSignal) => {}); const wait = createTaskCalculationWaiter({ query }, () => owner, pause, () => 0.5); expect(await wait("s", new AbortController().signal)).toContain("succeeded"); expect(pause.mock.calls.map(([ms]) => ms)).toEqual([1000]); expect(query).toHaveBeenCalledTimes(2); }); } it("bounds exponential retry delays and fails clearly after exhaustion", async () => { const names: string[] = []; const query = mock(async (_id: string, _owner: unknown, name: string) => { names.push(name); throw Object.assign(new Error("unavailable"), { status: 503 }); }); const pause = mock(async (_ms: number, _signal: AbortSignal) => {}); const wait = createTaskCalculationWaiter({ query }, () => owner, pause, () => 1); await expect(wait("s", new AbortController().signal)).rejects.toThrow("after transient retries"); expect(pause.mock.calls.map(([ms]) => ms)).toEqual([1250, 2500, 5000]); expect(names).toEqual(Array(4).fill("list_calculation_jobs")); }); for (const failure of [ Object.assign(new Error("denied"), { status: 401 }), Object.assign(new Error("denied"), { status: 403 }), ...["AUTH_REQUIRED", "QUERY_CONTEXT_DENIED", "QUERY_CONTEXT_STALE"].map((code) => ({ content: [], isError: true, structuredContent: { error: { code } } })), ]) { it("fails authorization and stale context errors without retrying", async () => { const query = mock(async () => { if (failure instanceof Error) throw failure; return failure; }); const pause = mock(async (_ms: number, _signal: AbortSignal) => {}); await expect(createTaskCalculationWaiter({ query }, () => owner, pause)("s", new AbortController().signal)).rejects.toThrow(); expect(query).toHaveBeenCalledTimes(1); expect(pause).not.toHaveBeenCalled(); }); } it("aborts retry backoff without reading again or cancelling jobs", async () => { const controller = new AbortController(); const names: string[] = []; const query = mock(async (_id: string, _owner: unknown, name: string) => { names.push(name); throw new DOMException("Timed out", "TimeoutError"); }); const wait = createTaskCalculationWaiter({ query }, () => owner, async () => { controller.abort(); }); await expect(wait("s", controller.signal)).rejects.toThrow(); expect(names).toEqual(["list_calculation_jobs"]); }); it("returns new terminal jobs while other jobs remain pending and checks pending jobs next round", async () => { const mixed = { content: [], structuredContent: { data: [{ job_id: "job-1", status: "succeeded" }, { job_id: "job-2", status: "running" }], page: { has_more: false } } }; const finished = { content: [], structuredContent: { data: [{ job_id: "job-1", status: "succeeded" }, { job_id: "job-2", status: "failed" }], page: { has_more: false } } }; const outputs = [mixed, mixed, finished]; const query = mock(async () => outputs.shift()!); const pause = mock(async (_ms: number, _signal: AbortSignal) => {}); const wait = createTaskCalculationWaiter({ query }, () => owner, pause); const signal = new AbortController().signal; const first = await wait("s", signal); expect(first).toContain("job-1"); expect(first).not.toContain("job-2"); expect(pause).not.toHaveBeenCalled(); const second = await wait("s", signal); expect(second).toContain("job-2"); expect(second).not.toContain("job-1"); expect(pause).toHaveBeenCalledTimes(1); });