Skip to content
This repository was archived by the owner on May 15, 2026. It is now read-only.

Commit 42f2b21

Browse files
committed
fix: prevent duplicate tool_result in subtask delegation (EXT-665)
- Check ALL user messages in parentApiMessages for existing tool_result with the same tool_use_id before appending a new one - Previously only checked the last message, missing duplicates in earlier messages - Log warning when skipping duplicate tool_result for debugging - Add tests for duplicate detection and normal case scenarios
1 parent a44842f commit 42f2b21

2 files changed

Lines changed: 177 additions & 11 deletions

File tree

src/__tests__/history-resume-delegation.spec.ts

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -541,4 +541,167 @@ describe("History resume delegation - parent metadata transitions", () => {
541541
}),
542542
)
543543
})
544+
545+
it("reopenParentFromDelegation skips duplicate tool_result when one already exists in history (EXT-665)", async () => {
546+
const logSpy = vi.fn()
547+
const provider = {
548+
contextProxy: { globalStorageUri: { fsPath: "/storage" } },
549+
log: logSpy,
550+
getTaskWithId: vi.fn().mockResolvedValue({
551+
historyItem: {
552+
id: "p-dup",
553+
status: "delegated",
554+
awaitingChildId: "c-dup",
555+
childIds: [],
556+
ts: 100,
557+
task: "Parent with existing tool_result",
558+
tokensIn: 0,
559+
tokensOut: 0,
560+
totalCost: 0,
561+
},
562+
}),
563+
emit: vi.fn(),
564+
getCurrentTask: vi.fn(() => ({ taskId: "c-dup" })),
565+
removeClineFromStack: vi.fn().mockResolvedValue(undefined),
566+
createTaskWithHistoryItem: vi.fn().mockResolvedValue({
567+
taskId: "p-dup",
568+
resumeAfterDelegation: vi.fn().mockResolvedValue(undefined),
569+
overwriteClineMessages: vi.fn().mockResolvedValue(undefined),
570+
overwriteApiConversationHistory: vi.fn().mockResolvedValue(undefined),
571+
}),
572+
updateTaskHistory: vi.fn().mockResolvedValue([]),
573+
} as unknown as ClineProvider
574+
575+
// Simulate the bug scenario: API history already has a tool_result for the same tool_use_id
576+
// This can happen if the tool was interrupted and the result was already added
577+
const existingToolUseId = "toolu_01SnH3c7xgVdfLc2md4Fk6yB"
578+
const existingUiMessages = [{ type: "ask", ask: "tool", text: "new_task request", ts: 50 }]
579+
const existingApiMessages = [
580+
{ role: "user", content: [{ type: "text", text: "Create a subtask" }], ts: 40 },
581+
{
582+
role: "assistant",
583+
content: [
584+
{
585+
type: "tool_use",
586+
name: "new_task",
587+
id: existingToolUseId,
588+
input: { mode: "code", message: "Do something" },
589+
},
590+
],
591+
ts: 50,
592+
},
593+
// This tool_result already exists from a previous operation (e.g., interruption)
594+
{
595+
role: "user",
596+
content: [
597+
{
598+
type: "tool_result",
599+
tool_use_id: existingToolUseId,
600+
content: "Tool execution was interrupted before completion.",
601+
},
602+
],
603+
ts: 60,
604+
},
605+
]
606+
607+
vi.mocked(readTaskMessages).mockResolvedValue(existingUiMessages as any)
608+
vi.mocked(readApiMessages).mockResolvedValue(existingApiMessages as any)
609+
610+
await (ClineProvider.prototype as any).reopenParentFromDelegation.call(provider, {
611+
parentTaskId: "p-dup",
612+
childTaskId: "c-dup",
613+
completionResultSummary: "Subtask completed successfully",
614+
})
615+
616+
// Verify that we logged the skip message
617+
expect(logSpy).toHaveBeenCalledWith(
618+
expect.stringContaining(`Skipping duplicate tool_result for tool_use_id: ${existingToolUseId}`),
619+
)
620+
621+
// Verify API history was saved WITHOUT a new tool_result (should still have exactly 3 messages)
622+
const apiCall = vi.mocked(saveApiMessages).mock.calls[0][0]
623+
expect(apiCall.messages).toHaveLength(3) // Original 3 messages, no new one added
624+
625+
// Count tool_result blocks - should only be 1 (the existing one)
626+
const toolResultBlocks = apiCall.messages.flatMap((msg: any) =>
627+
msg.role === "user" && Array.isArray(msg.content)
628+
? msg.content.filter((block: any) => block.type === "tool_result")
629+
: [],
630+
)
631+
expect(toolResultBlocks).toHaveLength(1)
632+
expect(toolResultBlocks[0].tool_use_id).toBe(existingToolUseId)
633+
})
634+
635+
it("reopenParentFromDelegation adds tool_result when none exists for the tool_use_id (normal case)", async () => {
636+
const logSpy = vi.fn()
637+
const provider = {
638+
contextProxy: { globalStorageUri: { fsPath: "/storage" } },
639+
log: logSpy,
640+
getTaskWithId: vi.fn().mockResolvedValue({
641+
historyItem: {
642+
id: "p-new",
643+
status: "delegated",
644+
awaitingChildId: "c-new",
645+
childIds: [],
646+
ts: 100,
647+
task: "Parent without tool_result yet",
648+
tokensIn: 0,
649+
tokensOut: 0,
650+
totalCost: 0,
651+
},
652+
}),
653+
emit: vi.fn(),
654+
getCurrentTask: vi.fn(() => ({ taskId: "c-new" })),
655+
removeClineFromStack: vi.fn().mockResolvedValue(undefined),
656+
createTaskWithHistoryItem: vi.fn().mockResolvedValue({
657+
taskId: "p-new",
658+
resumeAfterDelegation: vi.fn().mockResolvedValue(undefined),
659+
overwriteClineMessages: vi.fn().mockResolvedValue(undefined),
660+
overwriteApiConversationHistory: vi.fn().mockResolvedValue(undefined),
661+
}),
662+
updateTaskHistory: vi.fn().mockResolvedValue([]),
663+
} as unknown as ClineProvider
664+
665+
// Normal case: API history has tool_use but no tool_result yet
666+
const toolUseId = "toolu_normal123"
667+
const existingUiMessages = [{ type: "ask", ask: "tool", text: "new_task request", ts: 50 }]
668+
const existingApiMessages = [
669+
{ role: "user", content: [{ type: "text", text: "Create a subtask" }], ts: 40 },
670+
{
671+
role: "assistant",
672+
content: [
673+
{
674+
type: "tool_use",
675+
name: "new_task",
676+
id: toolUseId,
677+
input: { mode: "code", message: "Do something" },
678+
},
679+
],
680+
ts: 50,
681+
},
682+
]
683+
684+
vi.mocked(readTaskMessages).mockResolvedValue(existingUiMessages as any)
685+
vi.mocked(readApiMessages).mockResolvedValue(existingApiMessages as any)
686+
687+
await (ClineProvider.prototype as any).reopenParentFromDelegation.call(provider, {
688+
parentTaskId: "p-new",
689+
childTaskId: "c-new",
690+
completionResultSummary: "Subtask completed successfully",
691+
})
692+
693+
// Verify that we did NOT log a skip message
694+
expect(logSpy).not.toHaveBeenCalledWith(expect.stringContaining("Skipping duplicate tool_result"))
695+
696+
// Verify API history was saved WITH a new tool_result (should have 3 messages now)
697+
const apiCall = vi.mocked(saveApiMessages).mock.calls[0][0]
698+
expect(apiCall.messages).toHaveLength(3)
699+
700+
// The last message should be the new tool_result
701+
const lastMsg = apiCall.messages[2]
702+
expect(lastMsg.role).toBe("user")
703+
expect((lastMsg.content[0] as any).type).toBe("tool_result")
704+
expect((lastMsg.content[0] as any).tool_use_id).toBe(toolUseId)
705+
expect((lastMsg.content[0] as any).content).toContain("Subtask c-new completed")
706+
})
544707
})

