Skip to content
Merged
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
62 changes: 30 additions & 32 deletions src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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[]) => {
log.debug("Intercepted OpenCode tool call (stream)", {
name: toolCall.function.name,
callId: toolCall.id,
names: toolCalls.map((toolCall) => toolCall.function.name),
callIds: toolCalls.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`);
Expand Down Expand Up @@ -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") {
Expand Down Expand Up @@ -1686,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);
Expand Down Expand Up @@ -1775,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);
Expand Down Expand Up @@ -2107,26 +2106,25 @@ 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));
return;
}

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));
Expand Down Expand Up @@ -2225,18 +2223,18 @@ export async function ensureCursorProxyServer(workspaceDirectory: string, toolRo
streamTerminated = true;
res.end();
});
const emitToolCallAndTerminate = (toolCall: OpenAiToolCall) => {
const emitToolCallAndTerminate = (toolCalls: OpenAiToolCall[]) => {
if (streamTerminated || res.writableEnded) {
return;
}
log.debug("Intercepted OpenCode tool call (stream)", {
name: toolCall.function.name,
callId: toolCall.id,
names: toolCalls.map((toolCall) => toolCall.function.name),
callIds: toolCalls.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`);
Expand Down Expand Up @@ -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") {
Expand Down Expand Up @@ -2365,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);
Expand Down
12 changes: 6 additions & 6 deletions src/provider/boundary.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,8 +160,8 @@ export interface ProviderBoundary {
allowedToolNames: Set<string>,
toolLoopMode: ToolLoopMode,
): ToolCallExtractionResult;
createNonStreamToolCallResponse(meta: ToolLoopMeta, toolCall: OpenAiToolCall): any;
createStreamToolCallChunks(meta: ToolLoopMeta, toolCall: OpenAiToolCall): Array<any>;
createNonStreamToolCallResponse(meta: ToolLoopMeta, toolCalls: OpenAiToolCall[]): any;
createStreamToolCallChunks(meta: ToolLoopMeta, toolCalls: OpenAiToolCall[]): Array<any>;
}

export function parseProviderBoundaryMode(
Expand Down Expand Up @@ -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);
},
};
}
74 changes: 52 additions & 22 deletions src/proxy/bridge-json.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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";

Expand All @@ -18,8 +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"}}
[{"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 = {
Expand All @@ -30,7 +34,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";
Expand Down Expand Up @@ -59,10 +63,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;
Expand All @@ -88,15 +93,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)) {
Expand Down Expand Up @@ -159,11 +164,11 @@ export function applyBridgeJsonPrompt(prompt: string, options: BridgePromptOptio
return result;
}

export function extractBridgeToolCallFromText(
export function extractBridgeToolCallsFromText(
text: string,
allowedToolNames: Set<string>,
writeSchema?: unknown,
): OpenAiToolCall | null {
): OpenAiToolCall[] | null {
const jsonText = extractStrictJsonText(text);
if (!jsonText) {
return null;
Expand All @@ -176,13 +181,39 @@ export function extractBridgeToolCallFromText(
return null;
}

// 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));
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(
parsed: unknown,
id: string,
allowedToolNames: Set<string>,
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;
}

Expand All @@ -200,7 +231,7 @@ export function extractBridgeToolCallFromText(
}

return {
id: `call_bridge_${shortHash(jsonText)}`,
id,
type: "function",
function: {
name: writeToolName,
Expand All @@ -210,7 +241,7 @@ export function extractBridgeToolCallFromText(
}

function buildTaskToolCall(
jsonText: string,
id: string,
args: Record<string, unknown>,
): OpenAiToolCall | null {
if (
Expand All @@ -224,7 +255,7 @@ function buildTaskToolCall(
}

return {
id: `call_bridge_${shortHash(jsonText)}`,
id,
type: "function",
function: {
name: "task",
Expand All @@ -233,11 +264,11 @@ function buildTaskToolCall(
};
}

export function extractBridgeToolCallFromStreamOutput(
export function extractBridgeToolCallsFromStreamOutput(
output: string,
allowedToolNames: Set<string>,
writeSchema?: unknown,
): OpenAiToolCall | null {
): OpenAiToolCall[] | null {
if (!output) {
return null;
}
Expand All @@ -251,7 +282,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();
Expand Down Expand Up @@ -290,7 +321,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;
}

Expand All @@ -311,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<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
Expand Down
Loading
Loading