Skip to content
Closed
Show file tree
Hide file tree
Changes from 3 commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
e61feb1
feat(api): add throwIfAborted helper and completePrompt options regre…
easonliang28 Aug 19, 2026
644b63c
feat(api): abort signal support for opencode-go, unbound, vercel-ai-g…
easonliang28 Aug 19, 2026
88a8446
fix(api): address CodeRabbit review on gateway-b abort handling
easonliang28 Aug 19, 2026
a3c4e6b
fix(api): preserve abort identity in gateway-b completion error handling
easonliang28 Aug 20, 2026
5c604a4
test(api): close changed-line coverage gaps in opencode-go, unbound
easonliang28 Aug 20, 2026
a0117fb
feat(api): add shared isRequestAborted and createAbortError helpers t…
easonLiangWorldedtech Aug 21, 2026
fe34190
Merge branch 'feat/abort-r1-foundation' into feat/abort-r1-gateway-b
easonliang28 Aug 21, 2026
6119cc1
refactor(api): use shared abort helpers from foundation utils in open…
easonLiangWorldedtech Aug 21, 2026
9d88d3f
Merge branch 'main' into feat/abort-r1-gateway-b
easonLiangWorldedtech Aug 30, 2026
6b8443d
Merge branch 'main' into feat/abort-r1-gateway-b
easonLiangWorldedtech Sep 1, 2026
55de3fd
Merge branch 'main' into feat/abort-r1-gateway-b
easonLiangWorldedtech Sep 2, 2026
c8e2df1
Merge upstream/main (79cd12f2c) into feat/abort-r1-gateway-b
easonliang28 Sep 3, 2026
9b87229
fix(ci): declare vitest as a root devDependency so the mutation gate …
easonliang28 Sep 3, 2026
10f21eb
test: add mutation-killing abort/bridge/loop tests to gateway B specs
easonliang28 Sep 3, 2026
ff747d0
Merge branch 'main' into feat/abort-r1-gateway-b
easonLiangWorldedtech Sep 3, 2026
2c4a42e
Merge branch 'main' into feat/abort-r1-gateway-b
edelauna Sep 3, 2026
823b2b1
Merge branch 'main' of https://github.com/Zoo-Code-Org/Zoo-Code into …
easonliang28 Sep 4, 2026
5fbb4cd
fix(api): address CodeRabbit abort-signal findings in opencode-go and…
easonliang28 Sep 5, 2026
3ff6877
Merge remote-tracking branch 'upstream/main' into feat/abort-r1-gatew…
easonliang28 Sep 5, 2026
b57adf3
test(api): assert two-arg create call with abort signal in the Fable …
easonliang28 Sep 5, 2026
25f290c
test(api): kill mutation survivors in the gateway-b abort paths
easonliang28 Sep 5, 2026
9e3f679
Merge branch main into feat/abort-r1-gateway-b
easonliang28 Sep 6, 2026
ee1e343
fix(api): race Opencode Go createMessage model resolution against the…
easonliang28 Sep 6, 2026
4457a00
fix(api): harden Opencode Go abort race and bridge for the Stryker mu…
easonliang28 Sep 6, 2026
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
378 changes: 362 additions & 16 deletions src/api/providers/__tests__/opencode-go.spec.ts

Large diffs are not rendered by default.

237 changes: 233 additions & 4 deletions src/api/providers/__tests__/unbound.spec.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,27 @@
import { Anthropic } from "@anthropic-ai/sdk"
import OpenAI from "openai"
import OpenAI, { APIConnectionTimeoutError, APIUserAbortError } from "openai"

import { UnboundHandler } from "../unbound"
import { asyncStreamFrom, collectStream } from "../../../test-utils/stream"
import { clearAllMocks } from "../../../test-utils/reset"
import { makeCreateMessageMetadata } from "../../../test-utils/api"

