From 2a72ec5a0a73e43cf1491b2e6422185a7014ead4 Mon Sep 17 00:00:00 2001 From: Nomadcxx Date: Fri, 2 Oct 2026 12:53:42 +1000 Subject: [PATCH 1/2] fix(bridge): accept JSON array envelopes for parallel task dispatch The bridge detector returned on the first parsed envelope and the response builders hardcoded a single tool call at index 0, so fan-out requests dispatched one task and silently dropped the rest. Accept a JSON array of envelopes (all-or-nothing validation), emit all calls with sequential stream indices, and document the array form in the task bridge prompt. Only [{ opens a candidate so markdown that starts with [ still streams through. --- src/plugin.ts | 52 +++++++-------- src/provider/boundary.ts | 12 ++-- src/proxy/bridge-json.ts | 55 +++++++++++----- src/proxy/tool-loop.ts | 19 +++--- .../opencode-loop.integration.test.ts | 30 +++++++++ tests/unit/proxy/bridge-json.test.ts | 65 +++++++++++++++++-- 6 files changed, 171 insertions(+), 62 deletions(-) diff --git a/src/plugin.ts b/src/plugin.ts index 8c6db74..f4ba625 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -34,7 +34,7 @@ import { extractImagesFromMessages, type SdkImageLike } from "./proxy/image-extr import { applyBridgeJsonPrompt, BridgeJsonStreamDetector, - extractBridgeToolCallFromStreamOutput, + extractBridgeToolCallsFromStreamOutput, isBridgeJsonEnabled, } from "./proxy/bridge-json.js"; import { buildIncrementalPrompt, type ProxyMessage } from "./proxy/incremental-prompt.js"; @@ -1458,18 +1458,17 @@ export async function ensureCursorProxyServer(workspaceDirectory: string, toolRo } const completion = extractCompletionFromStream(stdout); - const bridgeToolCall = bridgeJsonEnabled - ? extractBridgeToolCallFromStreamOutput(stdout, allowedToolNames, toolSchemaMap.get("write")) + const bridgeToolCalls = bridgeJsonEnabled + ? extractBridgeToolCallsFromStreamOutput(stdout, allowedToolNames, toolSchemaMap.get("write")) : null; - if (bridgeToolCall) { - const toolCall = bridgeToolCall; - log.debug("Intercepted bridge JSON tool call (non-stream)", { - name: toolCall.function.name, - callId: toolCall.id, + if (bridgeToolCalls) { + log.debug("Intercepted bridge JSON tool calls (non-stream)", { + names: bridgeToolCalls.map((toolCall) => toolCall.function.name), + callIds: bridgeToolCalls.map((toolCall) => toolCall.id), }); const payload = boundaryContext.run( "createNonStreamBridgeToolCallResponse", - (boundary) => boundary.createNonStreamToolCallResponse(meta, toolCall), + (boundary) => boundary.createNonStreamToolCallResponse(meta, bridgeToolCalls), ); return new Response(JSON.stringify(payload), { status: 200, @@ -1548,15 +1547,15 @@ export async function ensureCursorProxyServer(workspaceDirectory: string, toolRo const bridgeDetector = bridgeJsonEnabled ? new BridgeJsonStreamDetector(allowedToolNames, toolSchemaMap.get("write")) : null; - const emitToolCallAndTerminate = (toolCall: OpenAiToolCall) => { + const emitToolCallAndTerminate = (toolCalls: OpenAiToolCall | OpenAiToolCall[]) => { log.debug("Intercepted OpenCode tool call (stream)", { - name: toolCall.function.name, - callId: toolCall.id, + names: [toolCalls].flat().map((toolCall) => toolCall.function.name), + callIds: [toolCalls].flat().map((toolCall) => toolCall.id), }); const streamChunks = boundaryContext.run( "createStreamToolCallChunks", (boundary) => - boundary.createStreamToolCallChunks({ id, created, model }, toolCall), + boundary.createStreamToolCallChunks({ id, created, model }, toolCalls), ); for (const chunk of streamChunks) { enqueueSse(`data: ${JSON.stringify(chunk)}\n\n`); @@ -1596,7 +1595,7 @@ export async function ensureCursorProxyServer(workspaceDirectory: string, toolRo } const decision = bridgeDetector.push(event); if (decision.action === "tool_call") { - emitToolCallAndTerminate(decision.toolCall); + emitToolCallAndTerminate(decision.toolCalls); return true; } if (decision.action === "buffer") { @@ -2115,18 +2114,17 @@ export async function ensureCursorProxyServer(workspaceDirectory: string, toolRo } const completion = extractCompletionFromStream(stdout); - const bridgeToolCall = bridgeJsonEnabled - ? extractBridgeToolCallFromStreamOutput(stdout, allowedToolNames, toolSchemaMap.get("write")) + const bridgeToolCalls = bridgeJsonEnabled + ? extractBridgeToolCallsFromStreamOutput(stdout, allowedToolNames, toolSchemaMap.get("write")) : null; - if (bridgeToolCall) { - const toolCall = bridgeToolCall; - log.debug("Intercepted bridge JSON tool call (non-stream)", { - name: toolCall.function.name, - callId: toolCall.id, + if (bridgeToolCalls) { + log.debug("Intercepted bridge JSON tool calls (non-stream)", { + names: bridgeToolCalls.map((toolCall) => toolCall.function.name), + callIds: bridgeToolCalls.map((toolCall) => toolCall.id), }); const payload = boundaryContext.run( "createNonStreamBridgeToolCallResponse", - (boundary) => boundary.createNonStreamToolCallResponse(meta, toolCall), + (boundary) => boundary.createNonStreamToolCallResponse(meta, bridgeToolCalls), ); res.writeHead(200, { "Content-Type": "application/json" }); res.end(JSON.stringify(payload)); @@ -2225,18 +2223,18 @@ export async function ensureCursorProxyServer(workspaceDirectory: string, toolRo streamTerminated = true; res.end(); }); - const emitToolCallAndTerminate = (toolCall: OpenAiToolCall) => { + const emitToolCallAndTerminate = (toolCalls: OpenAiToolCall | OpenAiToolCall[]) => { if (streamTerminated || res.writableEnded) { return; } log.debug("Intercepted OpenCode tool call (stream)", { - name: toolCall.function.name, - callId: toolCall.id, + names: [toolCalls].flat().map((toolCall) => toolCall.function.name), + callIds: [toolCalls].flat().map((toolCall) => toolCall.id), }); const streamChunks = boundaryContext.run( "createStreamToolCallChunks", (boundary) => - boundary.createStreamToolCallChunks({ id, created, model }, toolCall), + boundary.createStreamToolCallChunks({ id, created, model }, toolCalls), ); for (const chunk of streamChunks) { writeSse(`data: ${JSON.stringify(chunk)}\n\n`); @@ -2292,7 +2290,7 @@ export async function ensureCursorProxyServer(workspaceDirectory: string, toolRo } const decision = bridgeDetector.push(event); if (decision.action === "tool_call") { - emitToolCallAndTerminate(decision.toolCall); + emitToolCallAndTerminate(decision.toolCalls); return true; } if (decision.action === "buffer") { diff --git a/src/provider/boundary.ts b/src/provider/boundary.ts index 1ba5f9e..156bb76 100644 --- a/src/provider/boundary.ts +++ b/src/provider/boundary.ts @@ -160,8 +160,8 @@ export interface ProviderBoundary { allowedToolNames: Set, toolLoopMode: ToolLoopMode, ): ToolCallExtractionResult; - createNonStreamToolCallResponse(meta: ToolLoopMeta, toolCall: OpenAiToolCall): any; - createStreamToolCallChunks(meta: ToolLoopMeta, toolCall: OpenAiToolCall): Array; + createNonStreamToolCallResponse(meta: ToolLoopMeta, toolCalls: OpenAiToolCall | OpenAiToolCall[]): any; + createStreamToolCallChunks(meta: ToolLoopMeta, toolCalls: OpenAiToolCall | OpenAiToolCall[]): Array; } export function parseProviderBoundaryMode( @@ -276,12 +276,12 @@ function createSharedBoundary( return extractOpenAiToolCall(event, allowedToolNames); }, - createNonStreamToolCallResponse(meta, toolCall) { - return createToolCallCompletionResponse(meta, toolCall); + createNonStreamToolCallResponse(meta, toolCalls) { + return createToolCallCompletionResponse(meta, toolCalls); }, - createStreamToolCallChunks(meta, toolCall) { - return createToolCallStreamChunks(meta, toolCall); + createStreamToolCallChunks(meta, toolCalls) { + return createToolCallStreamChunks(meta, toolCalls); }, }; } diff --git a/src/proxy/bridge-json.ts b/src/proxy/bridge-json.ts index 3990b15..217b1b1 100644 --- a/src/proxy/bridge-json.ts +++ b/src/proxy/bridge-json.ts @@ -20,6 +20,7 @@ const TASK_BRIDGE_JSON_CONTEXT = `SYSTEM: OpenCode Task bridge mode is active. For Task only, the exact envelope below overrides the earlier generic "standard OpenAI tool_call" instruction. Do not add id, type, or function fields, and do not stringify arguments. OpenCode owns the task tool. Do not invoke Cursor's built-in Task tool; it uses a different subagent list. To call OpenCode's task tool, respond with exactly one JSON object and no prose: {"name":"task","arguments":{"description":"3-5 words","prompt":"task details","subagent_type":"one name listed in the OpenCode task description"}} +To dispatch several tasks in parallel, respond instead with exactly one JSON array of these objects and no prose: [{"name":"task","arguments":{...}},{"name":"task","arguments":{...}}] Use this only when delegating through OpenCode. Otherwise answer normally.`; type BridgePromptOptions = { @@ -30,7 +31,7 @@ type BridgePromptOptions = { export type BridgeStreamDecision = | { action: "buffer" } | { action: "passthrough"; text?: string } - | { action: "tool_call"; toolCall: OpenAiToolCall }; + | { action: "tool_call"; toolCalls: OpenAiToolCall[] }; export class BridgeJsonStreamDetector { private state: "undecided" | "candidate" | "passthrough" = "undecided"; @@ -59,10 +60,11 @@ export class BridgeJsonStreamDetector { if (this.state === "undecided") { const meaningful = this.buffer.trimStart(); - if (!meaningful || meaningful === "`" || meaningful === "``") { + if (!meaningful || /^(`{1,2}|\[\s*)$/.test(meaningful)) { return { action: "buffer" }; } - if (meaningful.startsWith("{") || meaningful.startsWith("```")) { + // `[` alone is common prose (markdown links); only `[{` starts an envelope array. + if (/^(\{|```|\[\s*\{)/.test(meaningful)) { this.state = "candidate"; } else { const withheld = this.buffer; @@ -88,15 +90,15 @@ export class BridgeJsonStreamDetector { // ponytail: bridge responses are small; reparse the accumulated candidate. // If envelopes become large, replace this O(n²) path with an incremental parser. - const toolCall = extractBridgeToolCallFromText( + const toolCalls = extractBridgeToolCallsFromText( this.buffer, this.allowedToolNames, this.writeSchema, ); - if (toolCall) { + if (toolCalls) { this.buffer = ""; this.state = "passthrough"; - return { action: "tool_call", toolCall }; + return { action: "tool_call", toolCalls }; } if (containsCompleteJson(this.buffer)) { @@ -159,11 +161,11 @@ export function applyBridgeJsonPrompt(prompt: string, options: BridgePromptOptio return result; } -export function extractBridgeToolCallFromText( +export function extractBridgeToolCallsFromText( text: string, allowedToolNames: Set, writeSchema?: unknown, -): OpenAiToolCall | null { +): OpenAiToolCall[] | null { const jsonText = extractStrictJsonText(text); if (!jsonText) { return null; @@ -176,13 +178,31 @@ export function extractBridgeToolCallFromText( return null; } + const id = `call_bridge_${shortHash(jsonText)}`; + if (!Array.isArray(parsed)) { + const toolCall = buildBridgeToolCall(parsed, id, allowedToolNames, writeSchema); + return toolCall ? [toolCall] : null; + } + const toolCalls = parsed.map((entry, i) => + buildBridgeToolCall(entry, `${id}_${i}`, allowedToolNames, writeSchema)); + return toolCalls.length > 0 && toolCalls.every((call): call is OpenAiToolCall => call !== null) + ? toolCalls + : null; +} + +function buildBridgeToolCall( + parsed: unknown, + id: string, + allowedToolNames: Set, + writeSchema: unknown, +): OpenAiToolCall | null { if (!isRecord(parsed) || !isRecord(parsed.arguments)) { return null; } if (parsed.name === "task") { return allowedToolNames.has("task") - ? buildTaskToolCall(jsonText, parsed.arguments) + ? buildTaskToolCall(id, parsed.arguments) : null; } @@ -200,7 +220,7 @@ export function extractBridgeToolCallFromText( } return { - id: `call_bridge_${shortHash(jsonText)}`, + id, type: "function", function: { name: writeToolName, @@ -210,7 +230,7 @@ export function extractBridgeToolCallFromText( } function buildTaskToolCall( - jsonText: string, + id: string, args: Record, ): OpenAiToolCall | null { if ( @@ -224,7 +244,7 @@ function buildTaskToolCall( } return { - id: `call_bridge_${shortHash(jsonText)}`, + id, type: "function", function: { name: "task", @@ -233,11 +253,11 @@ function buildTaskToolCall( }; } -export function extractBridgeToolCallFromStreamOutput( +export function extractBridgeToolCallsFromStreamOutput( output: string, allowedToolNames: Set, writeSchema?: unknown, -): OpenAiToolCall | null { +): OpenAiToolCall[] | null { if (!output) { return null; } @@ -251,7 +271,7 @@ export function extractBridgeToolCallFromStreamOutput( if (isAssistantText(event)) { const decision = detector.push(event); if (decision.action === "tool_call") { - return decision.toolCall; + return decision.toolCalls; } } else if (event.type === "tool_call") { detector.reset(); @@ -290,7 +310,10 @@ function extractStrictJsonText(text: string): string | null { if (!trimmed) { return null; } - if (trimmed.startsWith("{") && trimmed.endsWith("}")) { + if ( + (trimmed.startsWith("{") && trimmed.endsWith("}")) + || (trimmed.startsWith("[") && trimmed.endsWith("]")) + ) { return trimmed; } diff --git a/src/proxy/tool-loop.ts b/src/proxy/tool-loop.ts index e7b4374..8726ef0 100644 --- a/src/proxy/tool-loop.ts +++ b/src/proxy/tool-loop.ts @@ -196,7 +196,10 @@ export function extractOpenAiToolCall( }; } -export function createToolCallCompletionResponse(meta: ToolLoopMeta, toolCall: OpenAiToolCall) { +export function createToolCallCompletionResponse( + meta: ToolLoopMeta, + toolCalls: OpenAiToolCall | OpenAiToolCall[], +) { return { id: meta.id, object: "chat.completion", @@ -208,7 +211,7 @@ export function createToolCallCompletionResponse(meta: ToolLoopMeta, toolCall: O message: { role: "assistant", content: null, - tool_calls: [toolCall], + tool_calls: [toolCalls].flat(), }, finish_reason: "tool_calls", }, @@ -216,7 +219,10 @@ export function createToolCallCompletionResponse(meta: ToolLoopMeta, toolCall: O }; } -export function createToolCallStreamChunks(meta: ToolLoopMeta, toolCall: OpenAiToolCall): Array { +export function createToolCallStreamChunks( + meta: ToolLoopMeta, + toolCalls: OpenAiToolCall | OpenAiToolCall[], +): Array { const toolDelta = { id: meta.id, object: "chat.completion.chunk", @@ -227,12 +233,7 @@ export function createToolCallStreamChunks(meta: ToolLoopMeta, toolCall: OpenAiT index: 0, delta: { role: "assistant", - tool_calls: [ - { - index: 0, - ...toolCall, - }, - ], + tool_calls: [toolCalls].flat().map((toolCall, index) => ({ index, ...toolCall })), }, finish_reason: null, }, diff --git a/tests/integration/opencode-loop.integration.test.ts b/tests/integration/opencode-loop.integration.test.ts index 55c8670..99d9ea9 100644 --- a/tests/integration/opencode-loop.integration.test.ts +++ b/tests/integration/opencode-loop.integration.test.ts @@ -362,6 +362,16 @@ process.stdin.on("end", () => { }, { type: "result", subtype: "success", is_error: false }, ]; + } else if (scenario === "assistant-bridge-task-array") { + const text = JSON.stringify(["explore", "general", "explore"].map((subagent_type) => ({ + name: "task", + arguments: { description: "Parallel probe", prompt: "Report back.", subagent_type }, + }))); + events = [ + { type: "assistant", timestamp_ms: now + 1, message: { role: "assistant", content: [{ type: "text", text: text.slice(0, 50) }] } }, + { type: "assistant", timestamp_ms: now + 2, message: { role: "assistant", content: [{ type: "text", text: text.slice(50) }] } }, + { type: "result", subtype: "success", is_error: false }, + ]; } else if (scenario === "assistant-bridge-task-mixed-thinking") { events = [ { @@ -919,6 +929,26 @@ describe("OpenCode-owned tool loop integration", () => { expect(promptText).not.toContain(["When calling", "the task tool"].join(" ")); }); + it("streams a Task bridge JSON array as parallel tool calls", async () => { + process.env.MOCK_CURSOR_SCENARIO = "assistant-bridge-task-array"; + process.env.MOCK_CURSOR_PROMPT_FILE = ""; + + const response = await requestCompletion(baseURL, { + model: "auto", + stream: true, + tools: [TASK_TOOL], + messages: [{ role: "user", content: "Dispatch three subagents in parallel" }], + }); + + const chunks = parseJsonChunks(parseSseData(await response.text())); + const toolCalls = chunks.flatMap((chunk) => chunk.choices?.[0]?.delta?.tool_calls ?? []); + + expect(toolCalls.map((call: any) => call.index)).toEqual([0, 1, 2]); + expect(toolCalls.map((call: any) => JSON.parse(call.function.arguments).subagent_type)) + .toEqual(["explore", "general", "explore"]); + expect(new Set(toolCalls.map((call: any) => call.id)).size).toBe(3); + }); + it("preserves thinking from mixed assistant events while bridging Task JSON", async () => { process.env.MOCK_CURSOR_SCENARIO = "assistant-bridge-task-mixed-thinking"; process.env.MOCK_CURSOR_PROMPT_FILE = ""; diff --git a/tests/unit/proxy/bridge-json.test.ts b/tests/unit/proxy/bridge-json.test.ts index 2c58114..7c3a1cd 100644 --- a/tests/unit/proxy/bridge-json.test.ts +++ b/tests/unit/proxy/bridge-json.test.ts @@ -2,10 +2,17 @@ import { describe, expect, it } from "bun:test"; import { applyBridgeJsonPrompt, BridgeJsonStreamDetector, - extractBridgeToolCallFromStreamOutput, - extractBridgeToolCallFromText, + extractBridgeToolCallsFromStreamOutput, + extractBridgeToolCallsFromText, isBridgeJsonEnabled, } from "../../../src/proxy/bridge-json.js"; +import { createToolCallStreamChunks } from "../../../src/proxy/tool-loop.js"; + +const extractBridgeToolCallFromText = (...args: Parameters) => + extractBridgeToolCallsFromText(...args)?.[0] ?? null; +const extractBridgeToolCallFromStreamOutput = ( + ...args: Parameters +) => extractBridgeToolCallsFromStreamOutput(...args)?.[0] ?? null; const delta = (text: string) => ({ type: "assistant" as const, @@ -127,6 +134,35 @@ describe("proxy/bridge-json", () => { }); } + it("extracts a JSON array of task envelopes as parallel tool calls", () => { + const second = JSON.parse(TASK_JSON); + second.arguments.subagent_type = "explore"; + const calls = extractBridgeToolCallsFromText( + `[${TASK_JSON}, ${JSON.stringify(second)}, ${TASK_JSON}]`, + new Set(["task"]), + ); + + expect(calls?.map((call) => JSON.parse(call.function.arguments).subagent_type)).toEqual([ + "project-proof", + "explore", + "project-proof", + ]); + expect(new Set(calls?.map((call) => call.id)).size).toBe(3); + + const chunks = createToolCallStreamChunks({ id: "x", created: 0, model: "m" }, calls!); + expect(chunks[0].choices[0].delta.tool_calls.map((call: any) => call.index)).toEqual([0, 1, 2]); + }); + + it("rejects an array when any envelope is invalid or the array is empty", () => { + const invalid = JSON.parse(TASK_JSON); + delete invalid.arguments.prompt; + + expect( + extractBridgeToolCallsFromText(`[${TASK_JSON},${JSON.stringify(invalid)}]`, new Set(["task"])), + ).toBeNull(); + expect(extractBridgeToolCallsFromText("[]", new Set(["task"]))).toBeNull(); + }); + it("extracts a later bridge response from stream-json output after prelude text", () => { const output = [ JSON.stringify({ @@ -315,11 +351,32 @@ describe("proxy/bridge-json", () => { const decision = detector.push(delta('"subagent_type":"project-proof"}}')); expect(decision.action).toBe("tool_call"); if (decision.action === "tool_call") { - expect(decision.toolCall.function.name).toBe("task"); + expect(decision.toolCalls[0].function.name).toBe("task"); } expect(detector.flush()).toBe(""); }); + it("reassembles a split array of Task envelopes", () => { + const detector = new BridgeJsonStreamDetector(new Set(["task"])); + const array = `[${TASK_JSON},${TASK_JSON}]`; + + expect(detector.push(delta("["))).toEqual({ action: "buffer" }); + expect(detector.push(delta(array.slice(1, 40)))).toEqual({ action: "buffer" }); + const decision = detector.push(delta(array.slice(40))); + + expect(decision.action === "tool_call" && decision.toolCalls.length).toBe(2); + }); + + it("passes markdown that starts with a bracket through", () => { + const detector = new BridgeJsonStreamDetector(new Set(["task"])); + + expect(detector.push(delta("["))).toEqual({ action: "buffer" }); + expect(detector.push(delta("docs](https://example.com)"))).toEqual({ + action: "passthrough", + text: "[docs](https://example.com)", + }); + }); + it("passes ordinary text through immediately", () => { const detector = new BridgeJsonStreamDetector(new Set(["task"])); @@ -334,7 +391,7 @@ describe("proxy/bridge-json", () => { expect(decision.action).toBe("tool_call"); if (decision.action === "tool_call") { - expect(JSON.parse(decision.toolCall.function.arguments)).toEqual( + expect(JSON.parse(decision.toolCalls[0].function.arguments)).toEqual( JSON.parse(TASK_JSON).arguments, ); } From 8e733dee00d0c5184449b1363eced03ac0238405 Mon Sep 17 00:00:00 2001 From: Nomadcxx Date: Fri, 2 Oct 2026 13:25:57 +1000 Subject: [PATCH 2/2] fix(bridge): address review on parallel task envelopes - Single prompt rule: one object, or one array for parallel dispatch, with a complete inline array example. - Builders take OpenAiToolCall[] only; native sites wrap their call. - Stream one chunk per tool call, mirroring the upstream wire shape. - Warn-log when an array is dropped to passthrough text. - Random bridge call ids so retried envelopes do not reuse ids. - Tests: direct non-stream array extraction, array completion response, write arrays, retry ids, prompt array line, finish_reason and leak assertions, non-stream integration scenario. --- src/plugin.ts | 22 +++++----- src/provider/boundary.ts | 4 +- src/proxy/bridge-json.ts | 29 +++++++++----- src/proxy/tool-loop.ts | 14 +++---- .../opencode-loop.integration.test.ts | 26 ++++++++++++ tests/unit/provider-boundary.test.ts | 4 +- tests/unit/proxy/bridge-json.test.ts | 40 +++++++++++++++++-- tests/unit/proxy/tool-loop.test.ts | 25 ++++++++++-- 8 files changed, 124 insertions(+), 40 deletions(-) diff --git a/src/plugin.ts b/src/plugin.ts index f4ba625..a2ba8f0 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -1449,7 +1449,7 @@ export async function ensureCursorProxyServer(workspaceDirectory: string, toolRo }); const payload = boundaryContext.run( "createNonStreamToolCallResponse", - (boundary) => boundary.createNonStreamToolCallResponse(meta, toolCall), + (boundary) => boundary.createNonStreamToolCallResponse(meta, [toolCall]), ); return new Response(JSON.stringify(payload), { status: 200, @@ -1547,10 +1547,10 @@ export async function ensureCursorProxyServer(workspaceDirectory: string, toolRo const bridgeDetector = bridgeJsonEnabled ? new BridgeJsonStreamDetector(allowedToolNames, toolSchemaMap.get("write")) : null; - const emitToolCallAndTerminate = (toolCalls: OpenAiToolCall | OpenAiToolCall[]) => { + const emitToolCallAndTerminate = (toolCalls: OpenAiToolCall[]) => { log.debug("Intercepted OpenCode tool call (stream)", { - names: [toolCalls].flat().map((toolCall) => toolCall.function.name), - callIds: [toolCalls].flat().map((toolCall) => toolCall.id), + names: toolCalls.map((toolCall) => toolCall.function.name), + callIds: toolCalls.map((toolCall) => toolCall.id), }); const streamChunks = boundaryContext.run( "createStreamToolCallChunks", @@ -1685,7 +1685,7 @@ export async function ensureCursorProxyServer(workspaceDirectory: string, toolRo enqueueSse(`data: ${JSON.stringify(toolResult)}\n\n`); }, onInterceptedToolCall: (toolCall) => { - emitToolCallAndTerminate(toolCall); + emitToolCallAndTerminate([toolCall]); }, onFallbackToLegacy: (error) => { boundaryContext.activateLegacyFallback("handleToolLoopEvent", error); @@ -1774,7 +1774,7 @@ export async function ensureCursorProxyServer(workspaceDirectory: string, toolRo enqueueSse(`data: ${JSON.stringify(toolResult)}\n\n`); }, onInterceptedToolCall: (toolCall) => { - emitToolCallAndTerminate(toolCall); + emitToolCallAndTerminate([toolCall]); }, onFallbackToLegacy: (error) => { boundaryContext.activateLegacyFallback("handleToolLoopEvent.flush", error); @@ -2106,7 +2106,7 @@ export async function ensureCursorProxyServer(workspaceDirectory: string, toolRo }); const payload = boundaryContext.run( "createNonStreamToolCallResponse", - (boundary) => boundary.createNonStreamToolCallResponse(meta, toolCall), + (boundary) => boundary.createNonStreamToolCallResponse(meta, [toolCall]), ); res.writeHead(200, { "Content-Type": "application/json" }); res.end(JSON.stringify(payload)); @@ -2223,13 +2223,13 @@ export async function ensureCursorProxyServer(workspaceDirectory: string, toolRo streamTerminated = true; res.end(); }); - const emitToolCallAndTerminate = (toolCalls: OpenAiToolCall | OpenAiToolCall[]) => { + const emitToolCallAndTerminate = (toolCalls: OpenAiToolCall[]) => { if (streamTerminated || res.writableEnded) { return; } log.debug("Intercepted OpenCode tool call (stream)", { - names: [toolCalls].flat().map((toolCall) => toolCall.function.name), - callIds: [toolCalls].flat().map((toolCall) => toolCall.id), + names: toolCalls.map((toolCall) => toolCall.function.name), + callIds: toolCalls.map((toolCall) => toolCall.id), }); const streamChunks = boundaryContext.run( "createStreamToolCallChunks", @@ -2363,7 +2363,7 @@ export async function ensureCursorProxyServer(workspaceDirectory: string, toolRo writeSse(`data: ${JSON.stringify(toolResult)}\n\n`); }, onInterceptedToolCall: (toolCall) => { - emitToolCallAndTerminate(toolCall); + emitToolCallAndTerminate([toolCall]); }, onFallbackToLegacy: (error) => { boundaryContext.activateLegacyFallback("handleToolLoopEvent", error); diff --git a/src/provider/boundary.ts b/src/provider/boundary.ts index 156bb76..2f2dd00 100644 --- a/src/provider/boundary.ts +++ b/src/provider/boundary.ts @@ -160,8 +160,8 @@ export interface ProviderBoundary { allowedToolNames: Set, toolLoopMode: ToolLoopMode, ): ToolCallExtractionResult; - createNonStreamToolCallResponse(meta: ToolLoopMeta, toolCalls: OpenAiToolCall | OpenAiToolCall[]): any; - createStreamToolCallChunks(meta: ToolLoopMeta, toolCalls: OpenAiToolCall | OpenAiToolCall[]): Array; + createNonStreamToolCallResponse(meta: ToolLoopMeta, toolCalls: OpenAiToolCall[]): any; + createStreamToolCallChunks(meta: ToolLoopMeta, toolCalls: OpenAiToolCall[]): Array; } export function parseProviderBoundaryMode( diff --git a/src/proxy/bridge-json.ts b/src/proxy/bridge-json.ts index 217b1b1..aab222f 100644 --- a/src/proxy/bridge-json.ts +++ b/src/proxy/bridge-json.ts @@ -1,4 +1,4 @@ -import { createHash } from "node:crypto"; +import { randomUUID } from "node:crypto"; import { parseStreamJsonLine } from "../streaming/parser.js"; import { MixedDeltaTracker } from "../streaming/delta-tracker.js"; import { @@ -8,6 +8,9 @@ import { type StreamJsonAssistantEvent, } from "../streaming/types.js"; import type { OpenAiToolCall } from "./tool-loop.js"; +import { createLogger } from "../utils/logger.js"; + +const log = createLogger("proxy:bridge-json"); export const BRIDGE_JSON_ENV = "CURSOR_ACP_BRIDGE_JSON"; @@ -18,9 +21,9 @@ Use this only for a single complete-file write. Otherwise answer normally or use const TASK_BRIDGE_JSON_CONTEXT = `SYSTEM: OpenCode Task bridge mode is active. For Task only, the exact envelope below overrides the earlier generic "standard OpenAI tool_call" instruction. Do not add id, type, or function fields, and do not stringify arguments. -OpenCode owns the task tool. Do not invoke Cursor's built-in Task tool; it uses a different subagent list. To call OpenCode's task tool, respond with exactly one JSON object and no prose: +OpenCode owns the task tool. Do not invoke Cursor's built-in Task tool; it uses a different subagent list. To call OpenCode's task tool, respond with one JSON object, or one JSON array of those objects to dispatch several tasks in parallel, and no prose: {"name":"task","arguments":{"description":"3-5 words","prompt":"task details","subagent_type":"one name listed in the OpenCode task description"}} -To dispatch several tasks in parallel, respond instead with exactly one JSON array of these objects and no prose: [{"name":"task","arguments":{...}},{"name":"task","arguments":{...}}] +[{"name":"task","arguments":{"description":"first task","prompt":"details","subagent_type":"explore"}},{"name":"task","arguments":{"description":"second task","prompt":"details","subagent_type":"general"}}] Use this only when delegating through OpenCode. Otherwise answer normally.`; type BridgePromptOptions = { @@ -178,16 +181,24 @@ export function extractBridgeToolCallsFromText( return null; } - const id = `call_bridge_${shortHash(jsonText)}`; + // Random, not content-derived: a retried identical envelope must not reuse earlier call ids. + const id = `call_bridge_${randomUUID().replace(/-/g, "").slice(0, 12)}`; if (!Array.isArray(parsed)) { const toolCall = buildBridgeToolCall(parsed, id, allowedToolNames, writeSchema); return toolCall ? [toolCall] : null; } const toolCalls = parsed.map((entry, i) => buildBridgeToolCall(entry, `${id}_${i}`, allowedToolNames, writeSchema)); - return toolCalls.length > 0 && toolCalls.every((call): call is OpenAiToolCall => call !== null) - ? toolCalls - : null; + if (toolCalls.length > 0 && toolCalls.every((call): call is OpenAiToolCall => call !== null)) { + return toolCalls; + } + const rejectedIndex = toolCalls.indexOf(null); + log.warn("Dropping bridge JSON array; passing it through as text", { + envelopeCount: parsed.length, + rejectedIndex, + rejectedName: isRecord(parsed[rejectedIndex]) ? parsed[rejectedIndex].name : undefined, + }); + return null; } function buildBridgeToolCall( @@ -334,10 +345,6 @@ function containsCompleteJson(text: string): boolean { } } -function shortHash(value: string): string { - return createHash("sha256").update(value).digest("hex").slice(0, 12); -} - function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } diff --git a/src/proxy/tool-loop.ts b/src/proxy/tool-loop.ts index 8726ef0..3e766ac 100644 --- a/src/proxy/tool-loop.ts +++ b/src/proxy/tool-loop.ts @@ -198,7 +198,7 @@ export function extractOpenAiToolCall( export function createToolCallCompletionResponse( meta: ToolLoopMeta, - toolCalls: OpenAiToolCall | OpenAiToolCall[], + toolCalls: OpenAiToolCall[], ) { return { id: meta.id, @@ -211,7 +211,7 @@ export function createToolCallCompletionResponse( message: { role: "assistant", content: null, - tool_calls: [toolCalls].flat(), + tool_calls: toolCalls, }, finish_reason: "tool_calls", }, @@ -221,9 +221,9 @@ export function createToolCallCompletionResponse( export function createToolCallStreamChunks( meta: ToolLoopMeta, - toolCalls: OpenAiToolCall | OpenAiToolCall[], + toolCalls: OpenAiToolCall[], ): Array { - const toolDelta = { + const toolDeltas = toolCalls.map((toolCall, index) => ({ id: meta.id, object: "chat.completion.chunk", created: meta.created, @@ -233,12 +233,12 @@ export function createToolCallStreamChunks( index: 0, delta: { role: "assistant", - tool_calls: [toolCalls].flat().map((toolCall, index) => ({ index, ...toolCall })), + tool_calls: [{ index, ...toolCall }], }, finish_reason: null, }, ], - }; + })); const finishChunk = { id: meta.id, @@ -254,7 +254,7 @@ export function createToolCallStreamChunks( ], }; - return [toolDelta, finishChunk]; + return [...toolDeltas, finishChunk]; } function extractToolNameAndArgs(event: StreamJsonToolCallEvent): { diff --git a/tests/integration/opencode-loop.integration.test.ts b/tests/integration/opencode-loop.integration.test.ts index 99d9ea9..2b4bea0 100644 --- a/tests/integration/opencode-loop.integration.test.ts +++ b/tests/integration/opencode-loop.integration.test.ts @@ -947,6 +947,32 @@ describe("OpenCode-owned tool loop integration", () => { expect(toolCalls.map((call: any) => JSON.parse(call.function.arguments).subagent_type)) .toEqual(["explore", "general", "explore"]); expect(new Set(toolCalls.map((call: any) => call.id)).size).toBe(3); + expect(chunks.map((chunk) => chunk.choices?.[0]?.finish_reason).filter(Boolean)) + .toEqual(["tool_calls"]); + const allContent = chunks + .map((chunk) => chunk.choices?.[0]?.delta?.content) + .filter((value): value is string => typeof value === "string") + .join(""); + expect(allContent).not.toContain('"name":"task"'); + }); + + it("returns a non-stream Task bridge JSON array as parallel tool calls", async () => { + process.env.MOCK_CURSOR_SCENARIO = "assistant-bridge-task-array"; + process.env.MOCK_CURSOR_PROMPT_FILE = ""; + + const response = await requestCompletion(baseURL, { + model: "auto", + stream: false, + tools: [TASK_TOOL], + messages: [{ role: "user", content: "Dispatch three subagents in parallel" }], + }); + + const json: any = await response.json(); + const toolCalls = json.choices?.[0]?.message?.tool_calls ?? []; + expect(toolCalls.map((call: any) => JSON.parse(call.function.arguments).subagent_type)) + .toEqual(["explore", "general", "explore"]); + expect(json.choices?.[0]?.finish_reason).toBe("tool_calls"); + expect(json.choices?.[0]?.message?.content).toBeNull(); }); it("preserves thinking from mixed assistant events while bridging Task JSON", async () => { diff --git a/tests/unit/provider-boundary.test.ts b/tests/unit/provider-boundary.test.ts index 65a24d9..bfd2a4f 100644 --- a/tests/unit/provider-boundary.test.ts +++ b/tests/unit/provider-boundary.test.ts @@ -124,10 +124,10 @@ describe("provider boundary", () => { expect(skipped.action).toBe("skip"); const meta = { id: "resp-1", created: 123, model: "auto" }; - const nonStream = boundary.createNonStreamToolCallResponse(meta, result.toolCall!); + const nonStream = boundary.createNonStreamToolCallResponse(meta, [result.toolCall!]); expect(nonStream.choices[0].finish_reason).toBe("tool_calls"); - const stream = boundary.createStreamToolCallChunks(meta, result.toolCall!); + const stream = boundary.createStreamToolCallChunks(meta, [result.toolCall!]); expect(stream).toHaveLength(2); expect(stream[1].choices[0].finish_reason).toBe("tool_calls"); }); diff --git a/tests/unit/proxy/bridge-json.test.ts b/tests/unit/proxy/bridge-json.test.ts index 7c3a1cd..b314f90 100644 --- a/tests/unit/proxy/bridge-json.test.ts +++ b/tests/unit/proxy/bridge-json.test.ts @@ -6,7 +6,7 @@ import { extractBridgeToolCallsFromText, isBridgeJsonEnabled, } from "../../../src/proxy/bridge-json.js"; -import { createToolCallStreamChunks } from "../../../src/proxy/tool-loop.js"; +import { createToolCallCompletionResponse, createToolCallStreamChunks } from "../../../src/proxy/tool-loop.js"; const extractBridgeToolCallFromText = (...args: Parameters) => extractBridgeToolCallsFromText(...args)?.[0] ?? null; @@ -150,7 +150,36 @@ describe("proxy/bridge-json", () => { expect(new Set(calls?.map((call) => call.id)).size).toBe(3); const chunks = createToolCallStreamChunks({ id: "x", created: 0, model: "m" }, calls!); - expect(chunks[0].choices[0].delta.tool_calls.map((call: any) => call.index)).toEqual([0, 1, 2]); + expect(chunks.flatMap((chunk) => chunk.choices[0].delta.tool_calls ?? []).map((call: any) => call.index)) + .toEqual([0, 1, 2]); + }); + + it("does not reuse ids when an identical envelope is retried", () => { + const first = extractBridgeToolCallsFromText(`[${TASK_JSON}]`, new Set(["task"])); + const retry = extractBridgeToolCallsFromText(`[${TASK_JSON}]`, new Set(["task"])); + + expect(first?.[0].id).not.toBe(retry?.[0].id); + }); + + it("extracts a write array as parallel write calls", () => { + const calls = extractBridgeToolCallsFromText( + JSON.stringify(["a.txt", "b.txt"].map((path) => ({ name: "write", arguments: { path, content: path } }))), + new Set(["write"]), + ); + + expect(calls?.map((call) => JSON.parse(call.function.arguments).path)).toEqual(["a.txt", "b.txt"]); + }); + + it("extracts every envelope of a streamed array from non-stream output", () => { + const array = `[${TASK_JSON},${TASK_JSON},${TASK_JSON}]`; + const output = [delta(array.slice(0, 60)), delta(array.slice(60))].map(JSON.stringify).join("\n"); + + const calls = extractBridgeToolCallsFromStreamOutput(output, new Set(["task"])); + + expect(calls).toHaveLength(3); + expect(new Set(calls?.map((call) => call.id)).size).toBe(3); + const response = createToolCallCompletionResponse({ id: "x", created: 0, model: "m" }, calls!); + expect(response.choices[0].message.tool_calls).toHaveLength(3); }); it("rejects an array when any envelope is invalid or the array is empty", () => { @@ -323,6 +352,8 @@ describe("proxy/bridge-json", () => { ); expect(taskPrompt).toContain("Do not add id, type, or function fields"); expect(taskPrompt).toContain("do not stringify arguments"); + expect(taskPrompt).toContain("one JSON array of those objects to dispatch several tasks in parallel"); + expect(taskPrompt).not.toContain("exactly one JSON object"); expect(readPrompt).toBe(basePrompt); expect(disabled).toBe(basePrompt); }); @@ -364,7 +395,10 @@ describe("proxy/bridge-json", () => { expect(detector.push(delta(array.slice(1, 40)))).toEqual({ action: "buffer" }); const decision = detector.push(delta(array.slice(40))); - expect(decision.action === "tool_call" && decision.toolCalls.length).toBe(2); + expect(decision.action).toBe("tool_call"); + const calls = decision.action === "tool_call" ? decision.toolCalls : []; + expect(calls.map((call) => call.function.name)).toEqual(["task", "task"]); + expect(new Set(calls.map((call) => call.id)).size).toBe(2); }); it("passes markdown that starts with a bracket through", () => { diff --git a/tests/unit/proxy/tool-loop.test.ts b/tests/unit/proxy/tool-loop.test.ts index 462d7bd..711dd5b 100644 --- a/tests/unit/proxy/tool-loop.test.ts +++ b/tests/unit/proxy/tool-loop.test.ts @@ -415,14 +415,14 @@ describe("proxy/tool-loop", () => { it("builds valid non-stream tool call response", () => { const response = createToolCallCompletionResponse( { id: "resp-1", created: 123, model: "cursor-acp/auto" }, - { + [{ id: "call_9", type: "function", function: { name: "oc_read", arguments: "{\"path\":\"a.txt\"}", }, - }, + }], ); expect(response.object).toBe("chat.completion"); @@ -434,14 +434,14 @@ describe("proxy/tool-loop", () => { it("builds valid stream chunks with tool_calls finish reason", () => { const chunks = createToolCallStreamChunks( { id: "resp-2", created: 456, model: "cursor-acp/auto" }, - { + [{ id: "call_10", type: "function", function: { name: "oc_write", arguments: "{\"path\":\"b.txt\",\"content\":\"x\"}", }, - }, + }], ); expect(chunks).toHaveLength(2); @@ -449,6 +449,23 @@ describe("proxy/tool-loop", () => { expect(chunks[0].choices[0].finish_reason).toBeNull(); expect(chunks[1].choices[0].finish_reason).toBe("tool_calls"); }); + + it("emits one indexed stream chunk per parallel tool call", () => { + const calls = ["a", "b", "c"].map((name) => ({ + id: `call_${name}`, + type: "function" as const, + function: { name, arguments: "{}" }, + })); + const meta = { id: "resp-3", created: 789, model: "cursor-acp/auto" }; + + const chunks = createToolCallStreamChunks(meta, calls); + expect(chunks.map((chunk) => chunk.choices[0].delta.tool_calls?.map((c: any) => [c.index, c.id]))) + .toEqual([[[0, "call_a"]], [[1, "call_b"]], [[2, "call_c"]], undefined]); + expect(chunks.at(-1).choices[0].finish_reason).toBe("tool_calls"); + + const response = createToolCallCompletionResponse(meta, calls); + expect(response.choices[0].message.tool_calls.map((c) => c.id)).toEqual(["call_a", "call_b", "call_c"]); + }); }); describe("extractOpenAiToolCall with pass-through", () => {