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
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
22 changes: 19 additions & 3 deletions src/provider/agent-events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,12 @@ export async function* streamAgentTurn(
// culprit and lets armWatchdog pick the larger budget while a tool runs.
const openTools = new Map<string, string>();

// 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<string>();

const push = (event: CursorEvent) => {
anyEvent = true;
queue.push(event);
Expand Down Expand Up @@ -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 ?? {};
Expand Down
258 changes: 258 additions & 0 deletions test/duplicate-tool-call.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});