diff --git a/packages/config-eslint/api-provider-hygiene.js b/packages/config-eslint/api-provider-hygiene.js index 46fc303731..f340694834 100644 --- a/packages/config-eslint/api-provider-hygiene.js +++ b/packages/config-eslint/api-provider-hygiene.js @@ -63,6 +63,16 @@ export const apiProviderHygieneConfig = { }, } +// Shared by every spec lane, not only src/api: an abort assertion must not pass on a raw SDK error. +export const abortSubstringSelector = { + selector: + // Strings contain literal punctuation; only regex source can contain anchors, wildcards, + // optional suffix groups, or word boundaries around the generic abort wording. + "CallExpression[callee.property.name=/^toThrow(Error)?$/]:not([callee.object.property.name='not']):matches([arguments.0.value=/^(the )?(request )?(was )?abort(ed)?[.]*$/i], [arguments.0.regex.pattern=/^[$^]?(?:\\.\\*)?(the )?(request )?(was )?abort(ed)?(?:\\(\\?:(?:ed|ion|al)\\)\\??)?(?:\\\\b)?(?:\\.\\*)?[\\\\.$]*$/i])", + message: + "A bare 'abort' substring also matches a raw SDK error. Assert the AbortError name and the full provider message.", +} + // Spec files under src/api. A loop of expects in one test stops at the first failing case and hides the rest. export const apiProviderTestSelectors = [ { @@ -70,6 +80,7 @@ export const apiProviderTestSelectors = [ message: "Use it.each for repeated cases. A loop of expects in one test stops at the first failing case and does not name it.", }, + abortSubstringSelector, ] // A new @ts-ignore in a provider spec hides a private-member access. Use bracket notation or a typed double. diff --git a/packages/config-eslint/api-provider-hygiene.test.js b/packages/config-eslint/api-provider-hygiene.test.js index 62f197fa6b..310500d62f 100644 --- a/packages/config-eslint/api-provider-hygiene.test.js +++ b/packages/config-eslint/api-provider-hygiene.test.js @@ -1,7 +1,8 @@ import assert from "node:assert/strict" import { describe, it } from "node:test" +import { fileURLToPath, URL } from "node:url" -import { Linter } from "eslint" +import { ESLint, Linter } from "eslint" import typescriptEslint from "typescript-eslint" import { apiProviderHygieneConfig, apiProviderTestSelectors, apiSpecTsIgnoreConfig } from "./api-provider-hygiene.js" @@ -173,6 +174,37 @@ describe("api provider hygiene: spec loops", () => { assert.deepEqual(lintSpec(code), []) }) } + + for (const code of [ + 'await expect(p).rejects.toThrow("aborted")', + "expect(() => f()).toThrow('abort')", + 'await expect(p).rejects.toThrowError("aborted")', + "expect(() => f()).toThrow(/abort/i)", + "await expect(p).rejects.toThrowError(/aborted/i)", + 'await expect(p).rejects.toThrow("was aborted")', + 'await expect(p).rejects.toThrow("Request was aborted.")', + 'await expect(p).rejects.toThrow("The request was aborted")', + "await expect(p).rejects.toThrow(/was aborted/)", + ]) { + it(`flags a bare abort substring: ${code}`, () => { + assert.equal(lintSpec(code).length, 1) + }) + } + + for (const code of [ + 'await expect(p).rejects.toThrow("The Requesty request was aborted")', + 'await expect(p).rejects.toMatchObject({ name: "AbortError" })', + 'await expect(p).rejects.toThrow("API Error")', + 'expect(() => f()).not.toThrow("aborted")', + 'await expect(p).rejects.not.toThrow("aborted")', + "expect(() => f()).not.toThrowError(/abort/i)", + "await expect(p).rejects.not.toThrowError(/aborted/i)", + "await expect(p).rejects.toThrow(/^The Requesty request was aborted$/)", + ]) { + it(`allows: ${code}`, () => { + assert.deepEqual(lintSpec(code), []) + }) + } }) describe("api provider hygiene: hand-built AbortError", () => { @@ -245,3 +277,68 @@ describe("api provider hygiene: @ts-ignore in specs", () => { assert.deepEqual(lintSpecFile("// @ts-ignore x\nhandler.client", "services/__tests__/example.spec.ts"), []) }) }) + +// Resolve each lane through the extension's real config before exercising its syntax rules. +// Isolating syntax rules avoids requiring fixture files in the TypeScript project. +describe("abort assertions in the extension ESLint config", () => { + const srcRoot = fileURLToPath(new URL("../../src/", import.meta.url)) + const eslint = new ESLint({ cwd: srcRoot }) + + for (const filename of [ + "core/task/__tests__/example.spec.ts", + "core/config/__tests__/example.spec.ts", + // The workflow tests are exempt from the path-traversal selectors, not from the abort + // rule; listing one proves the lane that re-applies it actually resolves for them. + "services/__tests__/pr-review-state-workflow.test.ts", + ]) { + it(`checks rejected and accepted assertions in ${filename}`, async () => { + const resolved = await eslint.calculateConfigForFile(filename) + const syntaxConfig = [ + { + files: ["**/*.ts"], + languageOptions: { parser: typescriptEslint.parser }, + rules: { "no-restricted-syntax": resolved.rules["no-restricted-syntax"] }, + }, + ] + for (const [code, expectedCount] of [ + ['await expect(p).rejects.toThrow("aborted")', 1], + ["await expect(p).rejects.toThrowError(/abort/i)", 1], + // An escaped dot in the source text is still a bare abort substring: the pattern + // keeps a backslash, so the matcher must allow it before the trailing dot or anchor. + ["await expect(p).rejects.toThrow(/^aborted$/)", 1], + ["await expect(p).rejects.toThrow(/.*aborted.*/i)", 1], + ["await expect(p).rejects.toThrowError(/abort(?:ed)/i)", 1], + ["await expect(p).rejects.toThrow(/aborted\\b/i)", 1], + ['await expect(p).rejects.toThrow("abort$")', 0], + ['await expect(p).rejects.toThrow("^abort")', 0], + ["await expect(p).rejects.toThrow(/.*The Requesty request was aborted.*/i)", 0], + ["await expect(p).rejects.toThrow(/The Requesty request was aborted\\b/i)", 0], + ["await expect(p).rejects.toThrow(/^Request was aborted\\.$/)", 1], + ["await expect(p).rejects.toThrowError(/request aborted\\.$/i)", 1], + ["await expect(p).rejects.toThrow(/^request aborted\\$/)", 1], + // An inline non-capturing group is regex source, not part of the asserted + // message: an anchored match that stops short of it lets a bare abort through. + ["await expect(p).rejects.toThrow(/abort(?:ed)?/i)", 1], + ["await expect(p).rejects.toThrowError(/^request aborted(?:ed)?$/i)", 1], + ["await expect(p).rejects.toThrow(/cancel(?:ed)?/i)", 0], + [ + "await expect(p).rejects.toThrow(/^The Requesty request was aborted(?:ed)? because the peer closed the stream\\.$/)", + 0, + ], + ['await expect(p).rejects.not.toThrow("aborted")', 0], + ["await expect(p).rejects.toThrow(/^Request was aborted because the peer closed the stream\\.$/)", 0], + [ + 'await expect(p).rejects.toMatchObject({ name: "AbortError", message: "The Requesty request was aborted" })', + 0, + ], + ]) { + const messages = linter.verify(code, syntaxConfig, { filename }) + assert.equal(messages.length, expectedCount, code) + if (expectedCount) { + assert.equal(messages[0].ruleId, "no-restricted-syntax") + assert.match(messages[0].message, /bare 'abort' substring/) + } + } + }) + } +}) diff --git a/src/api/providers/__tests__/opencode-go.spec.ts b/src/api/providers/__tests__/opencode-go.spec.ts index b96cf2acce..6ee2df0f87 100644 --- a/src/api/providers/__tests__/opencode-go.spec.ts +++ b/src/api/providers/__tests__/opencode-go.spec.ts @@ -1100,13 +1100,19 @@ describe("OpencodeGoHandler", () => { it("stops an in-flight Responses iterator when its abort signal rejects the read", async () => { const controller = new AbortController() + // The iterator's own listener and the request-level listener race to reject the same + // promise, so the double is one object and the assertion below pins which rejection + // propagated. opencode-go hands the caller's signal straight to the SDK and never + // normalises it into a provider AbortError, so there is no provider message to assert + // on this path: identity is the strongest statement that is true here. + const abortDouble = new Error("request aborted") let rejectNext: ((reason?: unknown) => void) | undefined const iterator = { next: vitest.fn().mockImplementation( () => new Promise((_resolve, reject) => { rejectNext = reject - controller.signal.addEventListener("abort", () => reject(new Error("request aborted")), { + controller.signal.addEventListener("abort", () => reject(abortDouble), { once: true, }) }), @@ -1117,7 +1123,7 @@ describe("OpencodeGoHandler", () => { }, } mockResponsesCreate.mockImplementation(async (_body: unknown, options: { signal?: AbortSignal }) => { - options.signal?.addEventListener("abort", () => rejectNext?.(new Error("request aborted")), { + options.signal?.addEventListener("abort", () => rejectNext?.(abortDouble), { once: true, }) return iterator @@ -1132,7 +1138,7 @@ describe("OpencodeGoHandler", () => { await vitest.waitFor(() => expect(mockResponsesCreate).toHaveBeenCalled()) controller.abort() - await expect(nextPromise).rejects.toThrow("request aborted") + await expect(nextPromise).rejects.toBe(abortDouble) expect(iterator.return).toHaveBeenCalled() }) @@ -1473,8 +1479,14 @@ describe("OpencodeGoHandler", () => { it("rejects non-streaming Responses completion when the abort signal fires", async () => { const controller = new AbortController() + // One double object here too. The Responses completion path wraps every failure + // (`opencode-go.ts:439` throws `Opencode Go completion error: `), so the + // abort surfaces as a new Error carrying the double's text behind the provider prefix, + // not as the double and not as an AbortError. The assertion below pins that whole + // message; a bare "abort" substring would also match a raw SDK error. + const abortDouble = new Error("request aborted") const request = new Promise((_resolve, reject) => { - controller.signal.addEventListener("abort", () => reject(new Error("request aborted")), { once: true }) + controller.signal.addEventListener("abort", () => reject(abortDouble), { once: true }) }) mockResponsesCreate.mockReturnValue(request) const handler = new OpencodeGoHandler(lunaOptions) @@ -1483,7 +1495,10 @@ describe("OpencodeGoHandler", () => { await vitest.waitFor(() => expect(mockResponsesCreate).toHaveBeenCalled()) controller.abort() - await expect(completion).rejects.toThrow("request aborted") + await expect(completion).rejects.toMatchObject({ + name: "Error", + message: `Opencode Go completion error: ${abortDouble.message}`, + }) }) it("completePrompt calls responses.create and returns output_text", async () => { diff --git a/src/api/providers/__tests__/requesty.spec.ts b/src/api/providers/__tests__/requesty.spec.ts index 346ea4f14c..31a245570e 100644 --- a/src/api/providers/__tests__/requesty.spec.ts +++ b/src/api/providers/__tests__/requesty.spec.ts @@ -10,6 +10,7 @@ import { Anthropic } from "@anthropic-ai/sdk" import OpenAI from "openai" import { providerIdentifiers, requestyDefaultModelInfo } from "@roo-code/types" +import type { ModelRecord } from "@roo-code/types" import { getModels } from "../fetchers/modelCache" import { RequestyHandler } from "../requesty" @@ -18,6 +19,8 @@ import { ApiHandlerCreateMessageMetadata } from "../../index" import { makeApiHandlerOptions } from "../../../test-utils/api" import { asyncStreamFrom, collectStream } from "../../../test-utils/stream" import { clearAllMocks } from "../../../test-utils/reset" +import { withSettleGuard } from "../../../test-utils/settle-guard" +import { flushMacrotasks } from "../../../test-utils/timers" const mockCreate = vitest.fn() @@ -616,6 +619,13 @@ describe("RequestyHandler", () => { }) describe("completePrompt", () => { + // The createMessage tests leave behind a persistent stream mock plus queued + // one-shot implementations; reset so each completePrompt test starts from a clean + // mock (its own mockSetup below is authoritative). + beforeEach(() => { + mockCreate.mockReset() + }) + it("returns correct response", async () => { const handler = new RequestyHandler(mockOptions) const mockResponse = { choices: [{ message: { content: "test completion" } }] } @@ -626,12 +636,15 @@ describe("RequestyHandler", () => { expect(result).toBe("test completion") - expect(mockCreate).toHaveBeenCalledWith({ - model: mockOptions.requestyModelId, - max_tokens: 8192, - messages: [{ role: "system", content: "test prompt" }], - temperature: 0, - }) + expect(mockCreate).toHaveBeenCalledWith( + { + model: mockOptions.requestyModelId, + max_tokens: 8192, + messages: [{ role: "system", content: "test prompt" }], + temperature: 0, + }, + {}, + ) }) it("omits temperature for Claude Fable 5 in completePrompt", async () => { @@ -645,12 +658,15 @@ describe("RequestyHandler", () => { await handler.completePrompt("test prompt") - expect(mockCreate).toHaveBeenCalledWith({ - model: "anthropic/claude-fable-5", - max_tokens: 8192, - messages: [{ role: "system", content: "test prompt" }], - temperature: undefined, - }) + expect(mockCreate).toHaveBeenCalledWith( + { + model: "anthropic/claude-fable-5", + max_tokens: 8192, + messages: [{ role: "system", content: "test prompt" }], + temperature: undefined, + }, + {}, + ) }) it("omits temperature for Claude Sonnet 5 in completePrompt", async () => { @@ -664,12 +680,15 @@ describe("RequestyHandler", () => { await handler.completePrompt("test prompt") - expect(mockCreate).toHaveBeenCalledWith({ - model: "anthropic/claude-sonnet-5", - max_tokens: 8192, - messages: [{ role: "system", content: "test prompt" }], - temperature: undefined, - }) + expect(mockCreate).toHaveBeenCalledWith( + { + model: "anthropic/claude-sonnet-5", + max_tokens: 8192, + messages: [{ role: "system", content: "test prompt" }], + temperature: undefined, + }, + {}, + ) }) it("omits temperature for Claude Opus 5 in completePrompt", async () => { @@ -683,12 +702,15 @@ describe("RequestyHandler", () => { await handler.completePrompt("test prompt") - expect(mockCreate).toHaveBeenCalledWith({ - model: "anthropic/claude-opus-5", - max_tokens: 8192, - messages: [{ role: "system", content: "test prompt" }], - temperature: undefined, - }) + expect(mockCreate).toHaveBeenCalledWith( + { + model: "anthropic/claude-opus-5", + max_tokens: 8192, + messages: [{ role: "system", content: "test prompt" }], + temperature: undefined, + }, + {}, + ) }) it("handles API errors", async () => { @@ -705,6 +727,708 @@ describe("RequestyHandler", () => { await expect(handler.completePrompt("test prompt")).rejects.toThrow("Unexpected error") }) + it.each([ + // Ceiled up: 1500.5 ms is a usable timeout and toSdkTimeoutMs rounds it to 1501. + // Without the conversion the SDK would receive 1500.5 and the assertion below fails. + { input: 1500.5, expected: 1501 }, + // Clamped down: 3e9 exceeds the platform timer ceiling, so it is capped at MAX. + // Without the conversion the SDK would receive 3e9 and the assertion below fails. + { input: 3e9, expected: 2147483647 }, + ] as const)( + "normalises a fractional or oversized timeoutMs to the SDK timeout value $input -> $expected", + async ({ input, expected }) => { + // The wiring under test is toSdkTimeoutMs at the createOptions construction: whole + // numbers alone cannot tell the conversion from a pass-through, so both a value that + // must be ceiled and a value that must be clamped are asserted on what the SDK is + // actually handed. + const handler = new RequestyHandler(mockOptions) + mockCreate.mockResolvedValueOnce({ choices: [{ message: { content: "response" } }] }) + await handler.completePrompt("test prompt", { timeoutMs: input }) + const options = mockCreate.mock.calls.at(-1)?.[1] as { timeout?: number } | undefined + expect(options?.timeout).toBe(expected) + }, + ) + + it.each([ + ["an external signal", { abortSignal: new AbortController().signal }], + ["a per-request timeout", { timeoutMs: 5_000 }], + ])( + "does not classify an ordinary SDK failure as an abort when %s is configured", + async (_label, options: { abortSignal?: AbortSignal; timeoutMs?: number }) => { + // The catch must key on the MERGED SIGNAL BEING ABORTED, not on the signal merely + // being configured: a real API failure with a live signal is a provider error. + const handler = new RequestyHandler(mockOptions) + mockCreate.mockRejectedValue(new Error("API Error")) + + const error = await handler.completePrompt("test prompt", options).catch((e: unknown) => e) + + expect(error).toBeInstanceOf(Error) + expect((error as Error).name).not.toBe("AbortError") + // Assert the normalised message, not a substring: "API Error" is also the message of + // the unwrapped SDK error, so a catch that skipped handleOpenAIError would pass a + // contains-check while the provider contract was gone. + expect((error as Error).message).toBe("Requesty completion error: API Error") + }, + ) + + it("should pass abort signal through to client", async () => { + const handler = new RequestyHandler(mockOptions) + const controller = new AbortController() + + let notifyCreateStarted!: () => void + const createStarted = new Promise((resolve) => { + notifyCreateStarted = resolve + }) + let requestSignal: AbortSignal | undefined + mockCreate.mockImplementationOnce(async (_params: unknown, options?: { signal?: AbortSignal }) => { + notifyCreateStarted() + requestSignal = options?.signal + // Emulate the OpenAI SDK: the in-flight request settles through its signal. + await new Promise((resolve) => { + if (options?.signal?.aborted) { + resolve() + } else { + options?.signal?.addEventListener("abort", () => resolve(), { once: true }) + } + }) + return { choices: [{ message: { content: "response" } }] } + }) + + const promise = handler.completePrompt("test prompt", { abortSignal: controller.signal }) + void promise.catch(() => {}) + await withSettleGuard(createStarted) + + expect(requestSignal).toBeDefined() + // The SDK receives the bridged per-request signal rather than the caller's own: the SDK + // leaves its abort listener attached after a completed request, so that listener has to + // land on a signal that dies with the request. What the contract requires is that the + // signal tracks the caller, which the mock above proves: it settles only through the + // signal it was handed. + expect(requestSignal).not.toBe(controller.signal) + expect(requestSignal?.aborted).toBe(false) + + controller.abort() + // The late response must be discarded, and the bridged signal must have aborted with the + // caller's. + await expect(withSettleGuard(promise)).rejects.toMatchObject({ + name: "AbortError", + message: "The Requesty request was aborted", + }) + expect(requestSignal?.aborted).toBe(true) + }) + + it("should pass timeout through to client", async () => { + const handler = new RequestyHandler(mockOptions) + mockCreate.mockResolvedValueOnce({ choices: [{ message: { content: "response" } }] }) + + // Capture the exact timeout signal instance the provider creates so the + // test can assert identity, not just type, for the signal it forwards. + const timeoutSignalSpy = vitest.spyOn(AbortSignal, "timeout") + try { + await handler.completePrompt("test prompt", { timeoutMs: 5000 }) + // Assert the factory argument and call count too: the identity check + // below would also pass for a signal created with the wrong duration. + expect(timeoutSignalSpy).toHaveBeenCalledTimes(1) + expect(timeoutSignalSpy).toHaveBeenCalledWith(5000) + expect(mockCreate).toHaveBeenCalledWith( + expect.objectContaining({ model: expect.any(String) }), + expect.objectContaining({ timeout: 5000 }), + ) + // Without a caller signal the merged signal is exactly the + // AbortSignal.timeout instance; the SDK relies on it to reject with a + // DOM-standard AbortError when the timeout fires. + const clientOptions = mockCreate.mock.calls[0]?.[1] as { signal?: AbortSignal } | undefined + const expectedSignal = timeoutSignalSpy.mock.results[0]?.value + expect(expectedSignal).toBeInstanceOf(AbortSignal) + expect(clientOptions?.signal).toBe(expectedSignal) + } finally { + // Restore on every exit path: a failed assertion above must not leave + // the static spy installed for the remaining tests in this file. + timeoutSignalSpy.mockRestore() + } + }) + + it("should work without options (backward compatible)", async () => { + const handler = new RequestyHandler(mockOptions) + mockCreate.mockResolvedValueOnce({ choices: [{ message: { content: "response" } }] }) + + const result = await handler.completePrompt("test prompt") + expect(result).toBe("response") + }) + + it("rejects with AbortError when the signal is pre-aborted", async () => { + const handler = new RequestyHandler(mockOptions) + mockCreate.mockResolvedValueOnce({ choices: [{ message: { content: "response" } }] }) + + const controller = new AbortController() + controller.abort() + + await expect( + handler.completePrompt("test prompt", { abortSignal: controller.signal }), + ).rejects.toMatchObject({ + name: "AbortError", + message: "This operation was aborted", + }) + expect(mockCreate).not.toHaveBeenCalled() + // Pre-flight cancellation must skip model lookup entirely: the + // getModels mock must remain uncalled, not just the SDK create call. + const { getModels } = await import("../fetchers/modelCache") + expect(vitest.mocked(getModels)).not.toHaveBeenCalled() + }) + + it("rejects with AbortError when the signal aborts during model lookup", async () => { + const handler = new RequestyHandler(mockOptions) + const controller = new AbortController() + + // Model discovery is deferred and never settles: with rejectOnAbort racing the + // lookup, the abort must end the request before the lookup resolves. + let notifyLookupStarted!: () => void + const lookupStarted = new Promise((resolve) => { + notifyLookupStarted = resolve + }) + const deferredModelLookup = new Promise(() => {}) + const { getModels } = await import("../fetchers/modelCache") + vitest.mocked(getModels).mockImplementationOnce(() => { + notifyLookupStarted() + return deferredModelLookup + }) + + const promise = handler.completePrompt("test prompt", { abortSignal: controller.signal }) + // Defensive: the fast-fail path may reject before the barrier below settles, + // which would otherwise surface as an unhandled rejection. + void promise.catch(() => {}) + await withSettleGuard(lookupStarted) + controller.abort() + + await expect(withSettleGuard(promise)).rejects.toMatchObject({ + name: "AbortError", + message: "The Requesty request was aborted", + }) + expect(mockCreate).not.toHaveBeenCalled() + }) + + it("forwards the request signal to the model lookup", async () => { + const handler = new RequestyHandler(mockOptions) + const controller = new AbortController() + const { getModels } = await import("../fetchers/modelCache") + let lookupSignal: AbortSignal | undefined + let notifyLookupStarted!: () => void + const lookupStarted = new Promise((resolve) => { + notifyLookupStarted = resolve + }) + vitest.mocked(getModels).mockImplementationOnce((options) => { + lookupSignal = options.signal + notifyLookupStarted() + return new Promise(() => {}) + }) + + const promise = handler.completePrompt("test prompt", { abortSignal: controller.signal }) + void promise.catch(() => {}) + await withSettleGuard(lookupStarted) + expect(lookupSignal).toBeDefined() + expect(lookupSignal?.aborted).toBe(false) + controller.abort() + + await expect(withSettleGuard(promise)).rejects.toMatchObject({ name: "AbortError" }) + expect(lookupSignal?.aborted).toBe(true) + }) + + it("does not leak an unhandled rejection when the lookup rejects after abort", async () => { + const handler = new RequestyHandler(mockOptions) + const controller = new AbortController() + const { getModels } = await import("../fetchers/modelCache") + let notifyLookupStarted!: () => void + const lookupStarted = new Promise((resolve) => { + notifyLookupStarted = resolve + }) + let rejectLookup!: (error: Error) => void + vitest.mocked(getModels).mockImplementationOnce(() => { + notifyLookupStarted() + return new Promise((_resolve, reject) => { + rejectLookup = reject + }) + }) + + const unhandled: unknown[] = [] + const onUnhandledRejection = (reason: unknown) => unhandled.push(reason) + process.on("unhandledRejection", onUnhandledRejection) + try { + const promise = handler.completePrompt("test prompt", { abortSignal: controller.signal }) + void promise.catch(() => {}) + await withSettleGuard(lookupStarted) + controller.abort() + await expect(withSettleGuard(promise)).rejects.toMatchObject({ + name: "AbortError", + message: "The Requesty request was aborted", + }) + + // The lookup fails after the request already settled as aborted. + rejectLookup(new Error("late lookup failure")) + // Node reports an unhandled rejection only after the queue drains, and a rejection + // that ends up handled has no observable state to poll, so drain the queue through + // the shared helper instead of sleeping. + await flushMacrotasks() + expect(unhandled).toHaveLength(0) + } finally { + process.off("unhandledRejection", onUnhandledRejection) + } + }) + + it("applies timeoutMs to model lookup with no caller signal at all", async () => { + // The comment above completePrompt claims timeoutMs covers model lookup too. With no + // caller signal the only cancellation source is the timeout this method configures, so + // the lookup race must be driven by that merged signal: a never-settling getModels has + // to be ended by the 20ms deadline rather than hanging. Red if the lookup race is + // driven by the bridged caller signal instead (undefined here), which leaves the + // lookup awaited forever and the settle guard fires first. + const handler = new RequestyHandler(mockOptions) + const { getModels } = await import("../fetchers/modelCache") + vitest.mocked(getModels).mockImplementationOnce(() => new Promise(() => {})) + const promise = handler.completePrompt("test prompt", { timeoutMs: 20 }) + void promise.catch(() => {}) + await expect(withSettleGuard(promise)).rejects.toMatchObject({ + name: "AbortError", + message: "The Requesty request was aborted", + }) + expect(mockCreate).not.toHaveBeenCalled() + }) + + it("applies timeoutMs to model lookup while a live caller signal stays untouched", async () => { + // The case above proves the deadline can end a pending lookup with no caller in the picture. + // This one proves it still does when a caller signal *is* present: the lookup must die on the + // merged request signal, not on the caller's, and the caller's own signal must still read as + // live afterwards. Red if the timeout is dropped whenever a caller signal exists (the lookup + // then hangs and the settle guard fires), which no other case here distinguishes. + const handler = new RequestyHandler(mockOptions) + const controller = new AbortController() + const { getModels } = await import("../fetchers/modelCache") + vitest.mocked(getModels).mockImplementationOnce(() => new Promise(() => {})) + const promise = handler.completePrompt("test prompt", { + abortSignal: controller.signal, + timeoutMs: 20, + }) + void promise.catch(() => {}) + await expect(withSettleGuard(promise)).rejects.toMatchObject({ + name: "AbortError", + message: "The Requesty request was aborted", + }) + const lookupSignal = vitest.mocked(getModels).mock.calls.at(-1)?.[0].signal + expect(lookupSignal).toBeInstanceOf(AbortSignal) + expect(lookupSignal?.aborted, "the timeout must cancel the model lookup").toBe(true) + expect(controller.signal.aborted, "the timeout must not surface through the caller signal").toBe(false) + expect(mockCreate, "the lookup must not fall through into a completion").not.toHaveBeenCalled() + }) + + it("rethrows non-abort model lookup failures from completePrompt", async () => { + const handler = new RequestyHandler(mockOptions) + const { getModels } = await import("../fetchers/modelCache") + const lookupError = new Error("lookup failed") + vitest.mocked(getModels).mockImplementationOnce(() => { + return Promise.reject(lookupError) + }) + + // The handler rethrows this exact object, and the fixture owns it: pin the identity rather + // than the message text, which a rebuilt copy of the same failure would also satisfy. + await expect(handler.completePrompt("test prompt")).rejects.toBe(lookupError) + }) + + it("normalizes raw AbortError lookup failures to the provider AbortError", async () => { + const handler = new RequestyHandler(mockOptions) + const { getModels } = await import("../fetchers/modelCache") + const rawAbort = new Error("The user aborted a request") + rawAbort.name = "AbortError" + vitest.mocked(getModels).mockImplementationOnce(() => { + return Promise.reject(rawAbort) + }) + + await expect(handler.completePrompt("test prompt")).rejects.toMatchObject({ + name: "AbortError", + message: "The Requesty request was aborted", + }) + }) + + it("rejects with AbortError when aborted mid-flight", async () => { + const handler = new RequestyHandler(mockOptions) + const controller = new AbortController() + + // Deterministic synchronization: the mock notifies the + // test when the request actually starts, so the abort lands mid-flight (after model + // lookup) instead of winning the race at model discovery on a slow runner. + let notifyCreateStarted!: () => void + const createStarted = new Promise((resolve) => { + notifyCreateStarted = resolve + }) + mockCreate.mockImplementationOnce(async (_params: unknown, options?: { signal?: AbortSignal }) => { + notifyCreateStarted() + // Emulate the OpenAI SDK: the in-flight request rejects when the signal aborts. + await new Promise((resolve) => { + if (options?.signal?.aborted) { + resolve() + } else { + options?.signal?.addEventListener("abort", () => resolve(), { once: true }) + } + }) + const abortError = new Error("The user aborted a request") + abortError.name = "AbortError" + throw abortError + }) + + const promise = handler.completePrompt("test prompt", { abortSignal: controller.signal }) + // Defensive: the fast-fail path may reject before the barrier below settles, + // which would otherwise surface as an unhandled rejection. + void promise.catch(() => {}) + // Abort only once create() has actually started (after model lookup). + await withSettleGuard(createStarted) + controller.abort() + + await expect(withSettleGuard(promise)).rejects.toMatchObject({ + name: "AbortError", + message: "The Requesty request was aborted", + }) + }) + it("rejects with AbortError when only a timeout is provided and it elapses", async () => { + const handler = new RequestyHandler(mockOptions) + mockCreate.mockImplementationOnce(async (_params: unknown, options?: { signal?: AbortSignal }) => { + // Emulate the OpenAI SDK: the in-flight request rejects when the signal times out. + await new Promise((resolve) => { + if (options?.signal?.aborted) { + resolve() + } else { + options?.signal?.addEventListener("abort", () => resolve(), { once: true }) + } + }) + const timeoutError = new Error("TimeoutError: Request timed out.") + timeoutError.name = "TimeoutError" + throw timeoutError + }) + + await expect(handler.completePrompt("test prompt", { timeoutMs: 50 })).rejects.toMatchObject({ + name: "AbortError", + // Pin the complete contract: the timeout must be normalized to the + // provider abort message, not merely to a generic AbortError name. + message: "The Requesty request was aborted", + }) + }) + + it("does not return a late result when the response resolves after abort", async () => { + const handler = new RequestyHandler(mockOptions) + const controller = new AbortController() + + // Emulate the OpenAI SDK: the pending request resolves once the signal aborts, + // i.e. after the caller has already cancelled. + let notifyCreateStarted!: () => void + const createStarted = new Promise((resolve) => { + notifyCreateStarted = resolve + }) + mockCreate.mockImplementationOnce(async (_params: unknown, options?: { signal?: AbortSignal }) => { + notifyCreateStarted() + await new Promise((resolve) => { + if (options?.signal?.aborted) { + resolve() + } else { + options?.signal?.addEventListener("abort", () => resolve(), { once: true }) + } + }) + return { choices: [{ message: { content: "late" } }] } + }) + + const promise = handler.completePrompt("test prompt", { abortSignal: controller.signal }) + // Defensive: the fast-fail path may reject before the barrier below settles, + // which would otherwise surface as an unhandled rejection. + void promise.catch(() => {}) + // Abort while the request is in flight; the resolved response is late and must + // be discarded instead of returned. + await withSettleGuard(createStarted) + controller.abort() + + await expect(withSettleGuard(promise)).rejects.toMatchObject({ + name: "AbortError", + message: "The Requesty request was aborted", + }) + }) + + it("discards a completion that resolves in the same turn the caller aborts", async () => { + // The race above settles at the abort, so the post-completion check is reached only when the + // SDK wins the race outright: the response promise resolves, the abort lands before the + // provider continues with the value, and the value must still be discarded. Aborting from a + // handler on the response promise - queued one step ahead of the provider's race handler - + // pins that interleaving without timing it. + const handler = new RequestyHandler(mockOptions) + const controller = new AbortController() + mockCreate.mockImplementationOnce(() => { + const response = Promise.resolve({ choices: [{ message: { content: "late" } }] }) + void response.then(() => { + queueMicrotask(() => controller.abort()) + }) + return response + }) + + await expect( + handler.completePrompt("test prompt", { abortSignal: controller.signal }), + ).rejects.toMatchObject({ + name: "AbortError", + message: "The Requesty request was aborted", + }) + }) + + it.each([ + ["an empty message content", { choices: [{ message: { content: "" } }] }], + ["no choices at all", { choices: [] }], + ])("returns an empty string when the completion carries %s", async (_label: string, response: unknown) => { + // The fallback is what makes completePrompt return "" rather than an empty value or a + // TypeError, and callers render the result directly. + const handler = new RequestyHandler(mockOptions) + mockCreate.mockResolvedValueOnce(response) + + await expect(handler.completePrompt("test prompt")).resolves.toBe("") + }) + + it("does not forward a non-positive timeout to the client", async () => { + const handler = new RequestyHandler(mockOptions) + mockCreate.mockResolvedValueOnce({ choices: [{ message: { content: "response" } }] }) + + await handler.completePrompt("test prompt", { timeoutMs: 0 }) + expect(mockCreate).toHaveBeenCalledWith(expect.objectContaining({ model: expect.any(String) }), {}) + }) + + it("rejects with AbortError when both an abort signal and a timeout are provided", async () => { + const handler = new RequestyHandler(mockOptions) + const controller = new AbortController() + + let notifyCreateStarted!: () => void + const createStarted = new Promise((resolve) => { + notifyCreateStarted = resolve + }) + let requestSignal: AbortSignal | undefined + mockCreate.mockImplementationOnce(async (_params: unknown, options?: { signal?: AbortSignal }) => { + notifyCreateStarted() + requestSignal = options?.signal + await new Promise((resolve) => { + if (options?.signal?.aborted) { + resolve() + } else { + options?.signal?.addEventListener("abort", () => resolve(), { once: true }) + } + }) + const abortError = new Error("The user aborted a request") + abortError.name = "AbortError" + throw abortError + }) + + const promise = handler.completePrompt("test prompt", { + abortSignal: controller.signal, + timeoutMs: 100_000, + }) + // Defensive: the fast-fail path may reject before the barrier below settles, + // which would otherwise surface as an unhandled rejection. + void promise.catch(() => {}) + // Abort only once create() has actually started (after model lookup). + await withSettleGuard(createStarted) + controller.abort() + + await expect(withSettleGuard(promise)).rejects.toMatchObject({ name: "AbortError" }) + // The SDK received a merged signal (not the caller's signal) plus the timeout. + expect(requestSignal).toBeDefined() + expect(requestSignal).not.toBe(controller.signal) + expect(mockCreate).toHaveBeenCalledWith( + expect.objectContaining({ model: expect.any(String) }), + expect.objectContaining({ + timeout: 100_000, + }), + ) + }) + + it("lets the per-request timeout abort the request while the caller signal stays live", async () => { + // The caller signal is live and never aborted here, so only the timer may end the request. + // The combined-options test above aborts through the controller and cannot see a merged + // signal that drops the timeout whenever a caller signal exists; this one hangs if it does. + const handler = new RequestyHandler(mockOptions) + const controller = new AbortController() + + let notifyCreateStarted!: () => void + const createStarted = new Promise((resolve) => { + notifyCreateStarted = resolve + }) + let requestSignal: AbortSignal | undefined + mockCreate.mockImplementationOnce(async (_params: unknown, options?: { signal?: AbortSignal }) => { + notifyCreateStarted() + requestSignal = options?.signal + await new Promise((resolve) => { + if (options?.signal?.aborted) { + resolve() + } else { + options?.signal?.addEventListener("abort", () => resolve(), { once: true }) + } + }) + const abortError = new Error("The user aborted a request") + abortError.name = "AbortError" + throw abortError + }) + + const promise = handler.completePrompt("test prompt", { abortSignal: controller.signal, timeoutMs: 25 }) + void promise.catch(() => {}) + await withSettleGuard(createStarted) + + await expect(withSettleGuard(promise)).rejects.toMatchObject({ name: "AbortError" }) + expect(controller.signal.aborted).toBe(false) + expect(requestSignal).toBeDefined() + expect(requestSignal).not.toBe(controller.signal) + expect(requestSignal?.aborted).toBe(true) + }) + + it("settles on abort while the SDK is sleeping between retries instead of waiting for the completion", async () => { + // openai@5.23.2 runs "await sleep(timeoutMillis)" inside retryRequest before issuing the + // next attempt, and that sleep does not observe the request signal: awaiting the SDK call + // alone keeps completePrompt pending for the rest of the backoff (a server-provided + // retry-after-ms may stretch it to about a minute). The race against the cancellation + // scope must settle the caller at the abort instead, exactly as the model lookup above + // already does. + const handler = new RequestyHandler(mockOptions) + const controller = new AbortController() + + let notifyCreateStarted!: () => void + const createStarted = new Promise((resolve) => { + notifyCreateStarted = resolve + }) + let releaseBackoff!: () => void + const backoffSleep = new Promise((resolve) => { + releaseBackoff = resolve + }) + let completionSettled = false + mockCreate.mockImplementationOnce(async () => { + notifyCreateStarted() + await backoffSleep + completionSettled = true + return { choices: [{ message: { content: "late" } }] } + }) + + const promise = handler.completePrompt("test prompt", { abortSignal: controller.signal }) + // Defensive: the race may reject before the barrier below settles, which would otherwise + // surface as an unhandled rejection. + void promise.catch(() => {}) + await withSettleGuard(createStarted) + controller.abort() + + await expect(withSettleGuard(promise)).rejects.toMatchObject({ + name: "AbortError", + message: "The Requesty request was aborted", + }) + // The caller settled while the SDK was still inside its retry sleep. + expect(completionSettled).toBe(false) + releaseBackoff() + }) + + it.each([ + { + outcome: "completes", + expectRejection: undefined as string | undefined, + barrier: undefined as Promise | undefined, + start: (controller: AbortController) => { + mockCreate.mockResolvedValueOnce({ choices: [{ message: { content: "response" } }] }) + return new RequestyHandler(mockOptions).completePrompt("test prompt", { + abortSignal: controller.signal, + }) + }, + }, + { + outcome: "fails", + expectRejection: "API Error", + barrier: undefined as Promise | undefined, + start: (controller: AbortController) => { + mockCreate.mockRejectedValueOnce(new Error("API Error")) + return new RequestyHandler(mockOptions).completePrompt("test prompt", { + abortSignal: controller.signal, + }) + }, + }, + { + // Aborting mid-flight is the third exit path through finally: the SDK-shaped mock + // rejects once the signal aborts, so dispose() must detach on that path too. + outcome: "aborts mid-flight", + expectRejection: "The Requesty request was aborted", + start: (controller: AbortController) => { + let notifyStarted!: () => void + const barrier = new Promise((resolve) => { + notifyStarted = resolve + }) + mockCreate.mockImplementationOnce(async (_params: unknown, opts?: { signal?: AbortSignal }) => { + notifyStarted() + await new Promise((resolve) => + opts?.signal?.addEventListener("abort", () => resolve(), { once: true }), + ) + const abortError = new Error("The user aborted a request") + abortError.name = "AbortError" + throw abortError + }) + return { + pending: new RequestyHandler(mockOptions).completePrompt("test prompt", { + abortSignal: controller.signal, + }), + barrier, + } + }, + }, + ] as const)( + "detaches the abort listener from the caller's signal when the completion $outcome", + async (item) => { + // The bridge registers its listener on the caller's long-lived signal, so every exit + // path - resolve, provider error and abort - must detach it: otherwise one listener plus + // one per-request controller is retained for every request on a reused signal. The + // abort case adds nothing the two originals asserted about detaching; it pins that the + // finally dispose also runs when the request ends by cancellation rather than by settling. + const controller = new AbortController() + const addSpy = vitest.spyOn(controller.signal, "addEventListener") + const removeSpy = vitest.spyOn(controller.signal, "removeEventListener") + const started = item.start(controller) + const pending = "pending" in started ? started.pending : started + const barrier = "pending" in started ? started.barrier : item.barrier + void pending.catch(() => {}) + try { + if (barrier) { + await withSettleGuard(barrier) + controller.abort() + } + if (item.expectRejection) { + await expect(pending).rejects.toThrow(item.expectRejection) + } else { + await pending + } + const registered = addSpy.mock.calls.find(([type]) => type === "abort")?.[1] + expect(registered).toBeDefined() + expect(removeSpy).toHaveBeenCalledWith("abort", registered) + } finally { + addSpy.mockRestore() + removeSpy.mockRestore() + } + }, + ) + + it("does not leave the SDK's abort listener on the caller's signal after a completed request", async () => { + // Emulate openai@5.23.2: its request path attaches an abort listener to the signal it is + // handed and leaves it attached once the request completes. Reusing one caller signal + // therefore retains that listener - and the per-request state it closes over - for every + // completed request, unless the provider hands the SDK a signal that dies with the request. + const handler = new RequestyHandler(mockOptions) + const controller = new AbortController() + let leakedListenerFired = false + mockCreate.mockImplementationOnce(async (_params: unknown, options?: { signal?: AbortSignal }) => { + options?.signal?.addEventListener( + "abort", + () => { + leakedListenerFired = true + }, + { once: true }, + ) + return { choices: [{ message: { content: "response" } }] } + }) + + await handler.completePrompt("test prompt", { abortSignal: controller.signal }) + + // Nothing the request created may still be listening on the caller's signal: aborting it + // now must not reach the listener the SDK installed during the request. + controller.abort() + expect(leakedListenerFired).toBe(false) + }) }) describe("model catalog", () => { diff --git a/src/api/providers/requesty.ts b/src/api/providers/requesty.ts index f8d23c0ce6..b69afb18f0 100644 --- a/src/api/providers/requesty.ts +++ b/src/api/providers/requesty.ts @@ -29,6 +29,16 @@ import type { } from "../index" import { toRequestyServiceUrl } from "../../shared/utils/requesty" import { handleOpenAIError } from "./utils/error-handler" +import { + hasUsableTimeout, + toSdkTimeoutMs, + bridgeExternalAbortSignal, + createAbortError, + isRequestAborted, + mergeAbortSignalAndTimeout, + rejectOnAbort, + throwIfAborted, +} from "./utils/abort-signal" import { applyRouterToolPreferences } from "./utils/router-tool-preferences" import { extractReasoningFromDelta } from "./utils/extract-reasoning" @@ -94,7 +104,7 @@ export class RequestyHandler extends BaseProvider implements SingleCompletionHan return getRequestyModels(options.baseUrl, options.apiKey, ...fetchOpts) } - public async fetchModel() { + public async fetchModel(signal?: AbortSignal) { // Pass the API key so the catalog request is authenticated and the cache entry is // key-scoped. Requesty enforces per-key model allowlists, so a keyless (URL-only) // entry would let one key's catalog be served to another key on the same server. @@ -103,6 +113,9 @@ export class RequestyHandler extends BaseProvider implements SingleCompletionHan provider: providerIdentifiers.requesty, apiKey: this.options.requestyApiKey, baseUrl: this.baseURL, + // Per-waiter: modelCache detaches this caller and aborts the shared fetch only + // when the last waiter leaves. + ...(signal && { signal }), }, this, ) @@ -249,24 +262,86 @@ export class RequestyHandler extends BaseProvider implements SingleCompletionHan } } - async completePrompt(prompt: string, _options?: CompletePromptOptions): Promise { - const { id: model, maxTokens: max_tokens, temperature } = await this.fetchModel() + async completePrompt(prompt: string, options?: CompletePromptOptions): Promise { + // Bridge the caller's signal before anything else: the OpenAI SDK attaches its abort + // listener to whichever signal it receives and leaves it attached once the request + // completes, and with no usable timeout `mergeAbortSignalAndTimeout` would hand it the + // caller's own long-lived signal - one retained listener and controller per completed + // request. The bridge gives the SDK a per-request signal and returns the handle that + // detaches it, which the `finally` below must run on every exit path. + // + // Establishing the cancellation scope here also covers model lookup: a pre-aborted call, + // or one aborted while model metadata is loading, must reject promptly instead of waiting + // for the lookup to settle. The configured timeoutMs covers the lookup as well. + const { signal: bridgedCallerSignal, dispose: removeExternalAbortListener } = bridgeExternalAbortSignal( + options?.abortSignal, + ) + const requestAbortSignal = mergeAbortSignalAndTimeout(bridgedCallerSignal, options?.timeoutMs) - const openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = [{ role: "system", content: prompt }] + try { + throwIfAborted(requestAbortSignal) + + let modelData: Awaited> + try { + modelData = requestAbortSignal + ? await rejectOnAbort(this.fetchModel(requestAbortSignal), requestAbortSignal, this.providerName) + : await this.fetchModel() + } catch (error) { + if (isRequestAborted(error, requestAbortSignal)) { + throw createAbortError(this.providerName) + } + throw error + } + const { id: model, maxTokens: max_tokens, temperature } = modelData - const completionParams: RequestyChatCompletionParams = { - model, - max_tokens, - messages: openAiMessages, - temperature: temperature, - } + const openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = [{ role: "system", content: prompt }] - let response: OpenAI.Chat.ChatCompletion - try { - response = await this.client.chat.completions.create(completionParams) - } catch (error) { - throw handleOpenAIError(error, this.providerName) + const completionParams: RequestyChatCompletionParams = { + model, + max_tokens, + messages: openAiMessages, + temperature: temperature, + } + + // The merged abort signal (established before model lookup, above) is forwarded to the + // SDK so both abort and timeout reject with a DOM-standard AbortError in the catch + // below. The client-level timeout remains the default safety net; 0 is never passed + // to the SDK timeout. + const createOptions: OpenAI.RequestOptions = { + ...(requestAbortSignal && { signal: requestAbortSignal }), + ...(hasUsableTimeout(options?.timeoutMs) && { timeout: toSdkTimeoutMs(options.timeoutMs) }), + } + + let response: OpenAI.Chat.ChatCompletion + try { + const pendingCompletion = this.client.chat.completions.create(completionParams, createOptions) + // Race the completion against the cancellation scope as the model lookup above does: + // openai@5.23.2 does not observe the signal inside `retryRequest`, which sleeps for + // the backoff (or a server-provided `retry-after-ms`, up to about a minute) before + // issuing the next attempt. Awaiting the SDK call alone can therefore hold + // completePrompt open long after the caller cancelled; the race settles at once and a + // rejection the SDK wins first is still normalized by the catch below. + response = requestAbortSignal + ? await rejectOnAbort(pendingCompletion, requestAbortSignal, this.providerName) + : await pendingCompletion + } catch (error) { + // Aborted requests are user-initiated: surface them as AbortError (this also covers + // timeouts, which abort the same signal) instead of a completion error. + if (requestAbortSignal?.aborted) { + throw createAbortError(this.providerName) + } + throw handleOpenAIError(error, this.providerName) + } + + if (requestAbortSignal?.aborted) { + // The response resolved after the request was aborted: do not return the late result. + throw createAbortError(this.providerName) + } + return response.choices[0]?.message.content || "" + } finally { + // Detach on every exit path - completion, failure, abort, or a late result. The listener + // sits on the caller's long-lived signal and would outlive this request otherwise. + removeExternalAbortListener() } - return response.choices[0]?.message.content || "" } } diff --git a/src/core/task/__tests__/ask-abort-race.spec.ts b/src/core/task/__tests__/ask-abort-race.spec.ts index 7edc8f6aea..c4ae7b0e19 100644 --- a/src/core/task/__tests__/ask-abort-race.spec.ts +++ b/src/core/task/__tests__/ask-abort-race.spec.ts @@ -48,7 +48,10 @@ describe("Task.ask abort during the auto-approval await", () => { setAbortLatch(task, true) gate.release() - await expect(askPromise).rejects.toThrow(/aborted/) + await expect(askPromise).rejects.toMatchObject({ + name: "Error", + message: `[RooCode#ask] task ${task.taskId}.${task.instanceId} aborted`, + }) expect(task["addToClineMessages"]).not.toHaveBeenCalled() expect(task["lastMessageTs"]).toBeUndefined() }) @@ -66,7 +69,10 @@ describe("Task.ask abort during the auto-approval await", () => { setAbortLatch(task, true) gate.release() - await expect(askPromise).rejects.toThrow(/aborted/) + await expect(askPromise).rejects.toMatchObject({ + name: "Error", + message: `[RooCode#ask] task ${task.taskId}.${task.instanceId} aborted`, + }) expect(task["addToClineMessages"]).not.toHaveBeenCalled() expect(task["lastMessageTs"]).toBeUndefined() }) @@ -87,7 +93,10 @@ describe("Task.ask abort during the auto-approval await", () => { setAbortLatch(task, true) gate.release() - await expect(askPromise).rejects.toThrow(/aborted/) + await expect(askPromise).rejects.toMatchObject({ + name: "Error", + message: `[RooCode#ask] task ${task.taskId}.${task.instanceId} aborted`, + }) expect(task["clineMessages"]).toEqual([original]) expect(task["saveClineMessages"]).not.toHaveBeenCalled() expect(task["updateClineMessage"]).not.toHaveBeenCalled() @@ -106,7 +115,10 @@ describe("Task.ask abort during the auto-approval await", () => { setAbortLatch(task, true) gate.release() - await expect(askPromise).rejects.toThrow(/aborted/) + await expect(askPromise).rejects.toMatchObject({ + name: "Error", + message: `[RooCode#ask] task ${task.taskId}.${task.instanceId} aborted`, + }) expect(task["addToClineMessages"]).not.toHaveBeenCalled() expect(task.messageQueueService.claimNextMessage()?.text).toBe("queued feedback") }) diff --git a/src/core/task/__tests__/ask-queued-message-drain.spec.ts b/src/core/task/__tests__/ask-queued-message-drain.spec.ts index cc58fde82c..16fbb1c93e 100644 --- a/src/core/task/__tests__/ask-queued-message-drain.spec.ts +++ b/src/core/task/__tests__/ask-queued-message-drain.spec.ts @@ -15,7 +15,12 @@ const getQueueTaskTestAccess = (task: Task) => task as unknown as QueueTaskTestA describe("Task.ask queued message drain", () => { function createTask(provider?: { getState: () => Promise> }) { - const task = Object.create(Task.prototype) as Task + // Distinct task and instance ids, so an abort assertion can name this task instead of + // accepting whatever the fixture left unset. + const task = Object.create(Task.prototype, { + taskId: { value: "task-ask-drain", enumerable: true }, + instanceId: { value: "instance-ask-drain", enumerable: true }, + }) as Task setAbortLatch(task, false) ;(task as any).clineMessages = [] ;(task as any).askResponse = undefined @@ -349,7 +354,14 @@ describe("Task.ask queued message drain", () => { setAbortLatch(task, true) finishAddingAsk() - await expect(ask).rejects.toThrow("aborted") + // The abort must name this task and this instance: a regex that accepts any id would also + // pass for another task's abort or a different prefix. + await expect(ask).rejects.toMatchObject({ + message: `[ZooCode#ask] task ${task.taskId}.${task.instanceId} aborted`, + }) + await expect(ask).rejects.toMatchObject({ + message: "[ZooCode#ask] task task-ask-drain.instance-ask-drain aborted", + }) expect(task.messageQueueService.messages).toHaveLength(1) expect(task.messageQueueService.claimNextMessage()?.text).toBe("Persist me later") }) diff --git a/src/eslint.config.mjs b/src/eslint.config.mjs index bdc6806a5d..d850e6cf07 100644 --- a/src/eslint.config.mjs +++ b/src/eslint.config.mjs @@ -1,5 +1,6 @@ import { config } from "@roo-code/config-eslint/base" import { + abortSubstringSelector, apiProviderHygieneConfig, apiProviderTestSelectors, apiSpecSleepConfig, @@ -140,7 +141,21 @@ export default [ "**/services/__tests__/workflow-self-listen.test.ts", ], rules: { - "no-restricted-syntax": ["error", ...testPathTraversalSelectors], + "no-restricted-syntax": ["error", ...testPathTraversalSelectors, abortSubstringSelector], + }, + }, + { + // The four workflow tests ignored above are exempt from the path-traversal selectors + // because they read root-level config. That exception was never about abort + // assertions, so re-apply the abort rule for exactly those files. + files: [ + "**/services/__tests__/pr-review-state-workflow.test.ts", + "**/services/__tests__/stale-pr-clock-workflow.test.ts", + "**/services/__tests__/fork-review-followup-workflow.test.ts", + "**/services/__tests__/workflow-self-listen.test.ts", + ], + rules: { + "no-restricted-syntax": ["error", abortSubstringSelector], }, }, { @@ -161,7 +176,12 @@ export default [ "integrations/terminal/**/__tests__/**/*.ts", ], rules: { - "no-restricted-syntax": ["error", ...testPathTraversalSelectors, testResetHelperSelector], + "no-restricted-syntax": [ + "error", + ...testPathTraversalSelectors, + abortSubstringSelector, + testResetHelperSelector, + ], }, }, {