Repository navigation
feat(write-to-file): per-task partial stream state + cleanup primitives (split 2/6 of #1066) #1929
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
Open
easonLiangWorldedtech
wants to merge
23
commits into
Zoo-Code-Org:main
Choose a base branch
from
easonLiangWorldedtech:p1066/u4-per-task-stream-state
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+3,216
−87
Open
Changes from all commits
Commits
Show all changes
23 commits
Select commit
Hold shift + click to select a range
cf5abe6
fix(task): stage-independent saveClineMessages + finalize open partia…
easonLiangWorldedtech d172c95
feat(write-to-file): per-task partial stream state + cleanup primitives
easonLiangWorldedtech 52699c6
test(write-to-file): cover partial-state cleanup primitives directly
easonLiangWorldedtech 3bd6bbe
fix(tools): scope the write_to_file stream teardown to one task and f…
d8ee1fe
chore: trigger a fresh review pass at this head
1d4a2a6
fix(write-to-file): release stream state on every execute() exit and …
2f356e6
fix(write-to-file): check stream liveness after the diff-view open() …
ef49801
Merge branch 'main' into p1066/u4-per-task-stream-state
easonLiangWorldedtech 9a06ac9
fix(write-to-file): release the partial stream state on every tool-ca…
2a8d6e4
Merge org main (036245c5e, U1 #1927) into p1066/u4-per-task-stream-state
80fb429
fix(write-to-file): release per-task stream state when the parameter …
easonLiangWorldedtech fb21709
fix(write-to-file): report rollback failures and guard every partial …
easonLiangWorldedtech 61dd05a
fix(write-to-file): own the diff-view failure and record what the str…
easonLiangWorldedtech 12ec5f8
style: satisfy the repository prettier gate on the files this PR changes
easonLiangWorldedtech 8084eab
fix(write-to-file): keep rollback honest when the discard itself fails
easonLiangWorldedtech 9e84c77
fix(abort-r1-u4): release a stream the presenter rejects
easonLiangWorldedtech 1b387ee
fix(abort-r1-u4): keep a failed report from owning the teardown
easonLiangWorldedtech 1374085
fix(abort-r1-u4): account for directories created before the diff view
easonLiangWorldedtech cfc5464
style(abort-r1-u4): reflow a chained mock the formatter splits differ…
easonLiangWorldedtech 4cfb498
fix(abort-r1-u4): finish the teardowns the new discard path left open
easonLiangWorldedtech f046c92
fix(file-safety): remove adopted directories before resetting a denie…
easonLiangWorldedtech c02deeb
test(write-to-file): adopt directories before asserting their cleanup
237ee41
test(write-to-file): cover a failed discard on the rooignore-denial exit
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
260 changes: 260 additions & 0 deletions
260
src/core/assistant-message/__tests__/presentAssistantMessage-validation-rejection.spec.ts
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,260 @@ | ||
| // npx vitest run core/assistant-message/__tests__/presentAssistantMessage-validation-rejection.spec.ts | ||
|
|
||
| import { describe, it, expect, beforeEach, vi } from "vitest" | ||
|
|
||
| import { providerIdentifiers } from "@roo-code/types/provider-identifiers" | ||
|
|
||
| import { presentAssistantMessage } from "../presentAssistantMessage" | ||
| import type { Task } from "../../task/Task" | ||
|
|
||
| const mockRelease = vi.hoisted(() => vi.fn().mockResolvedValue(undefined)) | ||
| const mockHandle = vi.hoisted(() => vi.fn().mockResolvedValue(undefined)) | ||
| const mockValidate = vi.hoisted(() => vi.fn()) | ||
|
|
||
| vi.mock("../../task/Task") | ||
| vi.mock("../../tools/validateToolUse", () => ({ | ||
| validateToolUse: mockValidate, | ||
| isValidToolName: vi.fn(() => true), | ||
| })) | ||
| vi.mock("../../tools/NewTaskTool", () => ({ newTaskTool: { handle: vi.fn() } })) | ||
| vi.mock("../../tools/WriteToFileTool", () => ({ | ||
| writeToFileTool: { handle: mockHandle, releaseStreamAfterValidationRejection: mockRelease }, | ||
| })) | ||
| vi.mock("@roo-code/telemetry", () => ({ | ||
| TelemetryService: { | ||
| instance: { | ||
| captureToolUsage: vi.fn(), | ||
| captureConsecutiveMistakeError: vi.fn(), | ||
| captureException: vi.fn(), | ||
| }, | ||
| }, | ||
| })) | ||
|
|
||
| interface ToolResultBlock { | ||
| type: string | ||
| tool_use_id?: string | ||
| content?: string | ||
| is_error?: boolean | ||
| } | ||
|
|
||
| /** | ||
| * Structural double for the presenter's Task surface. The presenter reads far more of Task | ||
| * than the two rejection branches touch, so the double carries only what this file drives; | ||
| * it is handed over through unknown (the repo's pattern for presenter-level doubles, see | ||
| * presentAssistantMessage-tool-usage-attribution.spec.ts) rather than any. | ||
| */ | ||
| interface PresenterTask { | ||
| taskId: string | ||
| instanceId: string | ||
| abort: boolean | ||
| presentAssistantMessageLocked: boolean | ||
| presentAssistantMessageHasPendingUpdates: boolean | ||
| currentStreamingContentIndex: number | ||
| assistantMessageContent: Record<string, unknown>[] | ||
| userMessageContent: ToolResultBlock[] | ||
| didCompleteReadingStream: boolean | ||
| didRejectTool: boolean | ||
| didAlreadyUseTool: boolean | ||
| consecutiveMistakeCount: number | ||
| consecutiveMistakeLimit: number | ||
| apiConfiguration: { apiProvider: string } | ||
| clineMessages: unknown[] | ||
| getTaskMode: ReturnType<typeof vi.fn> | ||
| api: { getModel: () => { id: string; info: Record<string, unknown> } } | ||
| recordToolUsage: ReturnType<typeof vi.fn> | ||
| recordToolError: ReturnType<typeof vi.fn> | ||
| toolRepetitionDetector: { check: ReturnType<typeof vi.fn> } | ||
| providerRef: { deref: () => { getState: ReturnType<typeof vi.fn> } } | ||
| say: ReturnType<typeof vi.fn> | ||
| ask: ReturnType<typeof vi.fn> | ||
| pushToolResultToUserContent: ReturnType<typeof vi.fn> | ||
| } | ||
|
|
||
| describe("presentAssistantMessage - a rejected write_to_file releases its stream", () => { | ||
| let mockTask: PresenterTask | ||
|
|
||
| beforeEach(() => { | ||
| vi.clearAllMocks() | ||
| mockRelease.mockResolvedValue(undefined) | ||
| mockHandle.mockResolvedValue(undefined) | ||
| mockValidate.mockImplementation(() => { | ||
| throw new Error("write_to_file is not allowed in this mode") | ||
| }) | ||
| mockTask = { | ||
| taskId: "validation-task", | ||
| instanceId: "inst-1", | ||
| abort: false, | ||
| presentAssistantMessageLocked: false, | ||
| presentAssistantMessageHasPendingUpdates: false, | ||
| currentStreamingContentIndex: 0, | ||
| assistantMessageContent: [], | ||
| userMessageContent: [], | ||
| didCompleteReadingStream: false, | ||
| didRejectTool: false, | ||
| didAlreadyUseTool: false, | ||
| consecutiveMistakeCount: 0, | ||
| consecutiveMistakeLimit: 3, | ||
| apiConfiguration: { apiProvider: providerIdentifiers.anthropic }, | ||
| clineMessages: [], | ||
| getTaskMode: vi.fn().mockResolvedValue("code"), | ||
| api: { getModel: () => ({ id: "test-model", info: {} }) }, | ||
| recordToolUsage: vi.fn(), | ||
| recordToolError: vi.fn(), | ||
| toolRepetitionDetector: { check: vi.fn().mockReturnValue({ allowExecution: true }) }, | ||
| providerRef: { deref: () => ({ getState: vi.fn().mockResolvedValue({ mode: "code", customModes: [] }) }) }, | ||
| say: vi.fn().mockResolvedValue(undefined), | ||
| ask: vi.fn().mockResolvedValue({ response: "yesButtonClicked" }), | ||
|
|
||
| pushToolResultToUserContent: vi.fn().mockImplementation((toolResult) => { | ||
| mockTask.userMessageContent.push(toolResult) | ||
| return true | ||
| }), | ||
| } | ||
| }) | ||
|
|
||
| const writeBlock = () => [ | ||
| { | ||
| type: "tool_use", | ||
| id: "call-write-1", | ||
| name: "write_to_file", | ||
| params: { path: "a.ts", content: "partial from the stream" }, | ||
| // The presenter breaks earlier for a known tool whose nativeArgs never arrived, so the | ||
| // block has to carry them to reach the validation step. | ||
| nativeArgs: { path: "a.ts", content: "partial from the stream" }, | ||
| partial: false, | ||
| }, | ||
| ] | ||
|
|
||
| it("releases the streamed state when validation rejects the completed block", async () => { | ||
| // A partial delta is never validated, so streaming can already have registered this | ||
| // task's write_to_file state and opened a preview by the time the completed block is | ||
| // rejected. The loop breaks before writeToFileTool.handle() runs, so nothing else | ||
| // releases them and the task carries a stale stream into its next write. | ||
| mockTask.assistantMessageContent = writeBlock() | ||
|
|
||
| await presentAssistantMessage(mockTask as unknown as Task) | ||
|
|
||
| expect(mockRelease).toHaveBeenCalledTimes(1) | ||
| expect(mockRelease).toHaveBeenCalledWith(mockTask) | ||
| expect(mockHandle).not.toHaveBeenCalled() | ||
| // The validation error stays the tool result the model sees. | ||
| const toolResult = mockTask.userMessageContent.find((item) => item.type === "tool_result") | ||
| if (!toolResult) { | ||
| throw new Error("expected a tool_result for the rejected call") | ||
| } | ||
| expect(toolResult.is_error).toBe(true) | ||
| expect(toolResult.content).toContain("not allowed in this mode") | ||
| }) | ||
|
|
||
| it("releases the streamed state when the repetition guard refuses the completed block", async () => { | ||
| // Same family: the block is refused before handle() runs, so the stream owns nobody | ||
| // but this branch. | ||
| mockValidate.mockReturnValue(undefined) | ||
| mockTask.toolRepetitionDetector.check = vi.fn().mockReturnValue({ | ||
| allowExecution: false, | ||
| askUser: { messageKey: "mistake_limit_reached", messageDetail: "repeated" }, | ||
| }) | ||
| mockTask.assistantMessageContent = writeBlock() | ||
|
|
||
| await presentAssistantMessage(mockTask as unknown as Task) | ||
|
|
||
| expect(mockRelease).toHaveBeenCalledTimes(1) | ||
| expect(mockHandle).not.toHaveBeenCalled() | ||
| // pushToolResult is the presenter's own closure; what the model receives is the | ||
| // tool_result in the user content, which is what must still carry the refusal. | ||
| const toolResult = mockTask.userMessageContent.find((item) => item.type === "tool_result") | ||
| if (!toolResult) { | ||
| throw new Error("expected a tool_result for the rejected call") | ||
| } | ||
| expect(toolResult.content).toContain("repetition limit reached") | ||
| }) | ||
|
|
||
| it("releases the streamed state when the completed block carries no native arguments", async () => { | ||
| // A third way to break out of the loop before handle(): the parser never finished the | ||
| // call. Streaming is not gated by it either, so the same state and preview can exist. | ||
| mockValidate.mockReturnValue(undefined) | ||
| mockTask.assistantMessageContent = [ | ||
| { | ||
| type: "tool_use", | ||
| id: "call-write-2", | ||
| name: "write_to_file", | ||
| params: { path: "a.ts" }, | ||
| partial: false, | ||
| }, | ||
| ] | ||
|
|
||
| await presentAssistantMessage(mockTask as unknown as Task) | ||
|
|
||
| expect(mockRelease).toHaveBeenCalledTimes(1) | ||
| expect(mockRelease).toHaveBeenCalledWith(mockTask) | ||
| expect(mockHandle).not.toHaveBeenCalled() | ||
| const toolResult = mockTask.userMessageContent.find((item) => item.type === "tool_result") | ||
| if (!toolResult) { | ||
| throw new Error("expected a tool_result for the rejected call") | ||
| } | ||
| expect(toolResult.content).toContain("missing nativeArgs") | ||
| }) | ||
|
|
||
| it("does not release the write_to_file stream for a different tool refused by the repetition guard", async () => { | ||
| // The release is scoped by tool name in every branch. A repeated read_file must not | ||
| // reach through to write_to_file's state. | ||
| mockValidate.mockReturnValue(undefined) | ||
| mockTask.toolRepetitionDetector.check = vi.fn().mockReturnValue({ | ||
| allowExecution: false, | ||
| askUser: { messageKey: "mistake_limit_reached", messageDetail: "repeated" }, | ||
| }) | ||
| mockTask.assistantMessageContent = [ | ||
| { | ||
| type: "tool_use", | ||
| id: "call-read-2", | ||
| name: "read_file", | ||
| params: { path: "a.ts" }, | ||
| nativeArgs: { path: "a.ts" }, | ||
| partial: false, | ||
| }, | ||
| ] | ||
|
|
||
| await presentAssistantMessage(mockTask as unknown as Task) | ||
|
|
||
| expect(mockRelease).not.toHaveBeenCalled() | ||
| expect(mockHandle).not.toHaveBeenCalled() | ||
| }) | ||
|
|
||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| it("does not release the write_to_file stream for a different tool with no native arguments", async () => { | ||
| // The name check guards this branch too. Without a negative control here, a mutant | ||
| // that releases for every tool in this branch survives. | ||
| mockValidate.mockReturnValue(undefined) | ||
| mockTask.assistantMessageContent = [ | ||
| { | ||
| type: "tool_use", | ||
| id: "call-read-3", | ||
| name: "read_file", | ||
| params: { path: "a.ts" }, | ||
| partial: false, | ||
| }, | ||
| ] | ||
|
|
||
| await presentAssistantMessage(mockTask as unknown as Task) | ||
|
|
||
| expect(mockRelease).not.toHaveBeenCalled() | ||
| expect(mockHandle).not.toHaveBeenCalled() | ||
| }) | ||
|
|
||
| it("leaves the stream alone when a rejected tool never streamed", async () => { | ||
| // The release is write_to_file scoped: a rejected read_file must not touch it. | ||
| mockTask.assistantMessageContent = [ | ||
| { | ||
| type: "tool_use", | ||
| id: "call-read-1", | ||
| name: "read_file", | ||
| params: { path: "a.ts" }, | ||
| nativeArgs: { path: "a.ts" }, | ||
| partial: false, | ||
| }, | ||
| ] | ||
|
|
||
| await presentAssistantMessage(mockTask as unknown as Task) | ||
|
|
||
| expect(mockRelease).not.toHaveBeenCalled() | ||
| }) | ||
| }) | ||
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.
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.
Uh oh!
There was an error while loading. Please reload this page.