Skip to content

Commit 60a0dcf

Browse files
committed
feat: add abort singal core plumbing
1 parent 74583b5 commit 60a0dcf

3 files changed

Lines changed: 146 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: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1795,6 +1795,145 @@ 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 required methods for attemptApiRequest to work without hanging
1808+
vi.spyOn(task as any, "getSystemPrompt").mockResolvedValue("mock system prompt")
1809+
1810+
vi.spyOn(task.api, "getModel").mockReturnValue({
1811+
id: mockApiConfig.apiModelId!,
1812+
info: {
1813+
supportsImages: false,
1814+
supportsPromptCache: true,
1815+
contextWindow: 200000,
1816+
maxTokens: 4096,
1817+
inputPrice: 0.3,
1818+
outputPrice: 1.5,
1819+
} as ModelInfo,
1820+
})
1821+
1822+
const providerState = await mockProvider.getState()
1823+
vi.spyOn(mockProvider, "getState").mockResolvedValue({
1824+
...providerState,
1825+
apiConfiguration: mockApiConfig,
1826+
autoApprovalEnabled: true,
1827+
requestDelaySeconds: 0,
1828+
})
1829+
1830+
// Mock the API stream response
1831+
const mockStream = {
1832+
async *[Symbol.asyncIterator]() {
1833+
yield { type: "text", text: "response" }
1834+
},
1835+
async next() {
1836+
return { done: true, value: { type: "text", text: "response" } }
1837+
},
1838+
async return() {
1839+
return { done: true, value: undefined }
1840+
},
1841+
async throw(e: any) {
1842+
throw e
1843+
},
1844+
[Symbol.asyncDispose]: async () => {},
1845+
} as AsyncGenerator<ApiStreamChunk>
1846+
1847+
const createMessageSpy = vi.spyOn(task.api, "createMessage").mockReturnValue(mockStream)
1848+
1849+
task.apiConversationHistory = [
1850+
{
1851+
role: "user" as const,
1852+
content: [{ type: "text" as const, text: "test message" }],
1853+
ts: Date.now(),
1854+
},
1855+
] as any
1856+
1857+
const iterator = task.attemptApiRequest(0)
1858+
await iterator.next()
1859+
1860+
// Verify createMessage was called with metadata containing abortSignal
1861+
expect(createMessageSpy).toHaveBeenCalled()
1862+
const [, , metadata] = createMessageSpy.mock.calls[0]!
1863+
1864+
expect(metadata).toBeDefined()
1865+
expect(metadata!.abortSignal).toBeInstanceOf(AbortSignal)
1866+
})
1867+
1868+
it("should use the same AbortController signal as currentRequestAbortController", async () => {
1869+
const task = new Task({
1870+
provider: mockProvider,
1871+
apiConfiguration: mockApiConfig,
1872+
task: "test task",
1873+
startTask: false,
1874+
})
1875+
1876+
// Mock required methods for attemptApiRequest to work without hanging
1877+
vi.spyOn(task as any, "getSystemPrompt").mockResolvedValue("mock system prompt")
1878+
1879+
vi.spyOn(task.api, "getModel").mockReturnValue({
1880+
id: mockApiConfig.apiModelId!,
1881+
info: {
1882+
supportsImages: false,
1883+
supportsPromptCache: true,
1884+
contextWindow: 200000,
1885+
maxTokens: 4096,
1886+
inputPrice: 0.3,
1887+
outputPrice: 1.5,
1888+
} as ModelInfo,
1889+
})
1890+
1891+
const providerState = await mockProvider.getState()
1892+
vi.spyOn(mockProvider, "getState").mockResolvedValue({
1893+
...providerState,
1894+
apiConfiguration: mockApiConfig,
1895+
autoApprovalEnabled: true,
1896+
requestDelaySeconds: 0,
1897+
})
1898+
1899+
// Mock the API stream response
1900+
const mockStream = {
1901+
async *[Symbol.asyncIterator]() {
1902+
yield { type: "text", text: "response" }
1903+
},
1904+
async next() {
1905+
return { done: true, value: { type: "text", text: "response" } }
1906+
},
1907+
async return() {
1908+
return { done: true, value: undefined }
1909+
},
1910+
async throw(e: any) {
1911+
throw e
1912+
},
1913+
[Symbol.asyncDispose]: async () => {},
1914+
} as AsyncGenerator<ApiStreamChunk>
1915+
1916+
const createMessageSpy = vi.spyOn(task.api, "createMessage").mockReturnValue(mockStream)
1917+
1918+
task.apiConversationHistory = [
1919+
{
1920+
role: "user" as const,
1921+
content: [{ type: "text" as const, text: "test message" }],
1922+
ts: Date.now(),
1923+
},
1924+
] as any
1925+
1926+
const iterator = task.attemptApiRequest(0)
1927+
await iterator.next()
1928+
1929+
// Get the signal from metadata
1930+
const [, , metadata] = createMessageSpy.mock.calls[0]!
1931+
const metadataSignal = metadata!.abortSignal
1932+
1933+
// The signal in metadata should be the same as the one from currentRequestAbortController
1934+
expect(metadataSignal).toBe(task.currentRequestAbortController!.signal)
1935+
})
1936+
})
17981937
})
17991938
})
18001939

0 commit comments

Comments
 (0)