vi.mock("openai", () => {
const createMock = vi.fn()
// Single hoisted mock shared by the `openai` factory and every test so tests
// can configure the SDK `create` call without untyped access casts.
const sharedMockCreate = vi.hoisted(() => vi.fn())

// The real SDK error classes are re-exported alongside the mocked client so
// tests can emulate the SDK's abort/timeout rejections (APIUserAbortError,
// APIConnectionTimeoutError) and the provider's instanceof checks resolve.
vi.mock("openai", async () => {
const actual = await vi.importActual<typeof import("openai")>("openai")
return {
...actual,
default: vi.fn(function () {
return {
chat: {
completions: {
create: createMock,
create: sharedMockCreate,
},
},
}
Expand Down Expand Up @@ -175,6 +184,7 @@ describe("UnboundHandler", () => {
mode: "architect",
},
}),
expect.objectContaining({ signal: expect.any(AbortSignal) }),
)
})

Expand All @@ -195,6 +205,225 @@ describe("UnboundHandler", () => {
expect.objectContaining({
messages: [{ role: "system", content: "Write a haiku" }],
}),
{},
)
})

it("completePrompt should pass abort signal through to client", async () => {
const controller = new AbortController()
sharedMockCreate.mockResolvedValue({
choices: [{ message: { content: "completed text" } }],
})

const handler = new UnboundHandler({
unboundApiKey: "test-key",
unboundModelId: "openai/gpt-4o",
})

await handler.completePrompt("Write a haiku", { abortSignal: controller.signal })
expect(sharedMockCreate).toHaveBeenCalledWith(
expect.objectContaining({ model: expect.any(String) }),
expect.objectContaining({ signal: controller.signal }),
)
})

it("completePrompt should pass timeout through to client", async () => {
sharedMockCreate.mockResolvedValue({
choices: [{ message: { content: "completed text" } }],
})

const handler = new UnboundHandler({
unboundApiKey: "test-key",
unboundModelId: "openai/gpt-4o",
})

await handler.completePrompt("Write a haiku", { timeoutMs: 5000 })
expect(sharedMockCreate).toHaveBeenCalledWith(
expect.objectContaining({ model: expect.any(String) }),
expect.objectContaining({ timeout: 5000 }),
)
})

it("completePrompt should omit the timeout option when timeoutMs is 0", async () => {
// The OpenAI SDK treats timeout: 0 as an immediate abort, so the
// "disabled" value must never be forwarded — assert the absence of
// the option (a forwarded timeout: 0 would fail this assertion).
sharedMockCreate.mockResolvedValue({
choices: [{ message: { content: "completed text" } }],
})

const handler = new UnboundHandler({
unboundApiKey: "test-key",
unboundModelId: "openai/gpt-4o",
})

await handler.completePrompt("Write a haiku", { timeoutMs: 0 })
const call = sharedMockCreate.mock.calls[sharedMockCreate.mock.calls.length - 1]
const requestOptions = call[1] as { timeout?: number } | undefined
expect(requestOptions).not.toHaveProperty("timeout")
})

it("completePrompt should preserve abort identity when the caller aborts", async () => {
// Emulate the OpenAI SDK: an aborted request signal rejects with
// APIUserAbortError ("Request was aborted." — the trailing period would
// fail task-level abort detection, so the provider must normalize it).
sharedMockCreate.mockImplementation(async (_params: unknown, options: { signal?: AbortSignal }) => {
if (options?.signal?.aborted) {
throw new APIUserAbortError()
}
throw new Error("boom")
})

const handler = new UnboundHandler({
unboundApiKey: "test-key",
unboundModelId: "openai/gpt-4o",
})
const controller = new AbortController()
controller.abort()

const error = await handler.completePrompt("Write a haiku", { abortSignal: controller.signal }).then(
() => undefined,
(e: unknown) => e,
)
expect(error).toMatchObject({ name: "AbortError" })
expect((error as Error).message.endsWith("aborted")).toBe(true)
expect((error as Error).message).not.toContain("completion error")
})

it("completePrompt should surface request timeouts as an AbortError", async () => {
// Emulate the OpenAI SDK: when the request timeout fires, the SDK
// surfaces APIConnectionTimeoutError ("Request timed out.") once retries
// are exhausted — verified against openai v5.23.2 against a hung server.
sharedMockCreate.mockImplementation(async (_params: unknown, options: { timeout?: number }) => {
await new Promise((resolve) => setTimeout(resolve, options?.timeout ?? 50))
throw new APIConnectionTimeoutError()
})

const handler = new UnboundHandler({
unboundApiKey: "test-key",
unboundModelId: "openai/gpt-4o",
})

const error = await handler.completePrompt("Write a haiku", { timeoutMs: 50 }).then(
() => undefined,
(e: unknown) => e,
)
expect(error).toMatchObject({ name: "AbortError" })
expect((error as Error).message.endsWith("aborted")).toBe(true)
expect((error as Error).message).not.toContain("completion error")
})
it("completePrompt should work without options (backward compatible)", async () => {
sharedMockCreate.mockResolvedValue({
choices: [{ message: { content: "completed text" } }],
})

const handler = new UnboundHandler({
unboundApiKey: "test-key",
unboundModelId: "openai/gpt-4o",
})

const result = await handler.completePrompt("Write a haiku")
expect(result).toBe("completed text")
})

describe("createMessage abort signal bridging", () => {
it("rejects the request with an AbortError when the external signal is already aborted", async () => {
let requestError: unknown
sharedMockCreate.mockImplementation(async () => {
// The real SDK rejects with an AbortError when its request signal is aborted.
requestError = new DOMException("The operation was aborted.", "AbortError")
throw requestError
})

const controller = new AbortController()
controller.abort()

const handler = new UnboundHandler({
unboundApiKey: "test-key",
unboundModelId: "openai/gpt-4o",
})

const stream = handler.createMessage(
"system",
[{ role: "user", content: "hi" }],
makeCreateMessageMetadata({ abortSignal: controller.signal }),
)

// The bridge surfaces a DOM-standard AbortError (series standard)
// instead of the wrapped completion error.
await expect(collectStream(stream)).rejects.toMatchObject({
name: "AbortError",
message: "Unbound request aborted",
})
expect(requestError).toMatchObject({ name: "AbortError" })
})

it("aborts the in-flight request when the external signal fires mid-stream", async () => {
let capturedSignal: AbortSignal | undefined
sharedMockCreate.mockImplementation(async (_params: unknown, options: { signal?: AbortSignal }) => {
capturedSignal = options?.signal
return (async function* () {
yield { choices: [{ delta: { content: "partial" } }] }
await new Promise((_resolve, reject) => {
const onAbort = () => reject(new DOMException("The operation was aborted.", "AbortError"))
options?.signal?.addEventListener("abort", onAbort, { once: true })
})
})()
})

const controller = new AbortController()
const handler = new UnboundHandler({
unboundApiKey: "test-key",
unboundModelId: "openai/gpt-4o",
})

const consumed = collectStream(
handler.createMessage(
"system",
[{ role: "user", content: "hi" }],
makeCreateMessageMetadata({ abortSignal: controller.signal }),
),
)

// Let the request start and the first chunk be yielded before aborting.
await new Promise((resolve) => setTimeout(resolve, 25))
controller.abort()

await expect(consumed).rejects.toMatchObject({ name: "AbortError" })
expect(capturedSignal?.aborted).toBe(true)
})

it("detaches the bridged abort listener when the request completes normally", async () => {
// The listener is added with { once: true }, so it only detaches on
// abort. A task-scoped signal spanning many requests must not
// accumulate a listener per request: assert explicit removal after a
// normal (non-aborted) completion.
sharedMockCreate.mockImplementation(async () =>
asyncStreamFrom([
{ choices: [{ delta: { content: "ok" } }] },
{ choices: [{ delta: {} }], usage: { prompt_tokens: 1, completion_tokens: 1 } },
]),
)

const controller = new AbortController()
const removeListenerSpy = vi.spyOn(controller.signal, "removeEventListener")

const handler = new UnboundHandler({
unboundApiKey: "test-key",
unboundModelId: "openai/gpt-4o",
})

const chunks = await collectStream(
handler.createMessage(
"system",
[{ role: "user", content: "hi" }],
makeCreateMessageMetadata({ abortSignal: controller.signal }),
),
)

expect(chunks).toContainEqual({ type: "text", text: "ok" })
expect(removeListenerSpy).toHaveBeenCalledWith("abort", expect.any(Function))
expect(controller.signal.aborted).toBe(false)
})
})
})
Loading
Loading