Repository navigation
fix(task): guard saveClineMessages against abandoned tasks (fixes #1021) #1496
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
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -36,6 +36,9 @@ import { processUserContentMentions } from "../../mentions/processUserContentMen | |
| import { MultiSearchReplaceDiffStrategy } from "../../diff/strategies/multi-search-replace" | ||
| import type { ApiMessage } from "../../task-persistence" | ||
| import type { RequestPolicySnapshot } from "../../prompts/tools/effective-tool-policy" | ||
| import * as taskMetadataModule from "../../task-persistence/taskMetadata" | ||
| import * as taskMessagesModule from "../../task-persistence/taskMessages" | ||
| import { getApiMetrics } from "../../../shared/getApiMetrics" | ||
| import { asyncStreamFrom } from "../../../test-utils/stream" | ||
| import { McpHub } from "../../../services/mcp/McpHub" | ||
| import { McpServerManager } from "../../../services/mcp/McpServerManager" | ||
|
|
@@ -8679,3 +8682,224 @@ describe("pushToolResultToUserContent", () => { | |
| expect(task.userMessageContent).toEqual([toolResult1, toolResult2, image, text1, text2]) | ||
| }) | ||
| }) | ||
|
|
||
| describe("saveClineMessages abandoned guard (#1021)", () => { | ||
| beforeEach(() => { | ||
| if (!TelemetryService.hasInstance()) { | ||
| TelemetryService.createInstance([]) | ||
| } | ||
| }) | ||
|
|
||
| // The history item carries the stale link: a fire-and-forget save that | ||
| // reaches updateTaskHistory() after abandonSubtask's atomicUpdatePair() | ||
| // cleared parentTaskId/rootTaskId would silently reattach the severed | ||
| // parent-child link. | ||
| const staleHistoryItem: HistoryItem = { | ||
| id: "orphan-subtask", | ||
| number: 7, | ||
| ts: Date.now(), | ||
| task: "orphan subtask", | ||
| tokensIn: 0, | ||
| tokensOut: 0, | ||
| totalCost: 0, | ||
| rootTaskId: "stale-root", | ||
| parentTaskId: "stale-parent", | ||
| } | ||
|
|
||
| // Task receives a full ClineProvider at runtime; these focused unit tests only | ||
| // exercise these methods, so the partial double is cast. taskHistoryStore must | ||
| // be stubbed: without it provider?.taskHistoryStore.get() throws before the | ||
| // guard is evaluated and the catch would mask whether execution reached | ||
| // updateTaskHistory(). | ||
| // | ||
| // The double does not structurally overlap MockedClineProvider, so a direct | ||
| // literal `as` fails (TS2352). Object.assign over an empty-object single cast | ||
| // keeps one assertion: the target carries the declared type and the source's | ||
| // property types stay inferred. | ||
| function makeMockProvider() { | ||
| const mockProvider = Object.assign({} as MockedClineProvider, { | ||
| context: { | ||
| globalStorageUri: { fsPath: "/test/storage" }, | ||
| globalState: { | ||
| get: vi.fn().mockImplementation(() => undefined), | ||
| update: vi.fn().mockResolvedValue(undefined), | ||
| keys: vi.fn().mockReturnValue([]), | ||
| }, | ||
| }, | ||
| getState: vi.fn().mockResolvedValue({ | ||
| apiConfiguration: { apiProvider: providerIdentifiers.anthropic, apiKey: "test-key" }, | ||
| mcpEnabled: false, | ||
| }), | ||
| getMcpHub: vi.fn().mockReturnValue(undefined), | ||
| postMessageToWebview: vi.fn().mockResolvedValue(undefined), | ||
| updateTaskHistory: vi.fn().mockResolvedValue(undefined), | ||
| taskHistoryStore: { get: vi.fn(() => undefined) }, | ||
| }) | ||
| return mockProvider | ||
| } | ||
|
|
||
| function createTask(provider: MockedClineProvider) { | ||
| return new Task({ | ||
| provider, | ||
| apiConfiguration: { apiProvider: providerIdentifiers.anthropic, apiKey: "test-key" }, | ||
| task: "orphan subtask", | ||
| startTask: false, | ||
| }) | ||
| } | ||
|
|
||
| it("persists messages but does not update task history when the task was abandoned", async () => { | ||
| const saveSpy = vi.spyOn(taskMessagesModule, "saveTaskMessages").mockResolvedValue([]) | ||
| const metaSpy = vi | ||
| .spyOn(taskMetadataModule, "taskMetadata") | ||
| .mockResolvedValue({ historyItem: staleHistoryItem, tokenUsage: getApiMetrics([]) }) | ||
|
|
||
| try { | ||
| const mockProvider = makeMockProvider() | ||
| const task = createTask(mockProvider) | ||
|
|
||
| // Seed a message so the payload assertion is meaningful: clineMessages | ||
| // starts empty, and an empty-payload save would pass a call-count-only | ||
| // assertion even if the save dropped the messages field. | ||
| task.clineMessages = [{ ts: 1, type: "say", say: "text", text: "seeded message" }] | ||
|
|
||
| // abandonSubtask severs the link, then aborts the subtask with | ||
| // isAbandoned=true; an in-flight fire-and-forget save lands here. | ||
| task.requestAbort(true) | ||
|
|
||
| const saved = await getTaskTestAccess(task).saveClineMessages() | ||
|
|
||
|
easonLiangWorldedtech marked this conversation as resolved.
Resolved
|
||
| // The boolean reflects the message write only: the write succeeded, so the | ||
| // save reports success even though the history update is skipped. | ||
| expect(saved).toBe(true) | ||
| expect(saveSpy).toHaveBeenCalledTimes(1) // messages are still persisted | ||
|
coderabbitai[bot] marked this conversation as resolved.
Resolved
|
||
| expect(saveSpy).toHaveBeenCalledWith( | ||
| expect.objectContaining({ | ||
| taskId: task.taskId, | ||
| messages: expect.arrayContaining([expect.objectContaining({ text: "seeded message" })]), | ||
| }), | ||
| ) | ||
| expect(mockProvider.updateTaskHistory).not.toHaveBeenCalled() // history link is not reattached | ||
| } finally { | ||
| saveSpy.mockRestore() | ||
| metaSpy.mockRestore() | ||
| } | ||
| }) | ||
|
|
||
| it("refreshes an existing history row without reattaching the severed link when abandoned", async () => { | ||
| const saveSpy = vi.spyOn(taskMessagesModule, "saveTaskMessages").mockResolvedValue([]) | ||
| const metaSpy = vi.spyOn(taskMetadataModule, "taskMetadata").mockResolvedValue({ | ||
| historyItem: { ...staleHistoryItem, tokensIn: 11, tokensOut: 7, totalCost: 0.02 }, | ||
| tokenUsage: getApiMetrics([]), | ||
| }) | ||
|
|
||
| try { | ||
| const mockProvider = makeMockProvider() | ||
| // The row already exists with the severed link; a plain cancel's final save | ||
| // must refresh metadata but keep the link severed (upsertCore merges, so the | ||
| // link keys must be omitted from the update). | ||
| ;(mockProvider.taskHistoryStore.get as ReturnType<typeof vi.fn>).mockReturnValue({ | ||
| id: "orphan-subtask", | ||
| status: "completed", | ||
| }) | ||
| const task = createTask(mockProvider) | ||
| task.clineMessages = [{ ts: 1, type: "say", say: "text", text: "seeded message" }] | ||
| task.requestAbort(true) | ||
|
|
||
| const saved = await getTaskTestAccess(task).saveClineMessages() | ||
|
|
||
| expect(saved).toBe(true) | ||
| expect(mockProvider.updateTaskHistory).toHaveBeenCalledTimes(1) | ||
| const update = (mockProvider.updateTaskHistory as ReturnType<typeof vi.fn>).mock.calls[0][0] | ||
| // Metadata is refreshed... | ||
| expect(update).toEqual( | ||
| expect.objectContaining({ | ||
| id: "orphan-subtask", | ||
| tokensIn: 11, | ||
| tokensOut: 7, | ||
| totalCost: 0.02, | ||
| status: "completed", | ||
| }), | ||
| ) | ||
| // ...but the update carries no link keys: the merge must leave the stored | ||
| // severed link untouched. | ||
| expect(update).not.toHaveProperty("parentTaskId") | ||
| expect(update).not.toHaveProperty("rootTaskId") | ||
| } finally { | ||
| saveSpy.mockRestore() | ||
| metaSpy.mockRestore() | ||
| } | ||
| }) | ||
|
Comment on lines
+8788
to
+8831
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Add a test for an existing history row without The guard has two branches at If the As per path instructions, "Require regression coverage at the lowest valid harness with behavior-focused assertions, including relevant negative, error, false/unset, and boundary cases."
|
||
|
|
||
| it("updates task history for a non-abandoned task (guard does not block the normal path)", async () => { | ||
| const saveSpy = vi.spyOn(taskMessagesModule, "saveTaskMessages").mockResolvedValue([]) | ||
| const metaSpy = vi | ||
| .spyOn(taskMetadataModule, "taskMetadata") | ||
| .mockResolvedValue({ historyItem: staleHistoryItem, tokenUsage: getApiMetrics([]) }) | ||
|
|
||
| try { | ||
| const mockProvider = makeMockProvider() | ||
| const task = createTask(mockProvider) | ||
|
|
||
| const saved = await getTaskTestAccess(task).saveClineMessages() | ||
|
|
||
| expect(saved).toBe(true) | ||
| expect(saveSpy).toHaveBeenCalledTimes(1) | ||
| // Control case: the guard must not block the normal path, so execution | ||
| // genuinely reached updateTaskHistory(). No pre-existing store entry, | ||
| // so the item is written as-is. | ||
| expect(mockProvider.updateTaskHistory).toHaveBeenCalledTimes(1) | ||
| expect(mockProvider.updateTaskHistory).toHaveBeenCalledWith(staleHistoryItem) | ||
| } finally { | ||
| saveSpy.mockRestore() | ||
| metaSpy.mockRestore() | ||
| } | ||
| }) | ||
|
|
||
| it("skips the history update when the task is abandoned while the save is in flight", async () => { | ||
| // Fire-and-forget race: the save starts while the task is still active, | ||
| // abandonSubtask severs the link mid-save, and the guard must catch it | ||
| // when the awaited saveTaskMessages() finally resolves. | ||
| let resolveSave!: (value: import("@roo-code/types").ClineMessage[]) => void | ||
| const saveSpy = vi | ||
| .spyOn(taskMessagesModule, "saveTaskMessages") | ||
| .mockImplementation( | ||
| () => new Promise<import("@roo-code/types").ClineMessage[]>((resolve) => (resolveSave = resolve)), | ||
| ) | ||
| const metaSpy = vi | ||
| .spyOn(taskMetadataModule, "taskMetadata") | ||
| .mockResolvedValue({ historyItem: staleHistoryItem, tokenUsage: getApiMetrics([]) }) | ||
|
|
||
| try { | ||
| const mockProvider = makeMockProvider() | ||
| const task = createTask(mockProvider) | ||
|
|
||
| // Seed a message so the payload assertion is meaningful: clineMessages | ||
| // starts empty, and an empty-payload save would pass a call-count-only | ||
| // assertion even if the save dropped the messages field. | ||
| task.clineMessages = [{ ts: 1, type: "say", say: "text", text: "seeded message" }] | ||
|
|
||
| const savePromise = getTaskTestAccess(task).saveClineMessages() | ||
|
|
||
| // The save is in flight (awaiting saveTaskMessages) when the task is | ||
| // abandoned; only after it resumes does the save hit the guard. | ||
| task.requestAbort(true) | ||
| resolveSave([]) | ||
|
|
||
| const saved = await savePromise | ||
|
|
||
| // Write-success contract: the guard skips the history update, not the save. | ||
| expect(saved).toBe(true) | ||
| expect(saveSpy).toHaveBeenCalledTimes(1) // messages are still persisted | ||
| expect(saveSpy).toHaveBeenCalledWith( | ||
| expect.objectContaining({ | ||
| messages: expect.arrayContaining([expect.objectContaining({ text: "seeded message" })]), | ||
| }), | ||
| ) | ||
| expect(mockProvider.updateTaskHistory).not.toHaveBeenCalled() // history link is not reattached | ||
| } finally { | ||
| resolveSave?.([]) // settle the deferred save if an assertion failed above | ||
| saveSpy.mockRestore() | ||
| metaSpy.mockRestore() | ||
| } | ||
| }) | ||
| }) | ||
Uh oh!
There was an error while loading. Please reload this page.