Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
86 changes: 86 additions & 0 deletions src/__tests__/ClineProvider.delegation.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,92 @@ describe("ClineProvider.delegateParentAndOpenChild()", () => {
expect(taskHistoryStore.atomicReadAndUpdate).not.toHaveBeenCalled()
})

describe("when the parent's pending tool results cannot be saved", () => {
const setup = (parentTask: ReturnType<typeof makeParentTask>) => {
const child = { taskId: "child-1", run: vi.fn().mockResolvedValue(undefined) }
const taskHistoryStore = makeStoreStub({ get: vi.fn().mockReturnValue(parentHistoryItem) })
const provider = {
taskScheduler: new TaskScheduler(),
emit: vi.fn(),
getCurrentTask: vi.fn(() => parentTask),
removeClineFromStack: vi.fn().mockResolvedValue(undefined),
createTask: vi.fn().mockResolvedValue(child),
handleModeSwitch: vi.fn().mockResolvedValue(undefined),
log: vi.fn(),
isViewLaunched: false,
taskHistoryStore,
} as unknown as ClineProvider
const delegate = () =>
ClineProvider.prototype.delegateParentAndOpenChild.call(provider, {
parentTaskId: "parent-1",
message: "Do something",
initialTodos: [],
mode: "code",
})
return { provider, taskHistoryStore, delegate }
}

const expectNothingDelegated = ({ provider, taskHistoryStore }: ReturnType<typeof setup>) => {
expect(provider.removeClineFromStack).not.toHaveBeenCalled()
expect(provider.createTask).not.toHaveBeenCalled()
expect(taskHistoryStore.atomicReadAndUpdate).not.toHaveBeenCalled()
}

it.each([
{
name: "the flush and the retry save both fail",
arrange: (parent: ReturnType<typeof makeParentTask>) => {
parent.flushPendingToolResultsToHistory.mockResolvedValue(false)
parent.retrySaveApiConversationHistory.mockResolvedValue(false)
},
},
{
name: "the retry save succeeds but the results are still not in history",
arrange: (parent: ReturnType<typeof makeParentTask>) => {
parent.flushPendingToolResultsToHistory.mockResolvedValue(false)
parent.retrySaveApiConversationHistory.mockResolvedValue(true)
},
},
{
name: "the flush throws",
arrange: (parent: ReturnType<typeof makeParentTask>) => {
parent.flushPendingToolResultsToHistory.mockRejectedValue(new Error("unexpected"))
},
},
])("refuses to delegate and leaves the parent open when $name", async ({ arrange }) => {
const parentTask = makeParentTask()
arrange(parentTask)
const harness = setup(parentTask)

await expect(harness.delegate()).rejects.toThrow("pending tool results could not be saved")

expectNothingDelegated(harness)
expect(parentTask.emit).not.toHaveBeenCalled()
})

it("delegates when a repeat flush after the retry save succeeds", async () => {
const parentTask = makeParentTask()
parentTask.flushPendingToolResultsToHistory.mockResolvedValueOnce(false).mockResolvedValueOnce(true)
parentTask.retrySaveApiConversationHistory.mockResolvedValue(true)
const harness = setup(parentTask)

await expect(harness.delegate()).resolves.toMatchObject({ taskId: "child-1" })

expect(parentTask.flushPendingToolResultsToHistory).toHaveBeenCalledTimes(2)
expect(harness.provider.createTask).toHaveBeenCalledOnce()
})

it("delegates without a retry when the first flush succeeds", async () => {
const parentTask = makeParentTask()
const harness = setup(parentTask)

await expect(harness.delegate()).resolves.toMatchObject({ taskId: "child-1" })

expect(parentTask.flushPendingToolResultsToHistory).toHaveBeenCalledOnce()
expect(parentTask.retrySaveApiConversationHistory).not.toHaveBeenCalled()
})
})

