Skip to content

Commit e48946a

Browse files
committed
merge: sync main and adapt vector-store tests to readonly config
2 parents a0f2e81 + d351a15 commit e48946a

55 files changed

Lines changed: 2315 additions & 334 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎src/core/task/Task.ts‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3236,6 +3236,7 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
32363236
this.didRejectTool = false
32373237
this.didAlreadyUseTool = false
32383238
this.assistantMessageSavedToHistory = false
3239+
this.didFinishAbortingStream = false
32393240
this.resetAssistantMessagePersistence()
32403241
// Reset tool failure flag for each new assistant turn - this ensures that tool failures
32413242
// only prevent attempt_completion within the same assistant message, not across turns
@@ -3715,8 +3716,8 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
37153716
await abortStream(cancelReason, streamingFailedMessage)
37163717

37173718
if (this.abort) {
3718-
// User cancelled - abort the entire task
3719-
this.abortReason = cancelReason
3719+
// ??= keeps the first reason; a cancel can land during abortStream after cancelReason was already computed.
3720+
this.abortReason ??= "user_cancelled"
37203721
await this.abortTask()
37213722
} else if (error instanceof OutputTokenLimitError) {
37223723
// Truncation repeats on an identical request, so never auto-retry it
@@ -3751,8 +3752,7 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
37513752
console.log(
37523753
`[Task#${this.taskId}.${this.instanceId}] Task aborted during mid-stream retry backoff`,
37533754
)
3754-
// Abort the entire task
3755-
this.abortReason = "user_cancelled"
3755+
this.abortReason ??= "user_cancelled"
37563756
await this.abortTask()
37573757
break
37583758
}
Lines changed: 216 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,216 @@
1+
// npx vitest run core/task/__tests__/Task.abort-reason-race.spec.ts
2+
3+
import * as os from "os"
4+
import * as path from "path"
5+
6+
import type { GlobalState, ProviderSettings } from "@roo-code/types"
7+
import { providerIdentifiers } from "@roo-code/types/provider-identifiers"
8+
import { findLast } from "../../../shared/array"
9+
import { TelemetryService } from "@roo-code/telemetry"
10+
11+
import { Task } from "../Task"
12+
import { ClineProvider } from "../../webview/ClineProvider"
13+
14+
const { mockSaveTaskMessages, mockSaveApiMessages } = vi.hoisted(() => ({
15+
mockSaveTaskMessages: vi.fn().mockResolvedValue(undefined),
16+
mockSaveApiMessages: vi.fn().mockResolvedValue(undefined),
17+
}))
18+
19+
// vscode is globally aliased to __mocks__/vscode.js by vitest.config.ts.
20+
vi.mock("delay", () => ({ __esModule: true, default: vi.fn().mockResolvedValue(undefined) }))
21+
vi.mock("execa", () => ({ execa: vi.fn() }))
22+
vi.mock("p-wait-for", () => ({ default: vi.fn().mockResolvedValue(undefined) }))
23+
24+
vi.mock("../../task-persistence", async (importOriginal) => {
25+
const mod = await importOriginal<typeof import("../../task-persistence")>()
26+
return {
27+
...mod,
28+
saveApiMessages: mockSaveApiMessages,
29+
saveTaskMessages: mockSaveTaskMessages,
30+
TaskHistoryStore: vi.fn().mockImplementation(function () {
31+
return {
32+
initialize: vi.fn().mockResolvedValue(undefined),
33+
dispose: vi.fn(),
34+
get: vi.fn(),
35+
getAll: vi.fn().mockReturnValue([]),
36+
upsert: vi.fn().mockResolvedValue([]),
37+
delete: vi.fn().mockResolvedValue(undefined),
38+
deleteMany: vi.fn().mockResolvedValue(undefined),
39+
reconcile: vi.fn().mockResolvedValue(undefined),
40+
initialized: Promise.resolve(),
41+
}
42+
}),
43+
}
44+
})
45+
46+
vi.mock("../../mentions", () => ({
47+
parseMentions: vi
48+
.fn()
49+
.mockImplementation((text) =>
50+
Promise.resolve({ text: `processed: ${text}`, mode: undefined, contentBlocks: [] }),
51+
),
52+
openMention: vi.fn(),
53+
getLatestTerminalOutput: vi.fn(),
54+
}))
55+
56+
vi.mock("../../mentions/processUserContentMentions", () => ({
57+
processUserContentMentions: vi.fn().mockImplementation(async ({ userContent }: { userContent: unknown[] }) => ({
58+
content: userContent,
59+
mode: undefined,
60+
})),
61+
}))
62+
63+
vi.mock("../../../integrations/misc/extract-text", () => ({
64+
extractTextFromFile: vi.fn().mockResolvedValue(""),
65+
}))
66+
67+
vi.mock("../../environment/getEnvironmentDetails", () => ({
68+
getEnvironmentDetails: vi.fn().mockResolvedValue(""),
69+
}))
70+
71+
vi.mock("../../ignore/RooIgnoreController")
72+
73+
vi.mock("../../../utils/storage", () => ({
74+
getTaskDirectoryPath: vi
75+
.fn()
76+
.mockImplementation((base: string, id: string) => Promise.resolve(`${base}/tasks/${id}`)),
77+
getSettingsDirectoryPath: vi.fn().mockImplementation((base: string) => Promise.resolve(`${base}/settings`)),
78+
}))
79+
80+
vi.mock("../../../utils/fs", () => ({
81+
fileExistsAtPath: vi.fn().mockReturnValue(false),
82+
}))
83+
84+
vi.mock("../../../i18n", () => ({
85+
t: (key: string) => key,
86+
}))
87+
88+
function makeMockProvider() {
89+
return {
90+
log: vi.fn(),
91+
taskHistoryStore: { get: () => undefined },
92+
updateTaskHistory: vi.fn().mockResolvedValue([]),
93+
getState: vi.fn().mockResolvedValue({}),
94+
getSkillsManager: vi.fn().mockReturnValue(undefined),
95+
postStateToWebviewWithoutTaskHistory: vi.fn().mockResolvedValue(undefined),
96+
flushPostStateToWebviewThrottled: vi.fn().mockResolvedValue(undefined),
97+
postMessageToWebview: vi.fn().mockResolvedValue(undefined),
98+
postStateToWebview: vi.fn().mockResolvedValue(undefined),
99+
postStateToWebviewThrottled: vi.fn().mockResolvedValue(undefined),
100+
context: {
101+
globalStorageUri: { fsPath: path.join(os.tmpdir(), "test-storage-abort-race") },
102+
globalState: {
103+
get: vi.fn().mockImplementation((_key: keyof GlobalState) => undefined),
104+
update: vi.fn().mockResolvedValue(undefined),
105+
keys: vi.fn().mockReturnValue([]),
106+
},
107+
workspaceState: {
108+
get: vi.fn().mockImplementation(() => undefined),
109+
update: vi.fn().mockResolvedValue(undefined),
110+
keys: vi.fn().mockReturnValue([]),
111+
},
112+
secrets: {
113+
get: vi.fn().mockResolvedValue(undefined),
114+
store: vi.fn().mockResolvedValue(undefined),
115+
delete: vi.fn().mockResolvedValue(undefined),
116+
},
117+
extensionUri: { fsPath: "/mock/extension" },
118+
extension: { packageJSON: { version: "1.0.0" } },
119+
},
120+
}
121+
}
122+
123+
describe("Task abort-reason race", () => {
124+
let mockApiConfig: ProviderSettings
125+
126+
beforeEach(() => {
127+
vi.clearAllMocks()
128+
mockSaveTaskMessages.mockResolvedValue(undefined)
129+
130+
if (!TelemetryService.hasInstance()) {
131+
TelemetryService.createInstance([])
132+
}
133+
134+
mockApiConfig = {
135+
apiProvider: providerIdentifiers.anthropic,
136+
apiModelId: "claude-3-5-sonnet-20241022",
137+
apiKey: "test-api-key",
138+
}
139+
})
140+
141+
it("keeps user_cancelled when a cancel lands during abortStream cleanup", async () => {
142+
const mockProvider = makeMockProvider()
143+
144+
// Double cast: plain object satisfies only the methods called by this code path.
145+
const task = new Task({
146+
provider: mockProvider as unknown as ClineProvider,
147+
apiConfiguration: mockApiConfig,
148+
task: "test task",
149+
startTask: false,
150+
})
151+
152+
vi.spyOn(task.diffViewProvider, "reset").mockResolvedValue(undefined)
153+
vi.spyOn(task, "dispose").mockResolvedValue(undefined)
154+
const abortTaskSpy = vi.spyOn(task, "abortTask")
155+
156+
// eslint-disable-next-line require-yield -- intentional error-only async source
157+
task["attemptApiRequest"] = async function* () {
158+
throw new Error("mid-stream API failure")
159+
}
160+
161+
// abortStream calls updateApiReqMsg("streaming_failed") synchronously before saving,
162+
// so the api_req_started row has cancelReason="streaming_failed" when we are inside it.
163+
mockSaveTaskMessages.mockImplementation(async () => {
164+
const apiReqMsg = findLast(task.clineMessages, (m) => m.say === "api_req_started")
165+
const parsed = apiReqMsg?.text ? (JSON.parse(apiReqMsg.text) as { cancelReason?: string }) : {}
166+
if (parsed.cancelReason === "streaming_failed" && !task.didFinishAbortingStream) {
167+
// cancelTask sets abortReason before abort=true.
168+
task["abortReason"] = "user_cancelled"
169+
task["abort"] = true
170+
}
171+
})
172+
173+
// The outer catch in recursivelyMakeClineRequests swallows the abort throw and returns true.
174+
const result = await task.recursivelyMakeClineRequests([{ type: "text", text: "help me" }])
175+
expect(result).toBe(true)
176+
expect(task.didFinishAbortingStream).toBe(true)
177+
expect(abortTaskSpy).toHaveBeenCalledOnce()
178+
expect(task.abortReason).toBe("user_cancelled")
179+
})
180+
181+
it("keeps pre-existing abortReason when cancel lands during retry backoff", async () => {
182+
const mockProvider = makeMockProvider()
183+
// autoApprovalEnabled causes the else branch to call backoffAndAnnounce.
184+
mockProvider.getState = vi.fn().mockResolvedValue({ autoApprovalEnabled: true })
185+
186+
// Double cast: plain object satisfies only the methods called by this code path.
187+
const task = new Task({
188+
provider: mockProvider as unknown as ClineProvider,
189+
apiConfiguration: mockApiConfig,
190+
task: "test task",
191+
startTask: false,
192+
})
193+
194+
vi.spyOn(task.diffViewProvider, "reset").mockResolvedValue(undefined)
195+
vi.spyOn(task, "dispose").mockResolvedValue(undefined)
196+
197+
// eslint-disable-next-line require-yield -- intentional error-only async source
198+
task["attemptApiRequest"] = async function* () {
199+
throw new Error("mid-stream failure")
200+
}
201+
202+
// Simulate cancel landing during backoff: cancelTask sets abortReason before abort=true.
203+
// Use a distinct seed value so ??= (keeps it) is distinguishable from = (overwrites).
204+
task["backoffAndAnnounce"] = async () => {
205+
task["abortReason"] = "streaming_failed"
206+
task["abort"] = true
207+
}
208+
209+
const abortTaskSpy = vi.spyOn(task, "abortTask")
210+
// break after backoff exits the while loop; the outer try returns false.
211+
const result = await task.recursivelyMakeClineRequests([{ type: "text", text: "help me" }])
212+
expect(result).toBe(false)
213+
expect(abortTaskSpy).toHaveBeenCalledOnce()
214+
expect(task.abortReason).toBe("streaming_failed")
215+
})
216+
})

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

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@ vi.mock("delay", () => ({
8686
}))
8787

