/** * KHAELOR * File: tests/anthropic/cancel.test.ts * Description: Cancellation tests — AbortSignal propagation before start, mid-stream, and during retry backoff. * * Author: Simon-Pierre Boucher * Contact: contact@spboucher.ai */ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { APIError, APIUserAbortError } from "@anthropic-ai/sdk"; import type { RawMessageStreamEvent } from "@anthropic-ai/sdk/resources/messages"; import { AnthropicModelClient } from "../../src/anthropic/client.js"; import { ModelError } from "../../src/anthropic/errors.js"; import type { ModelEvent, ModelRequest } from "../../src/anthropic/types.js"; import { asRawStream, messageStart, simpleTextStream, textBlockStart, textDelta } from "./fixtures.js"; const REQUEST: ModelRequest = { model: "claude-sonnet-5", system: [{ name: "identity", text: "You are KHAELOR." }], messages: [{ role: "user", content: [{ type: "text", text: "hi" }] }], tools: [], maxOutputTokens: 64, }; function isCancelled(e: unknown): boolean { return e instanceof ModelError && e.kind === "cancelled"; } describe("AnthropicModelClient cancellation", () => { it("throws cancelled immediately when the signal is already aborted", async () => { const factory = vi.fn(); const client = new AnthropicModelClient({ apiKey: "test-key", streamFactory: factory }); const controller = new AbortController(); controller.abort(); await expect( (async () => { for await (const _ of client.stream(REQUEST, controller.signal)) { // unreachable } })(), ).rejects.toSatisfy(isCancelled); expect(factory).not.toHaveBeenCalled(); }); it("passes the AbortSignal to the raw stream factory (it reaches the HTTP request)", async () => { const factory = vi.fn().mockImplementation(() => Promise.resolve(asRawStream(simpleTextStream()))); const client = new AnthropicModelClient({ apiKey: "test-key", streamFactory: factory }); const controller = new AbortController(); for await (const _ of client.stream(REQUEST, controller.signal)) { // drain } expect(factory).toHaveBeenCalledWith(expect.anything(), controller.signal); }); it("aborting mid-stream surfaces ModelError{kind:cancelled} after real deltas arrived", async () => { const controller = new AbortController(); async function* abortableStream(): AsyncGenerator { yield messageStart(); yield textBlockStart(0); yield textDelta(0, "Hel"); // Simulate the SDK: the aborted HTTP request rejects with APIUserAbortError. await new Promise((_, reject) => { if (controller.signal.aborted) { reject(new APIUserAbortError()); return; } controller.signal.addEventListener("abort", () => reject(new APIUserAbortError()), { once: true, }); }); } const client = new AnthropicModelClient({ apiKey: "test-key", streamFactory: () => Promise.resolve(abortableStream()), }); const seen: ModelEvent[] = []; await expect( (async () => { for await (const event of client.stream(REQUEST, controller.signal)) { seen.push(event); if (event.type === "text-delta") controller.abort(); } })(), ).rejects.toSatisfy(isCancelled); expect(seen.map((e) => e.type)).toEqual(["started", "text-delta"]); }); it("aborting during retry backoff cancels promptly without another attempt (fake timers)", async () => { vi.useFakeTimers(); try { const factory = vi .fn() .mockRejectedValue( APIError.generate(500, { error: { type: "error", message: "boom" } }, "boom", new Headers()), ); const client = new AnthropicModelClient({ apiKey: "test-key", streamFactory: factory }); const controller = new AbortController(); const promise = (async () => { for await (const _ of client.stream(REQUEST, controller.signal)) { // unreachable } })(); const expectation = expect(promise).rejects.toSatisfy(isCancelled); await vi.advanceTimersByTimeAsync(100); // inside the first ≥250 ms backoff window expect(factory).toHaveBeenCalledTimes(1); controller.abort(); await expectation; expect(factory).toHaveBeenCalledTimes(1); // no retry after abort } finally { vi.useRealTimers(); } }); });