it("clears a matching pending action when delegation commits", async () => {
const pendingAction = {
kind: "create_subtask" as const,
Expand Down
18 changes: 18 additions & 0 deletions src/core/task/Task.ts
Original file line number Diff line number Diff line change
Expand Up @@ -534,6 +534,12 @@
* Set to `true` only after the assistant message is durably saved.
*/
assistantMessageSavedToHistory = false
/**
* True when the pending tool results were already appended to `apiConversationHistory` but the
* save failed. `userMessageContent` is kept for recovery, so a repeat flush must save the history
* as it is instead of appending the same user message again.
*/
private pendingToolResultsInHistory = false
private assistantMessagePersistencePromise!: Promise<AssistantMessagePersistenceResult>
private resolveAssistantMessagePersistence!: (result: AssistantMessagePersistenceResult) => void
private assistantMessagePersistenceCancellation?: AssistantMessagePersistenceCancellation
Expand Down Expand Up @@ -1528,6 +1534,16 @@
return false
}

if (this.pendingToolResultsInHistory) {
// A previous flush appended the results but could not save them; save, never append twice.
const saved = await this.saveApiConversationHistory()
if (saved) {

Check warning on line 1540 in src/core/task/Task.ts

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

src/core/task/Task.ts:1540: Survived ConditionalExpression mutant (replacement: true). See the job summary for the complete list and resolution guidance.
this.userMessageContent = []
this.pendingToolResultsInHistory = false

Check warning on line 1542 in src/core/task/Task.ts

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

src/core/task/Task.ts:1542: Survived BooleanLiteral mutant (replacement: true). See the job summary for the complete list and resolution guidance.
}
return saved
}

// CRITICAL: Wait for the assistant message to be saved to API history first.
// Without this, tool_result blocks would appear BEFORE tool_use blocks in the
// conversation history, causing API errors like:
Expand Down Expand Up @@ -1580,6 +1596,7 @@
// Clear the pending content since it's now saved
this.userMessageContent = []
} else {
this.pendingToolResultsInHistory = true
console.warn(
`[Task#${this.taskId}] flushPendingToolResultsToHistory: save failed, retaining pending tool results in memory`,
)
Expand Down Expand Up @@ -4106,6 +4123,7 @@
this.assistantMessageContent = []
this.didCompleteReadingStream = false
this.userMessageContent = []
this.pendingToolResultsInHistory = false

Check warning on line 4126 in src/core/task/Task.ts

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

src/core/task/Task.ts:4126: Survived BooleanLiteral mutant (replacement: true). See the job summary for the complete list and resolution guidance.
this.userMessageContentReady = false
this.activeToolContexts.clear()
this.didRejectTool = false
Expand Down
53 changes: 53 additions & 0 deletions src/core/task/__tests__/Task.persistence.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2467,5 +2467,58 @@ describe("Task persistence", () => {
// userMessageContent should be cleared on success
expect(task.userMessageContent).toEqual([])
})

const toolResultMessages = (task: Task) =>
task.apiConversationHistory.filter(
(message) =>
message.role === "user" &&
Array.isArray(message.content) &&
message.content.some((block) => block.type === "tool_result"),
)

it("saves, without appending again, results that were added to history but not saved", async () => {
mockSaveApiMessages.mockRejectedValueOnce(new Error("disk full")).mockResolvedValueOnce([])
const task = new Task({
provider: mockProvider,
apiConfiguration: mockApiConfig,
task: "test task",
startTask: false,
})
task.assistantMessageSavedToHistory = true
task.userMessageContent = [{ type: "tool_result", tool_use_id: "tool-1", content: "done" }]

await expect(task.flushPendingToolResultsToHistory()).resolves.toBe(false)
expect(toolResultMessages(task)).toHaveLength(1)

await expect(task.flushPendingToolResultsToHistory()).resolves.toBe(true)

// The retry saved the history as it was; it did not append a second copy of the results.
expect(toolResultMessages(task)).toHaveLength(1)
expect(task.userMessageContent).toEqual([])
expect(mockSaveApiMessages).toHaveBeenCalledTimes(2)
})

it("keeps results that never reached history so a later flush can still add them", async () => {
mockSaveApiMessages.mockResolvedValue([])
const task = new Task({
provider: mockProvider,
apiConfiguration: mockApiConfig,
task: "test task",
startTask: false,
})
task.userMessageContent = [{ type: "tool_result", tool_use_id: "tool-1", content: "done" }]
vi.spyOn(task, "waitForCurrentAssistantMessagePersistence")
.mockResolvedValueOnce(false)
.mockResolvedValue(true)

await expect(task.flushPendingToolResultsToHistory()).resolves.toBe(false)
expect(toolResultMessages(task)).toHaveLength(0)
expect(task.userMessageContent).toHaveLength(1)

await expect(task.flushPendingToolResultsToHistory()).resolves.toBe(true)

expect(toolResultMessages(task)).toHaveLength(1)
expect(task.userMessageContent).toEqual([])
})
})
})
32 changes: 19 additions & 13 deletions src/core/webview/ClineProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4042,30 +4042,36 @@
// is already added to apiConversationHistory by the normal flow in
// recursivelyMakeClineRequests BEFORE tools start executing. We only need to
// flush the pending user message with tool_results.
let pendingResultsPersisted = false
try {
const flushSuccess = await parent.flushPendingToolResultsToHistory()
pendingResultsPersisted = await parent.flushPendingToolResultsToHistory()

if (!flushSuccess) {
if (!pendingResultsPersisted) {
console.warn(`[delegateParentAndOpenChild] Flush failed for parent ${parentTaskId}, retrying...`)
const retrySuccess = await parent.retrySaveApiConversationHistory()

if (!retrySuccess) {
console.error(
`[delegateParentAndOpenChild] CRITICAL: Parent ${parentTaskId} API history not persisted to disk. Child return may produce stale state.`,
)
vscode.window.showWarningMessage(
"Warning: Parent task state could not be saved. The parent task may lose recent context when resumed.",
)
}
await parent.retrySaveApiConversationHistory()
// Saving the existing history does not add results that never reached it, so flush again.
pendingResultsPersisted = await parent.flushPendingToolResultsToHistory()
}
} catch (error) {
this.log(
`[delegateParentAndOpenChild] Error flushing pending tool results (non-fatal): ${
`[delegateParentAndOpenChild] Error flushing pending tool results: ${
error instanceof Error ? error.message : String(error)
}`,
)
}

if (!pendingResultsPersisted) {
// Resuming this parent later would send a tool_use without its tool_result (a provider 400).
// Nothing has been disposed or created yet: the parent stays open with its pending results in
// memory, so the same delegation can simply be retried.
console.error(
`[delegateParentAndOpenChild] CRITICAL: Pending tool results of parent ${parentTaskId} could not be saved.`,

Check warning on line 4068 in src/core/webview/ClineProvider.ts

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

src/core/webview/ClineProvider.ts:4068: Survived StringLiteral mutant (replacement: ``). See the job summary for the complete list and resolution guidance.
)
throw new Error(
`Could not start the subtask: the parent task's pending tool results could not be saved to disk. The parent task stays open and nothing was delegated. Check disk space and permissions, then try again.`,
)
}

// 3) Enforce single-open invariant by closing/disposing the parent first
// This ensures we never have >1 tasks open at any time during delegation.
// Await abort completion to ensure clean disposal and prevent unhandled rejections.
Expand Down
Loading