Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
cf5abe6
fix(task): stage-independent saveClineMessages + finalize open partia…
easonLiangWorldedtech Oct 5, 2026
d172c95
feat(write-to-file): per-task partial stream state + cleanup primitives
easonLiangWorldedtech Oct 5, 2026
52699c6
test(write-to-file): cover partial-state cleanup primitives directly
easonLiangWorldedtech Oct 5, 2026
4b23b6a
fix(write-to-file): capture streaming failure once, report it once
easonLiangWorldedtech Oct 5, 2026
e3c1040
feat(tools): onParameterParseFailure teardown boundary
easonLiangWorldedtech Oct 5, 2026
9b93a6f
fix(write-to-file): run diff cleanup when handleError rejects
easonLiangWorldedtech Oct 5, 2026
646c787
fix(write-to-file): clean partial state on missing-param and rooignor…
easonLiangWorldedtech Oct 5, 2026
b241bd2
Merge branch 'main' into p1066/u7-early-return-denial-cleanup
easonLiangWorldedtech Oct 5, 2026
418a494
fix(tools): do not report a completed teardown when the diff revert f…
Oct 6, 2026
9ac9068
test(tools): cover rollback failure with a retained stream error, and…
Oct 6, 2026
c59a283
fix(tools): report the rollback hazard from every write_to_file clean…
Oct 6, 2026
3709403
test(tools): drop the as-any casts from the write_to_file harness
Oct 6, 2026
b146056
chore: trigger a fresh review pass at this head
Oct 7, 2026
0a79bcb
fix(write-to-file): tear down stream state for a finalized call that …
Oct 8, 2026
fdf3d68
fix(write-to-file): report the rollback hazard from the abandoned-str…
Oct 8, 2026
711aab1
fix(write-to-file): never persist an abandoned stream's unapproved bu…
Oct 8, 2026
ddd3507
fix(write-to-file): make partial-stream handling cancellation-aware
Oct 8, 2026
876a93b
fix(write-to-file): make abandoned-stream cleanup failure-safe and ca…
Oct 8, 2026
d585c63
fix(write-to-file): route every pre-approval rollback through the new…
Oct 8, 2026
14fd87f
fix(p1066-u7): address the d585c6383 review - artifact cleanup, aband…
Oct 8, 2026
475d9e6
fix(task): do not let the dispose-time metadata retry block teardown
Oct 8, 2026
3a00650
fix(p1066-u7): address the 475d9e66b review - placeholder ownership, …
Oct 8, 2026
d9843bd
fix(task): run the dispose-time metadata retry after the synchronous …
Oct 8, 2026
aa959bf
test(editor): cover the ENOENT tolerance for every directory the disc…
Oct 8, 2026
4f02647
fix(write-to-file): release the partial stream state when the preview…
Oct 9, 2026
a244ef5
Merge org main (036245c5e, U1 #1927) into p1066/u7-early-return-denia…
Oct 9, 2026
1e68280
fix(task,write-to-file): repair metadata when the config name is know…
easonLiangWorldedtech Oct 9, 2026
f8f7ce1
fix(write-to-file): never save an unapproved stream when releasing it…
easonLiangWorldedtech Oct 10, 2026
9500c07
fix(abort-r1-u7): end the stream session on every disposal path
easonLiangWorldedtech Oct 10, 2026
b2b3b0d
style: format the three spec files the format check reaches
easonLiangWorldedtech Oct 10, 2026
f873a3d
refactor(task): drop the unreachable return in persistTaskMetadata
easonLiangWorldedtech Oct 10, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions src/__tests__/removeClineFromStack-delegation.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { ClineProvider } from "../core/webview/ClineProvider"
import { TaskRegistry } from "../core/task/TaskRegistry"
import { PendingActionSettlementError, type Task } from "../core/task/Task"
import { makeProviderStub } from "./helpers/provider-stub"
import { writeToFileTool } from "../core/tools/WriteToFileTool"

type MockTask = Pick<Task, "taskId" | "instanceId"> &
Partial<Pick<Task, "parentTaskId" | "abort" | "abandoned">> & {
Expand Down Expand Up @@ -208,6 +209,46 @@ describe("ClineProvider failed history restoration cleanup", () => {
expect(task.dispose).toHaveBeenCalledOnce()
})

it("releases the tool's per-task state before a directly disposed task loses its listeners", async () => {
const key = "failed-history-task.inst-1"
const order: string[] = []
const task = {
taskId: "failed-history-task",
instanceId: "inst-1",
emit: vi.fn(),
once: vi.fn(),
off: vi.fn(),
dispose: vi.fn().mockImplementation(() => {
// Dispose removes every listener, so this is the last moment the abort
// cleanup could still have run; the entry must already be gone here.
order.push(writeToFileTool["taskPartialStreamState"].has(key) ? "state-retained" : "state-cleared")
order.push("dispose")
return Promise.resolve()
}),
} as unknown as Task
// Fixture: the state entry the tool would have created during a partial stream.
writeToFileTool["taskPartialStreamState"].set(key, {
lastSeenPartialPath: undefined,
streamFailed: false,
streamError: undefined,
task,
abortCleanup: () => {},
})
const taskEventListeners = new Map([[task, [vi.fn()]]])
const taskRegistry = new TaskRegistry()
taskRegistry.push(task)
const provider = { taskRegistry, taskEventListeners, log: vi.fn() } as unknown as ClineProvider

await privateClineProvider.cleanupFailedHistoryTask.call(
provider,
task,
new PendingActionSettlementError("settlement failed"),
)

expect(order).toEqual(["state-cleared", "dispose"])
expect(writeToFileTool["taskPartialStreamState"].size).toBe(0)
})

it("keeps the task active after an unrelated history resume failure", async () => {
const cleanupListener = vi.fn()
const task = {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
// npx vitest src/core/assistant-message/__tests__/presentAssistantMessage-missing-native-args.spec.ts

import { describe, it, expect, beforeEach, vi } from "vitest"
import { presentAssistantMessage } from "../presentAssistantMessage"
import { isValidToolName, validateToolUse } from "../../tools/validateToolUse"

const mockTeardown = vi.hoisted(() => vi.fn())
const mockWriteHandle = vi.hoisted(() => vi.fn())

vi.mock("../../task/Task")
vi.mock("../../tools/validateToolUse", () => ({
validateToolUse: vi.fn(),
isValidToolName: vi.fn(() => true),
}))
vi.mock("../../tools/WriteToFileTool", () => ({
writeToFileTool: { handle: mockWriteHandle, teardownAbandonedStream: mockTeardown },
}))
vi.mock("@roo-code/telemetry", () => ({
TelemetryService: {
instance: {
captureToolUsage: vi.fn(),
captureConsecutiveMistakeError: vi.fn(),
captureException: vi.fn(),
},
},
}))

type ToolResultBlock = { type: string; tool_use_id: string; content: string; is_error: boolean }

describe("presentAssistantMessage - finalized block without nativeArgs", () => {
// The presenter reads only a subset of Task. A Record keeps the double honest without
// an `any`; the single cast at the call site is the documented boundary.
let mockTask: Record<string, unknown>
let userMessageContent: ToolResultBlock[]

beforeEach(() => {
mockTeardown.mockReset()
mockWriteHandle.mockReset()
userMessageContent = []
mockTask = {
taskId: "test-task-id",
instanceId: "test-instance",
abort: false,
presentAssistantMessageLocked: false,
presentAssistantMessageHasPendingUpdates: false,
currentStreamingContentIndex: 0,
assistantMessageContent: [
{
type: "tool_use",
name: "write_to_file",
params: {},
partial: false,
// Streaming JSON never parsed: Task completes the block with no nativeArgs.
id: "toolu_1",
nativeArgs: undefined,
},
],
userMessageContent,
didCompleteReadingStream: false,
didRejectTool: false,
didAlreadyUseTool: false,
consecutiveMistakeCount: 0,
consecutiveMistakeLimit: 3,
apiConfiguration: { apiProvider: "test-provider" },
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((toolResult: ToolResultBlock) => {
userMessageContent.push(toolResult)
return true
}),
}
})

it("tears the write_to_file stream state down instead of dispatching the tool", async () => {
await presentAssistantMessage(mockTask as unknown as Parameters<typeof presentAssistantMessage>[0])

// The malformed call must not be executed, and exactly one error tool_result is
// emitted for the provider.
expect(isValidToolName).toHaveBeenCalled()
expect(mockWriteHandle).not.toHaveBeenCalled()
expect(mockTask.pushToolResultToUserContent).toHaveBeenCalledTimes(1)
expect(userMessageContent).toEqual([
expect.objectContaining({
type: "tool_result",
tool_use_id: "toolu_1",
is_error: true,
content: expect.stringContaining("missing nativeArgs"),
}),
])
// The guard bypasses handle(), so the per-task stream state has to be released here:
// otherwise the entry, its TaskAborted listener and any streamed diff view leak into
// the next API request of the same task.
expect(mockTeardown).toHaveBeenCalledTimes(1)
expect(mockTeardown).toHaveBeenCalledWith(mockTask)
})

it("tears the stream state down when tool validation rejects the call", async () => {
// A mode file restriction (or any validateToolUse failure) lands here. The block
// streamed partial deltas - handlePartial ran - and this exit bypasses handle(), so
// without the teardown the per-task stream state, its TaskAborted listener and any
// streamed diff view leak into the next API request.
mockTask.assistantMessageContent = [
{
type: "tool_use",
name: "write_to_file",
params: { path: "restricted.ts", content: "partial model output" },
partial: false,
id: "toolu_2",
nativeArgs: { path: "restricted.ts", content: "partial model output" },
},
]
vi.mocked(validateToolUse).mockImplementationOnce(() => {
throw new Error("write_to_file is not allowed to write restricted.ts in this mode")
})

await presentAssistantMessage(mockTask as unknown as Parameters<typeof presentAssistantMessage>[0])

expect(mockWriteHandle).not.toHaveBeenCalled()
expect(userMessageContent).toEqual([
expect.objectContaining({
type: "tool_result",
tool_use_id: "toolu_2",
is_error: true,
content: expect.stringContaining("restricted.ts"),
}),
])
expect(mockTeardown).toHaveBeenCalledTimes(1)
expect(mockTeardown).toHaveBeenCalledWith(mockTask)
})

it("tears the stream state down when the tool repetition limit stops the call", async () => {
mockTask.assistantMessageContent = [
{
type: "tool_use",
name: "write_to_file",
params: { path: "same.ts", content: "same content" },
partial: false,
id: "toolu_3",
nativeArgs: { path: "same.ts", content: "same content" },
},
]
vi.mocked(mockTask.toolRepetitionDetector as unknown as { check: unknown }).check = vi.fn().mockReturnValue({
allowExecution: false,
askUser: { messageKey: "tool_repetition", messageDetail: "write_to_file" },
})

await presentAssistantMessage(mockTask as unknown as Parameters<typeof presentAssistantMessage>[0])

expect(mockWriteHandle).not.toHaveBeenCalled()
// The repetition exit reports through the local pushToolResult, which wraps the
// message in the tool-error envelope rather than the provider is_error flag.
expect(userMessageContent).toEqual([
expect.objectContaining({
type: "tool_result",
tool_use_id: "toolu_3",
content: expect.stringContaining("repetition limit reached"),
}),
])
expect(mockTeardown).toHaveBeenCalledTimes(1)
expect(mockTeardown).toHaveBeenCalledWith(mockTask)
})
})
22 changes: 22 additions & 0 deletions src/core/assistant-message/presentAssistantMessage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -572,6 +572,14 @@
is_error: true,
})

// A write_to_file call that streamed partial deltas leaves per-task stream state
// (and possibly an open diff view) behind, and this guard bypasses handle(), so
// the teardown that execute()/onParameterParseFailure would have run never does.
// Release it here instead of leaking it into the next API request.
if (block.name === "write_to_file") {

Check warning on line 579 in src/core/assistant-message/presentAssistantMessage.ts

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

src/core/assistant-message/presentAssistantMessage.ts:579: Survived ConditionalExpression mutant (replacement: true). See the job summary for the complete list and resolution guidance.
await writeToFileTool.teardownAbandonedStream(cline)
}

Comment thread
coderabbitai[bot] marked this conversation as resolved.
break
}
}
Expand Down Expand Up @@ -779,6 +787,14 @@
error.message,
)

// Same leak as the missing-nativeArgs guard: the block streamed partial
// deltas (handlePartial ran), this exit bypasses handle()/execute(), so the
// per-task stream state and any open diff view must be released here. A
// mode file restriction blocking the target path lands exactly here.
if (block.name === "write_to_file") {

Check warning on line 794 in src/core/assistant-message/presentAssistantMessage.ts

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

src/core/assistant-message/presentAssistantMessage.ts:794: Survived ConditionalExpression mutant (replacement: true). See the job summary for the complete list and resolution guidance.
await writeToFileTool.teardownAbandonedStream(cline)
}

break
}

Expand Down Expand Up @@ -846,6 +862,12 @@
`Tool call repetition limit reached for ${block.name}. Please try a different approach.`,
),
)

// The repetition exit also bypasses handle(): release the stream state and
// diff view the partial deltas created, or they leak into the next request.
if (block.name === "write_to_file") {

Check warning on line 868 in src/core/assistant-message/presentAssistantMessage.ts

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

src/core/assistant-message/presentAssistantMessage.ts:868: Survived ConditionalExpression mutant (replacement: true). See the job summary for the complete list and resolution guidance.
await writeToFileTool.teardownAbandonedStream(cline)
}
break
}
}
Expand Down
Loading
Loading