src/core/webview/ClineProvider.ts

Lines changed: 14 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -3344,22 +3344,25 @@ export class ClineProvider
33443344
// inject a matching tool_result for the Anthropic message contract:
33453345
// user → assistant (tool_use) → user (tool_result)
33463346
if (toolUseId) {
3347-
// Check if the last message is already a user message with a tool_result for this tool_use_id
3348-
// (in case this is a retry or the history was already updated)
3349-
const lastMsg = parentApiMessages[parentApiMessages.length - 1]
3347+
// Check ALL user messages for an existing tool_result with this tool_use_id
3348+
// (not just the last message, to prevent duplicate tool_results - EXT-665)
33503349
let alreadyHasToolResult = false
3351-
if (lastMsg?.role === "user" && Array.isArray(lastMsg.content)) {
3352-
for (const block of lastMsg.content) {
3353-
if (block.type === "tool_result" && block.tool_use_id === toolUseId) {
3354-
// Update the existing tool_result content
3355-
block.content = `Subtask ${childTaskId} completed.\n\nResult:\n${completionResultSummary}`
3356-
alreadyHasToolResult = true
3357-
break
3350+
for (const msg of parentApiMessages) {
3351+
if (msg.role === "user" && Array.isArray(msg.content)) {
3352+
for (const block of msg.content) {
3353+
if (block.type === "tool_result" && block.tool_use_id === toolUseId) {
3354+
alreadyHasToolResult = true
3355+
this.log(
3356+
`[reopenParentFromDelegation] Skipping duplicate tool_result for tool_use_id: ${toolUseId}`,
3357+
)
3358+
break
3359+
}
33583360
}
3361+
if (alreadyHasToolResult) break
33593362
}
33603363
}
33613364

3362-
// If no existing tool_result found, create a NEW user message with the tool_result
3365+
// Only create a NEW user message with the tool_result if none exists
33633366
if (!alreadyHasToolResult) {
33643367
parentApiMessages.push({
33653368
role: "user",

0 commit comments

Comments
 (0)