8888
import delay from "delay"
89+
import pWaitFor from "p-wait-for"
8990

9091
vi.mock("uuid", async (importOriginal) => {
9192
const actual = await importOriginal<typeof import("uuid")>()
@@ -713,6 +714,98 @@ describe("Cline", () => {
713714
])
714715
})
715716

717+
it("clears didFinishAbortingStream on retry so cancelTask waits for the new stream (#1801)", async () => {
718+
const task = new Task({
719+
provider: mockProvider,
720+
apiConfiguration: mockApiConfig,
721+
task: "abort flag reset test",
722+
startTask: false,
723+
})
724+
725+
vi.spyOn(task.diffViewProvider, "reset").mockResolvedValue(undefined)
726+
vi.spyOn(getTaskTestAccess(task), "safeEnsureModelFetched").mockResolvedValue(stubModelInfo)
727+
vi.spyOn(getTaskTestAccess(task), "presentAssistantMessageSafe").mockImplementation(() => {})
728+
729+
const failingStream = async function* (): AsyncGenerator<ApiStreamChunk> {
730+
yield { type: "text", text: "partial" }
731+
throw new Error("simulated mid-stream failure")
732+
}
733+
734+
let releaseRetryStream: (() => void) | undefined
735+
let markRetryStreamPaused: (() => void) | undefined
736+
const retryStreamRelease = new Promise<void>((resolve) => {
737+
releaseRetryStream = resolve
738+
})
739+
const retryStreamPaused = new Promise<void>((resolve) => {
740+
markRetryStreamPaused = resolve
741+
})
742+
const pausedRetryStream = async function* (): AsyncGenerator<ApiStreamChunk> {
743+
yield { type: "text", text: "retry" }
744+
markRetryStreamPaused?.()
745+
await retryStreamRelease
746+
}
747+
748+
const attemptApiRequestSpy = vi
749+
.spyOn(task, "attemptApiRequest")
750+
.mockImplementationOnce(() => failingStream())
751+
.mockImplementationOnce(() => pausedRetryStream())
752+
.mockImplementation(() => {
753+
throw new Error("stop after retry response")
754+
})
755+
756+
const { default: realPWaitFor } = await vi.importActual<typeof import("p-wait-for")>("p-wait-for")
757+
vi.mocked(pWaitFor).mockImplementation(realPWaitFor)
758+
let request: Promise<boolean> | undefined
759+
let cancel: Promise<void> | undefined
760+
try {
761+
await mockProvider.addClineToStack(task)
762+
request = task.recursivelyMakeClineRequests([{ type: "text", text: "abort flag reset test" }])
763+
await Promise.race([
764+
retryStreamPaused,
765+
request.then(() => {
766+
throw new Error("request settled before the retry stream paused")
767+
}),
768+
])
769+
770+
const firstApiReq = task.clineMessages.find((message) => message.say === "api_req_started")
771+
expect(JSON.parse(firstApiReq?.text ?? "{}")).toMatchObject({ cancelReason: "streaming_failed" })
772+
expect(attemptApiRequestSpy).toHaveBeenCalledTimes(2)
773+
774+
let markCancelWaiting: (() => void) | undefined
775+
const cancelWaiting = new Promise<void>((resolve) => {
776+
markCancelWaiting = resolve
777+
})
778+
vi.mocked(pWaitFor).mockImplementation((condition, options) =>
779+
realPWaitFor(async () => {
780+
const done = await condition()
781+
if (!done) markCancelWaiting?.()
782+
return done
783+
}, options),
784+
)
785+
786+
vi.spyOn(mockProvider, "createTaskWithHistoryItem").mockImplementation((historyItem) =>
787+
ClineProvider.prototype.createTaskWithHistoryItem.call(mockProvider, historyItem, {
788+
startTask: false,
789+
}),
790+
)
791+
792+
let cancelSettled = false
793+
cancel = mockProvider.cancelTask().then(() => {
794+
cancelSettled = true
795+
})
796+
await Promise.race([cancelWaiting, cancel])
797+
expect(cancelSettled, "cancelTask must wait while the retry stream is live").toBe(false)
798+
799+
releaseRetryStream?.()
800+
await cancel
801+
await expect(request).resolves.toBe(true)
802+
} finally {
803+
releaseRetryStream?.()
804+
await Promise.allSettled([request, cancel])
805+
vi.mocked(pWaitFor).mockImplementation(async () => {})
806+
}
807+
})
808+
716809
it("finalizes MCP tool call using the request-scoped parser state", async () => {
717810
const task = new Task({
718811
provider: mockProvider,

‎src/core/webview/ClineProvider.ts‎

Lines changed: 1 addition & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -406,35 +406,7 @@ export class ClineProvider
406406
}
407407
this.emit(RooCodeEventName.TaskCompleted, taskId, tokenUsage, toolUsage)
408408
}
409-
const onTaskAborted = async () => {
410-
this.emit(RooCodeEventName.TaskAborted, instance.taskId)
411-
412-
try {
413-
// Only rehydrate on genuine streaming failures.
414-
// User-initiated cancels are handled by cancelTask().
415-
if (instance.abortReason === "streaming_failed") {
416-
// Defensive safeguard: if another path already replaced this instance, skip
417-
const current = this.getCurrentTask()
418-
if (current && current.instanceId !== instance.instanceId) {
419-
this.log(
420-
`[onTaskAborted] Skipping rehydrate: current instance ${current.instanceId} != aborted ${instance.instanceId}`,
421-
)
422-
return
423-
}
424-
425-
const { historyItem } = await this.getTaskWithId(instance.taskId)
426-
const rootTask = instance.rootTask
427-
const parentTask = instance.parentTask
428-
await this.createTaskWithHistoryItem({ ...historyItem, rootTask, parentTask })
429-
}
430-
} catch (error) {
431-
this.log(
432-
`[onTaskAborted] Failed to rehydrate after streaming failure: ${
433-
error instanceof Error ? error.message : String(error)
434-
}`,
435-
)
436-
}
437-
}
409+
const onTaskAborted = () => this.emit(RooCodeEventName.TaskAborted, instance.taskId)
438410
const onTaskFocused = () => this.emit(RooCodeEventName.TaskFocused, instance.taskId)
439411
const onTaskUnfocused = () => this.emit(RooCodeEventName.TaskUnfocused, instance.taskId)
440412
const onTaskActive = (taskId: string) => this.emit(RooCodeEventName.TaskActive, taskId)

0 commit comments

Comments
 (0)