-
Notifications
You must be signed in to change notification settings - Fork 301
fix(task): keep the first abort reason (RSK-19) #1811
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
2 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,216 @@ | ||
| // npx vitest run core/task/__tests__/Task.abort-reason-race.spec.ts | ||
|
|
||
| import * as os from "os" | ||
| import * as path from "path" | ||
|
|
||
| import type { GlobalState, ProviderSettings } from "@roo-code/types" | ||
| import { providerIdentifiers } from "@roo-code/types/provider-identifiers" | ||
| import { findLast } from "../../../shared/array" | ||
| import { TelemetryService } from "@roo-code/telemetry" | ||
|
|
||
| import { Task } from "../Task" | ||
| import { ClineProvider } from "../../webview/ClineProvider" | ||
|
|
||
| const { mockSaveTaskMessages, mockSaveApiMessages } = vi.hoisted(() => ({ | ||
| mockSaveTaskMessages: vi.fn().mockResolvedValue(undefined), | ||
| mockSaveApiMessages: vi.fn().mockResolvedValue(undefined), | ||
| })) | ||
|
|
||
| // vscode is globally aliased to __mocks__/vscode.js by vitest.config.ts. | ||
| vi.mock("delay", () => ({ __esModule: true, default: vi.fn().mockResolvedValue(undefined) })) | ||
| vi.mock("execa", () => ({ execa: vi.fn() })) | ||
| vi.mock("p-wait-for", () => ({ default: vi.fn().mockResolvedValue(undefined) })) | ||
|
|
||
| vi.mock("../../task-persistence", async (importOriginal) => { | ||
| const mod = await importOriginal<typeof import("../../task-persistence")>() | ||
| return { | ||
| ...mod, | ||
| saveApiMessages: mockSaveApiMessages, | ||
| saveTaskMessages: mockSaveTaskMessages, | ||
| TaskHistoryStore: vi.fn().mockImplementation(function () { | ||
| return { | ||
| initialize: vi.fn().mockResolvedValue(undefined), | ||
| dispose: vi.fn(), | ||
| get: vi.fn(), | ||
| getAll: vi.fn().mockReturnValue([]), | ||
| upsert: vi.fn().mockResolvedValue([]), | ||
| delete: vi.fn().mockResolvedValue(undefined), | ||
| deleteMany: vi.fn().mockResolvedValue(undefined), | ||
| reconcile: vi.fn().mockResolvedValue(undefined), | ||
| initialized: Promise.resolve(), | ||
| } | ||
| }), | ||
| } | ||
| }) | ||
|
|
||
| vi.mock("../../mentions", () => ({ | ||
| parseMentions: vi | ||
| .fn() | ||
| .mockImplementation((text) => | ||
| Promise.resolve({ text: `processed: ${text}`, mode: undefined, contentBlocks: [] }), | ||
| ), | ||
| openMention: vi.fn(), | ||
| getLatestTerminalOutput: vi.fn(), | ||
| })) | ||
|
|
||
| vi.mock("../../mentions/processUserContentMentions", () => ({ | ||
| processUserContentMentions: vi.fn().mockImplementation(async ({ userContent }: { userContent: unknown[] }) => ({ | ||
| content: userContent, | ||
| mode: undefined, | ||
| })), | ||
| })) | ||
|
|
||
| vi.mock("../../../integrations/misc/extract-text", () => ({ | ||
| extractTextFromFile: vi.fn().mockResolvedValue(""), | ||
| })) | ||
|
|
||
| vi.mock("../../environment/getEnvironmentDetails", () => ({ | ||
| getEnvironmentDetails: vi.fn().mockResolvedValue(""), | ||
| })) | ||
|
|
||
| vi.mock("../../ignore/RooIgnoreController") | ||
|
|
||
| vi.mock("../../../utils/storage", () => ({ | ||
| getTaskDirectoryPath: vi | ||
| .fn() | ||
| .mockImplementation((base: string, id: string) => Promise.resolve(`${base}/tasks/${id}`)), | ||
| getSettingsDirectoryPath: vi.fn().mockImplementation((base: string) => Promise.resolve(`${base}/settings`)), | ||
| })) | ||
|
|
||
| vi.mock("../../../utils/fs", () => ({ | ||
| fileExistsAtPath: vi.fn().mockReturnValue(false), | ||
| })) | ||
|
|
||
| vi.mock("../../../i18n", () => ({ | ||
| t: (key: string) => key, | ||
| })) | ||
|
|
||
| function makeMockProvider() { | ||
| return { | ||
| log: vi.fn(), | ||
| taskHistoryStore: { get: () => undefined }, | ||
| updateTaskHistory: vi.fn().mockResolvedValue([]), | ||
| getState: vi.fn().mockResolvedValue({}), | ||
| getSkillsManager: vi.fn().mockReturnValue(undefined), | ||
| postStateToWebviewWithoutTaskHistory: vi.fn().mockResolvedValue(undefined), | ||
| flushPostStateToWebviewThrottled: vi.fn().mockResolvedValue(undefined), | ||
| postMessageToWebview: vi.fn().mockResolvedValue(undefined), | ||
| postStateToWebview: vi.fn().mockResolvedValue(undefined), | ||
| postStateToWebviewThrottled: vi.fn().mockResolvedValue(undefined), | ||
| context: { | ||
| globalStorageUri: { fsPath: path.join(os.tmpdir(), "test-storage-abort-race") }, | ||
| globalState: { | ||
| get: vi.fn().mockImplementation((_key: keyof GlobalState) => undefined), | ||
| update: vi.fn().mockResolvedValue(undefined), | ||
| keys: vi.fn().mockReturnValue([]), | ||
| }, | ||
| workspaceState: { | ||
| get: vi.fn().mockImplementation(() => undefined), | ||
| update: vi.fn().mockResolvedValue(undefined), | ||
| keys: vi.fn().mockReturnValue([]), | ||
| }, | ||
| secrets: { | ||
| get: vi.fn().mockResolvedValue(undefined), | ||
| store: vi.fn().mockResolvedValue(undefined), | ||
| delete: vi.fn().mockResolvedValue(undefined), | ||
| }, | ||
| extensionUri: { fsPath: "/mock/extension" }, | ||
| extension: { packageJSON: { version: "1.0.0" } }, | ||
| }, | ||
| } | ||
| } | ||
|
|
||
| describe("Task abort-reason race", () => { | ||
| let mockApiConfig: ProviderSettings | ||
|
|
||
| beforeEach(() => { | ||
| vi.clearAllMocks() | ||
| mockSaveTaskMessages.mockResolvedValue(undefined) | ||
|
|
||
| if (!TelemetryService.hasInstance()) { | ||
| TelemetryService.createInstance([]) | ||
| } | ||
|
|
||
| mockApiConfig = { | ||
| apiProvider: providerIdentifiers.anthropic, | ||
| apiModelId: "claude-3-5-sonnet-20241022", | ||
| apiKey: "test-api-key", | ||
| } | ||
| }) | ||
|
|
||
| it("keeps user_cancelled when a cancel lands during abortStream cleanup", async () => { | ||
| const mockProvider = makeMockProvider() | ||
|
|
||
| // Double cast: plain object satisfies only the methods called by this code path. | ||
| const task = new Task({ | ||
| provider: mockProvider as unknown as ClineProvider, | ||
| apiConfiguration: mockApiConfig, | ||
| task: "test task", | ||
| startTask: false, | ||
| }) | ||
|
|
||
| vi.spyOn(task.diffViewProvider, "reset").mockResolvedValue(undefined) | ||
| vi.spyOn(task, "dispose").mockResolvedValue(undefined) | ||
| const abortTaskSpy = vi.spyOn(task, "abortTask") | ||
|
|
||
| // eslint-disable-next-line require-yield -- intentional error-only async source | ||
| task["attemptApiRequest"] = async function* () { | ||
| throw new Error("mid-stream API failure") | ||
| } | ||
|
|
||
| // abortStream calls updateApiReqMsg("streaming_failed") synchronously before saving, | ||
| // so the api_req_started row has cancelReason="streaming_failed" when we are inside it. | ||
| mockSaveTaskMessages.mockImplementation(async () => { | ||
| const apiReqMsg = findLast(task.clineMessages, (m) => m.say === "api_req_started") | ||
| const parsed = apiReqMsg?.text ? (JSON.parse(apiReqMsg.text) as { cancelReason?: string }) : {} | ||
| if (parsed.cancelReason === "streaming_failed" && !task.didFinishAbortingStream) { | ||
| // cancelTask sets abortReason before abort=true. | ||
| task["abortReason"] = "user_cancelled" | ||
| task["abort"] = true | ||
| } | ||
| }) | ||
|
|
||
| // The outer catch in recursivelyMakeClineRequests swallows the abort throw and returns true. | ||
| const result = await task.recursivelyMakeClineRequests([{ type: "text", text: "help me" }]) | ||
| expect(result).toBe(true) | ||
| expect(task.didFinishAbortingStream).toBe(true) | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| expect(abortTaskSpy).toHaveBeenCalledOnce() | ||
| expect(task.abortReason).toBe("user_cancelled") | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| }) | ||
|
|
||
| it("keeps pre-existing abortReason when cancel lands during retry backoff", async () => { | ||
| const mockProvider = makeMockProvider() | ||
| // autoApprovalEnabled causes the else branch to call backoffAndAnnounce. | ||
| mockProvider.getState = vi.fn().mockResolvedValue({ autoApprovalEnabled: true }) | ||
|
|
||
| // Double cast: plain object satisfies only the methods called by this code path. | ||
| const task = new Task({ | ||
| provider: mockProvider as unknown as ClineProvider, | ||
| apiConfiguration: mockApiConfig, | ||
| task: "test task", | ||
| startTask: false, | ||
| }) | ||
|
|
||
| vi.spyOn(task.diffViewProvider, "reset").mockResolvedValue(undefined) | ||
| vi.spyOn(task, "dispose").mockResolvedValue(undefined) | ||
|
|
||
| // eslint-disable-next-line require-yield -- intentional error-only async source | ||
| task["attemptApiRequest"] = async function* () { | ||
| throw new Error("mid-stream failure") | ||
| } | ||
|
|
||
| // Simulate cancel landing during backoff: cancelTask sets abortReason before abort=true. | ||
| // Use a distinct seed value so ??= (keeps it) is distinguishable from = (overwrites). | ||
| task["backoffAndAnnounce"] = async () => { | ||
| task["abortReason"] = "streaming_failed" | ||
| task["abort"] = true | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| } | ||
|
|
||
| const abortTaskSpy = vi.spyOn(task, "abortTask") | ||
| // break after backoff exits the while loop; the outer try returns false. | ||
| const result = await task.recursivelyMakeClineRequests([{ type: "text", text: "help me" }]) | ||
| expect(result).toBe(false) | ||
| expect(abortTaskSpy).toHaveBeenCalledOnce() | ||
| expect(task.abortReason).toBe("streaming_failed") | ||
| }) | ||
| }) | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Can we apply the same
??=in the sibling abort path below (the mid-stream retry backoff branch,this.abortReason = "user_cancelled"at line 3755)? It's a harmless no-op overwrite today, but using??=in both places keeps the "first abort reason wins" invariant uniform across this whole catch block and avoids future readers wondering whether the two paths intentionally differ.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Done. Also removed the now-redundant comment on that line since the code is self-explanatory.