From 6b00637d199f5197d1c3041facf86f7a32ef8a4a Mon Sep 17 00:00:00 2001 From: alienfacepalm <9330288+alienfacepalm@users.noreply.github.com> Date: Tue, 10 Nov 2026 01:17:21 +0000 Subject: [PATCH] fix(agent-events): deduplicate tool call events after watchdog force resends When the stream watchdog detects a stall and forces a resend with `local: { force: true }`, the Cursor SDK may replay tool-call-started events with the same callId. OpenCode v2 tracks which tool call IDs have been received and rejects duplicates, throwing: `Error: Duplicate tool call: {id}` This was observed in long agentic sessions (15+ minutes) with OpenCode v2.2.6, always right after bash/PowerShell tool calls when the watchdog triggered a force resend. Root cause: OpenCode v2's publish-llm-event handler (packages/core/src/session/runner/publish-llm-event.ts:479) maintains a per-turn registry of tool calls and explicitly rejects duplicates: ```typescript if (tool.called) return yield* Effect.die(new Error(`Duplicate tool call: ${event.id}`)) ``` The plugin had no deduplication logic, so replayed events from force resends reached OpenCode and were rejected. Fix: Track emitted tool call IDs in a Set and skip duplicate tool-call-started events. The Set is scoped per turn (local to streamAgentTurn), persists across force resends, and only filters tool-call events (tool-result events pass through unchanged). Testing: - Added test/duplicate-tool-call.test.ts with 3 test cases - All new tests pass (3/3) - All existing tests pass (659/659) - Typecheck and build successful --- CHANGELOG.md | 13 ++ src/provider/agent-events.ts | 22 ++- test/duplicate-tool-call.test.ts | 258 +++++++++++++++++++++++++++++++ 3 files changed, 290 insertions(+), 3 deletions(-) create mode 100644 test/duplicate-tool-call.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index b5f5e3a..02737a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,19 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +### Fixed + +- **Deduplicate tool call events after watchdog force resends to prevent + OpenCode v2 "Duplicate tool call" errors.** When the stream watchdog + detects a stall and forces a resend with `local: { force: true }`, the + Cursor SDK may replay `tool-call-started` events with the same `callId`. + OpenCode v2 tracks which tool call IDs have been received and rejects + duplicates (`Error: Duplicate tool call: {id}`). The plugin now tracks + emitted tool call IDs per turn and skips replayed duplicates while still + allowing their corresponding tool-result events. This fixes long agentic + sessions being interrupted after bash/shell tool calls when the watchdog + triggers a force resend. + ## [0.10.2] — 2026-10-28 Patch release: a configured `thinking` level now reaches models that read diff --git a/src/provider/agent-events.ts b/src/provider/agent-events.ts index ee3350d..9766fa2 100644 --- a/src/provider/agent-events.ts +++ b/src/provider/agent-events.ts @@ -206,6 +206,12 @@ export async function* streamAgentTurn( // culprit and lets armWatchdog pick the larger budget while a tool runs. const openTools = new Map(); + // Track which tool-call IDs have been emitted to deduplicate replayed events. + // When the watchdog cancels and restarts a run with force, the SDK may replay + // tool-call-started events with the same callId. OpenCode v2 rejects duplicate + // tool-call events (throws "Duplicate tool call: {id}"), so we filter them here. + const emittedToolCalls = new Set(); + const push = (event: CursorEvent) => { anyEvent = true; queue.push(event); @@ -253,17 +259,27 @@ export async function* streamAgentTurn( input: update.toolCall?.args ?? {}, }); break; - case "tool-call-started": + case "tool-call-started": { + // Deduplicate: skip tool-call events we've already emitted. When the + // watchdog cancels and restarts a run with force, the SDK may replay + // tool-call-started with the same callId. OpenCode v2 rejects duplicates. + const callId = String(update.callId); + if (emittedToolCalls.has(callId)) { + if (debug) pluginLog("debug", "skipping duplicate tool-call", { callId }); + break; + } + emittedToolCalls.add(callId); // Track the open call BEFORE push() re-arms the watchdog with the // larger tool budget. - openTools.set(String(update.callId), toolDisplayName(update.toolCall)); + openTools.set(callId, toolDisplayName(update.toolCall)); push({ type: "tool-call", - id: String(update.callId), + id: callId, name: toolDisplayName(update.toolCall), input: update.toolCall?.args ?? {}, }); break; + } case "tool-call-completed": { openTools.delete(String(update.callId)); const tool = update.toolCall ?? {}; diff --git a/test/duplicate-tool-call.test.ts b/test/duplicate-tool-call.test.ts new file mode 100644 index 0000000..b2829be --- /dev/null +++ b/test/duplicate-tool-call.test.ts @@ -0,0 +1,258 @@ +import { describe, it, expect } from "vitest"; +import { streamAgentTurn, type CursorEvent } from "../src/provider/agent-events.js"; +import type { AgentLike, AgentRunLike, AgentSendOptions } from "../src/provider/agent-backend.js"; + +/** + * Test that duplicate tool-call events (same ID) are filtered out. + * This reproduces the scenario where the watchdog forces a resend and the SDK + * replays tool-call-started events with the same callId. + */ +describe("duplicate tool call filtering", () => { + it("deduplicates tool-call events with the same ID", async () => { + let onDelta: ((event: { update: any }) => void) | undefined; + + const mockRun: AgentRunLike = { + cancel: async () => {}, + wait: async () => { + // Emit events before resolving + if (onDelta) { + // First tool call + onDelta({ + update: { + type: "tool-call-started", + callId: "toolu_01RaUwXi2EGFB3VKvHrxM3zE", + toolCall: { type: "shell", args: { command: "ls" } }, + }, + }); + // Duplicate tool call with same ID (e.g., after force resend) + onDelta({ + update: { + type: "tool-call-started", + callId: "toolu_01RaUwXi2EGFB3VKvHrxM3zE", + toolCall: { type: "shell", args: { command: "ls" } }, + }, + }); + // Tool result + onDelta({ + update: { + type: "tool-call-completed", + callId: "toolu_01RaUwXi2EGFB3VKvHrxM3zE", + toolCall: { + type: "shell", + result: { status: "success", value: { stdout: "file.txt" } }, + }, + }, + }); + // Turn ended + onDelta({ + update: { + type: "turn-ended", + usage: { + inputTokens: 100, + outputTokens: 50, + cacheReadTokens: 0, + cacheWriteTokens: 0, + }, + }, + }); + } + return { status: "finished" as const, result: "done" }; + }, + }; + + const mockAgent: AgentLike = { + agentId: "test-agent", + send: async (_message, options: AgentSendOptions) => { + onDelta = options.onDelta; + return mockRun; + }, + close: async () => {}, + }; + + const events: CursorEvent[] = []; + const stream = streamAgentTurn( + mockAgent, + { text: "run ls" }, + { mode: "agent", idempotencyKey: "test" }, + ); + + for await (const event of stream) { + events.push(event); + } + + // Should have exactly ONE tool-call event (duplicate filtered out) + const toolCallEvents = events.filter((e) => e.type === "tool-call"); + expect(toolCallEvents).toHaveLength(1); + expect(toolCallEvents[0]?.id).toBe("toolu_01RaUwXi2EGFB3VKvHrxM3zE"); + + // Should still have the tool-result + const toolResultEvents = events.filter((e) => e.type === "tool-result"); + expect(toolResultEvents).toHaveLength(1); + expect(toolResultEvents[0]?.id).toBe("toolu_01RaUwXi2EGFB3VKvHrxM3zE"); + }); + + it("allows different tool call IDs", async () => { + let onDelta: ((event: { update: any }) => void) | undefined; + + const mockRun: AgentRunLike = { + cancel: async () => {}, + wait: async () => { + if (onDelta) { + // First tool call + onDelta({ + update: { + type: "tool-call-started", + callId: "toolu_01AAA", + toolCall: { type: "shell", args: { command: "ls" } }, + }, + }); + // Second tool call with different ID + onDelta({ + update: { + type: "tool-call-started", + callId: "toolu_01BBB", + toolCall: { type: "read", args: { path: "file.txt" } }, + }, + }); + // Results + onDelta({ + update: { + type: "tool-call-completed", + callId: "toolu_01AAA", + toolCall: { + type: "shell", + result: { status: "success", value: { stdout: "file.txt" } }, + }, + }, + }); + onDelta({ + update: { + type: "tool-call-completed", + callId: "toolu_01BBB", + toolCall: { + type: "read", + result: { status: "success", value: { content: "hello" } }, + }, + }, + }); + onDelta({ + update: { + type: "turn-ended", + usage: { + inputTokens: 100, + outputTokens: 50, + cacheReadTokens: 0, + cacheWriteTokens: 0, + }, + }, + }); + } + return { status: "finished" as const, result: "done" }; + }, + }; + + const mockAgent: AgentLike = { + agentId: "test-agent-2", + send: async (_message, options: AgentSendOptions) => { + onDelta = options.onDelta; + return mockRun; + }, + close: async () => {}, + }; + + const events: CursorEvent[] = []; + const stream = streamAgentTurn( + mockAgent, + { text: "list and read" }, + { mode: "agent", idempotencyKey: "test" }, + ); + + for await (const event of stream) { + events.push(event); + } + + // Should have TWO distinct tool-call events + const toolCallEvents = events.filter((e) => e.type === "tool-call"); + expect(toolCallEvents).toHaveLength(2); + expect(toolCallEvents.map((e) => e.id)).toEqual(["toolu_01AAA", "toolu_01BBB"]); + + // Should have both tool-results + const toolResultEvents = events.filter((e) => e.type === "tool-result"); + expect(toolResultEvents).toHaveLength(2); + }); + + it("deduplicates multiple duplicates of the same ID", async () => { + let onDelta: ((event: { update: any }) => void) | undefined; + + const mockRun: AgentRunLike = { + cancel: async () => {}, + wait: async () => { + if (onDelta) { + const callId = "toolu_01XYZ"; + // Emit the same tool call 3 times (simulating multiple force resends) + for (let i = 0; i < 3; i++) { + onDelta({ + update: { + type: "tool-call-started", + callId, + toolCall: { type: "shell", args: { command: "echo test" } }, + }, + }); + } + // Result + onDelta({ + update: { + type: "tool-call-completed", + callId, + toolCall: { + type: "shell", + result: { status: "success", value: { stdout: "test" } }, + }, + }, + }); + onDelta({ + update: { + type: "turn-ended", + usage: { + inputTokens: 100, + outputTokens: 50, + cacheReadTokens: 0, + cacheWriteTokens: 0, + }, + }, + }); + } + return { status: "finished" as const, result: "done" }; + }, + }; + + const mockAgent: AgentLike = { + agentId: "test-agent-3", + send: async (_message, options: AgentSendOptions) => { + onDelta = options.onDelta; + return mockRun; + }, + close: async () => {}, + }; + + const events: CursorEvent[] = []; + const stream = streamAgentTurn( + mockAgent, + { text: "echo test" }, + { mode: "agent", idempotencyKey: "test" }, + ); + + for await (const event of stream) { + events.push(event); + } + + // Should have exactly ONE tool-call event (all duplicates filtered) + const toolCallEvents = events.filter((e) => e.type === "tool-call"); + expect(toolCallEvents).toHaveLength(1); + expect(toolCallEvents[0]?.id).toBe("toolu_01XYZ"); + + // Should still have the tool-result + const toolResultEvents = events.filter((e) => e.type === "tool-result"); + expect(toolResultEvents).toHaveLength(1); + }); +});