Skip to content

Commit f036c90

Browse files
committed
feat: add abort signal core plumbing
1 parent 74583b5 commit f036c90

3 files changed

Lines changed: 100 additions & 0 deletions

File tree

src/api/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,12 @@ export interface ApiHandlerCreateMessageMetadata {
9090
* Only applies to providers that support function calling restrictions (e.g., Gemini).
9191
*/
9292
allowedFunctionNames?: string[]
93+
/**
94+
* Abort signal for cancelling the HTTP request mid-stream.
95+
* Passed through to AI SDK's streamText() so the underlying HTTP request is aborted
96+
* when the user clicks stop, preventing wasted API tokens/compute on the provider side.
97+
*/
98+
abortSignal?: AbortSignal
9399
}
94100

95101
export interface ApiHandler {

src/core/task/Task.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4161,6 +4161,7 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
41614161
// Create an AbortController to allow cancelling the request mid-stream
41624162
this.currentRequestAbortController = new AbortController()
41634163
const abortSignal = this.currentRequestAbortController.signal
4164+
metadata.abortSignal = abortSignal
41644165
// Reset the flag after using it
41654166
this.skipPrevResponseIdOnce = false
41664167

src/core/task/__tests__/Task.spec.ts

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1795,6 +1795,99 @@ describe("Cline", () => {
17951795
// Verify cancelCurrentRequest was called
17961796
expect(cancelSpy).toHaveBeenCalled()
17971797
})
1798+
describe("abortSignal", () => {
1799+
it("should pass AbortController signal to createMessage metadata", async () => {
1800+
const task = new Task({
1801+
provider: mockProvider,
1802+
apiConfiguration: mockApiConfig,
1803+
task: "test task",
1804+
startTask: false,
1805+
})
1806+
1807+
// Mock the API stream response
1808+
const mockStream = {
1809+
async *[Symbol.asyncIterator]() {
1810+
yield { type: "text", text: "response" }
1811+
},
1812+
async next() {
1813+
return { done: true, value: { type: "text", text: "response" } }
1814+
},
1815+
async return() {
1816+
return { done: true, value: undefined }
1817+
},
1818+
async throw(e: any) {
1819+
throw e
1820+
},
1821+
[Symbol.asyncDispose]: async () => {},
1822+
} as AsyncGenerator<ApiStreamChunk>
1823+
1824+
const createMessageSpy = vi.spyOn(task.api, "createMessage").mockReturnValue(mockStream)
1825+
1826+
task.apiConversationHistory = [
1827+
{
1828+
role: "user" as const,
1829+
content: [{ type: "text" as const, text: "test message" }],
1830+
ts: Date.now(),
1831+
},
1832+
] as any
1833+
1834+
const iterator = task.attemptApiRequest(0)
1835+
await iterator.next()
1836+
1837+
// Verify createMessage was called with metadata containing abortSignal
1838+
expect(createMessageSpy).toHaveBeenCalled()
1839+
const [, , metadata] = createMessageSpy.mock.calls[0]!
1840+
1841+
expect(metadata).toBeDefined()
1842+
expect(metadata!.abortSignal).toBeInstanceOf(AbortSignal)
1843+
})
1844+
1845+
it("should use the same AbortController signal as currentRequestAbortController", async () => {
1846+
const task = new Task({
1847+
provider: mockProvider,
1848+
apiConfiguration: mockApiConfig,
1849+
task: "test task",
1850+
startTask: false,
1851+
})
1852+
1853+
// Mock the API stream response
1854+
const mockStream = {
1855+
async *[Symbol.asyncIterator]() {
1856+
yield { type: "text", text: "response" }
1857+
},
1858+
async next() {
1859+
return { done: true, value: { type: "text", text: "response" } }
1860+
},
1861+
async return() {
1862+
return { done: true, value: undefined }
1863+
},
1864+
async throw(e: any) {
1865+
throw e
1866+
},
1867+
[Symbol.asyncDispose]: async () => {},
1868+
} as AsyncGenerator<ApiStreamChunk>
1869+
1870+
const createMessageSpy = vi.spyOn(task.api, "createMessage").mockReturnValue(mockStream)
1871+
1872+
task.apiConversationHistory = [
1873+
{
1874+
role: "user" as const,
1875+
content: [{ type: "text" as const, text: "test message" }],
1876+
ts: Date.now(),
1877+
},
1878+
] as any
1879+
1880+
const iterator = task.attemptApiRequest(0)
1881+
await iterator.next()
1882+
1883+
// Get the signal from metadata
1884+
const [, , metadata] = createMessageSpy.mock.calls[0]!
1885+
const metadataSignal = metadata!.abortSignal
1886+
1887+
// The signal in metadata should be the same as the one from currentRequestAbortController
1888+
expect(metadataSignal).toBe(task.currentRequestAbortController!.signal)
1889+
})
1890+
})
17981891
})
17991892
})
18001893

0 commit comments

Comments
 (0)