diff --git a/package.json b/package.json index 4257a9d..62a8e26 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "test:unit": "bun test --isolate tests/unit", "test:integration": "bun test tests/integration", "test:perf": "bun test tests/unit/perf.test.ts tests/unit/utils/logger.test.ts tests/unit/proxy/prompt-builder.test.ts tests/unit/proxy/session-resume.test.ts tests/unit/proxy/incremental-prompt.test.ts tests/unit/proxy/plugin-resume.test.ts tests/unit/cursor-agent-child.test.ts tests/unit/cursor-agent-runner.test.ts tests/unit/streaming/line-buffer.test.ts tests/unit/streaming/parser.test.ts tests/unit/streaming/delta-tracker.test.ts tests/unit/streaming/openai-sse.test.ts tests/integration/sdk-demux-roundtrip.test.ts", - "test:ci:unit": "bun test --isolate tests/tools/defaults.test.ts tests/tools/executor-chain.test.ts tests/tools/sdk-executor.test.ts tests/tools/mcp-executor.test.ts tests/tools/skills.test.ts tests/tools/registry.test.ts tests/unit/cli/opencode-cursor.test.ts tests/unit/cli/cursor-bridge-install.test.ts tests/unit/cli/model-discovery.test.ts tests/unit/cursor-agent-child.test.ts tests/unit/cursor-agent-pool.test.ts tests/unit/cursor-agent-runner.test.ts tests/unit/errors.test.ts tests/unit/binary-strict.test.ts tests/unit/cursor-agent-fallback.test.ts tests/unit/models/discovery.test.ts tests/unit/proxy/bridge-json.test.ts tests/unit/proxy/prompt-builder.test.ts tests/unit/proxy/tool-loop.test.ts tests/unit/proxy/session-resume.test.ts tests/unit/proxy/incremental-prompt.test.ts tests/unit/proxy/plugin-resume.test.ts tests/unit/provider-backend.test.ts tests/unit/provider-boundary.test.ts tests/unit/provider-runtime-interception.test.ts tests/unit/provider-tool-schema-compat.test.ts tests/unit/provider-tool-loop-guard.test.ts tests/unit/mcp/tool-bridge.test.ts tests/unit/sdk-child.test.ts tests/unit/sdk-runner.test.ts tests/unit/plugin.test.ts tests/unit/plugin-tools-hook.test.ts tests/unit/plugin-tool-resolution.test.ts tests/unit/plugin-config.test.ts tests/unit/plugin-stream-extraction.test.ts tests/unit/auth.test.ts tests/unit/streaming/line-buffer.test.ts tests/unit/streaming/parser.test.ts tests/unit/streaming/types.test.ts tests/unit/streaming/delta-tracker.test.ts tests/unit/streaming/openai-sse.test.ts tests/unit/streaming/ai-sdk-parts.test.ts tests/competitive/edge.test.ts", + "test:ci:unit": "bun test --isolate tests/tools/defaults.test.ts tests/tools/executor-chain.test.ts tests/tools/sdk-executor.test.ts tests/tools/mcp-executor.test.ts tests/tools/skills.test.ts tests/tools/registry.test.ts tests/unit/cli/opencode-cursor.test.ts tests/unit/cli/cursor-bridge-install.test.ts tests/unit/cli/model-discovery.test.ts tests/unit/cursor-agent-child.test.ts tests/unit/cursor-agent-pool.test.ts tests/unit/cursor-agent-runner.test.ts tests/unit/errors.test.ts tests/unit/binary-strict.test.ts tests/unit/cursor-agent-fallback.test.ts tests/unit/models/discovery.test.ts tests/unit/proxy/bridge-json.test.ts tests/unit/proxy/prompt-builder.test.ts tests/unit/proxy/tool-loop.test.ts tests/unit/proxy/session-resume.test.ts tests/unit/proxy/incremental-prompt.test.ts tests/unit/proxy/plugin-resume.test.ts tests/unit/provider-backend.test.ts tests/unit/provider-boundary.test.ts tests/unit/provider-runtime-interception.test.ts tests/unit/provider-tool-schema-compat.test.ts tests/unit/provider-tool-loop-guard.test.ts tests/unit/mcp/tool-bridge.test.ts tests/unit/sdk-child.test.ts tests/unit/sdk-runner.test.ts tests/unit/plugin.test.ts tests/unit/plugin-system-message.test.ts tests/unit/plugin-tools-hook.test.ts tests/unit/plugin-tool-resolution.test.ts tests/unit/plugin-config.test.ts tests/unit/plugin-stream-extraction.test.ts tests/unit/auth.test.ts tests/unit/streaming/line-buffer.test.ts tests/unit/streaming/parser.test.ts tests/unit/streaming/types.test.ts tests/unit/streaming/delta-tracker.test.ts tests/unit/streaming/openai-sse.test.ts tests/unit/streaming/ai-sdk-parts.test.ts tests/competitive/edge.test.ts", "test:ci:integration": "bun test tests/integration/comprehensive.test.ts tests/integration/tools-router.integration.test.ts tests/integration/stream-router.integration.test.ts tests/integration/opencode-loop.integration.test.ts", "verify:issue-92": "bash scripts/verify-issue-92.sh", "check:pricing": "bun run scripts/check-cursor-pricing-coverage.ts", diff --git a/src/mcp/config.ts b/src/mcp/config.ts index aeb99be..1b704aa 100644 --- a/src/mcp/config.ts +++ b/src/mcp/config.ts @@ -93,77 +93,6 @@ export function readMcpConfigs(deps: ReadMcpConfigsDeps = {}): McpServerConfig[] return configs; } -let _subagentCache: { names: string[]; expiry: number } | null = null; -const SUBAGENT_CACHE_TTL_MS = 60_000; - -/** Clear cached subagent names (for testing only). */ -export function _resetSubagentCache(): void { - _subagentCache = null; -} - -interface ReadSubagentNamesDeps { - configJson?: string; - existsSync?: (path: string) => boolean; - readFileSync?: (path: string, enc: BufferEncoding) => string; - env?: NodeJS.ProcessEnv; -} - -export function readSubagentNames(deps: ReadSubagentNamesDeps = {}): string[] { - const useCache = deps.configJson == null; - if (useCache && _subagentCache && Date.now() < _subagentCache.expiry) { - return _subagentCache.names; - } - - const result = readSubagentNamesUncached(deps); - - if (useCache) { - _subagentCache = { names: result, expiry: Date.now() + SUBAGENT_CACHE_TTL_MS }; - } - return result; -} - -function readSubagentNamesUncached(deps: ReadSubagentNamesDeps): string[] { - let raw: string; - - if (deps.configJson != null) { - raw = deps.configJson; - } else { - const exists = deps.existsSync ?? nodeExistsSync; - const readFile = deps.readFileSync ?? nodeReadFileSync; - const configPath = resolveOpenCodeConfigPath(deps.env ?? process.env); - if (!exists(configPath)) return ["general-purpose"]; - try { - raw = readFile(configPath, "utf8"); - } catch { - return ["general-purpose"]; - } - } - - let parsed: Record; - try { - parsed = JSON.parse(raw); - } catch { - return ["general-purpose"]; - } - - const agentSection = parsed.agent; - if (!agentSection || typeof agentSection !== "object" || Array.isArray(agentSection)) { - return ["general-purpose"]; - } - - const agents = agentSection as Record; - const names = Object.keys(agents); - if (names.length === 0) return ["general-purpose"]; - - const subagentNames = names.filter((name) => { - const entry = agents[name]; - return entry && typeof entry === "object" && !Array.isArray(entry) - && (entry as Record).mode === "subagent"; - }); - - return subagentNames.length > 0 ? subagentNames : names; -} - function isStringRecord(v: unknown): v is Record { return typeof v === "object" && v !== null && !Array.isArray(v); } diff --git a/src/plugin.ts b/src/plugin.ts index 4bcfc9c..27bfb10 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -18,6 +18,7 @@ import { isResult, isThinking, isToolCallStart, + type StreamJsonAssistantEvent, type StreamJsonEvent, } from "./streaming/types.js"; import { @@ -31,8 +32,8 @@ import { parseAgentError, formatErrorForUser, stripAnsi, isResumeSpecificFailure import { buildPromptFromMessages, buildToolFingerprint } from "./proxy/prompt-builder.js"; import { applyBridgeJsonPrompt, + BridgeJsonStreamDetector, extractBridgeToolCallFromStreamOutput, - extractBridgeToolCallFromText, isBridgeJsonEnabled, } from "./proxy/bridge-json.js"; import { buildIncrementalPrompt, type ProxyMessage } from "./proxy/incremental-prompt.js"; @@ -59,7 +60,7 @@ import { ToolRouter } from "./tools/router.js"; import { SkillLoader } from "./tools/skills/loader.js"; import { SkillResolver } from "./tools/skills/resolver.js"; import { autoRefreshModels } from "./models/sync.js"; -import { readMcpConfigs, readSubagentNames } from "./mcp/config.js"; +import { readMcpConfigs } from "./mcp/config.js"; import { McpClientManager } from "./mcp/client-manager.js"; import { MCP_TOOL_PREFIX, @@ -121,7 +122,6 @@ export function buildAvailableToolsSystemMessage( lastToolMap: Array<{ id: string; name: string }>, mcpToolDefs: any[], mcpToolSummaries?: McpToolSummary[], - subagentNames: string[] = [], ): string | null { const parts: string[] = []; @@ -166,12 +166,6 @@ export function buildAvailableToolsSystemMessage( parts.push(lines.join("\n")); } - if (subagentNames.length > 0) { - parts.push( - `When calling the task tool, set subagent_type to one of: ${subagentNames.join(", ")}. Do not omit this parameter.` - ); - } - return parts.length > 0 ? parts.join("\n\n") : null; } @@ -293,7 +287,6 @@ export interface ResolvedPrompt { contentPrefix?: string; recordContentPrefix?: string; toolFingerprint?: string; - subagentFingerprint?: string; } /** @@ -312,13 +305,12 @@ export function resolvePromptForBackend(input: { backend: CursorRuntimeBackend; messages: Array; tools: Array; - subagentNames: string[]; model: string; workspaceDirectory: string; }): ResolvedPrompt { let fullPrompt: string | undefined; const getFullPrompt = () => - fullPrompt ??= buildPromptFromMessages(input.messages, input.tools, input.subagentNames); + fullPrompt ??= buildPromptFromMessages(input.messages, input.tools); if (input.backend !== "cursor-agent" || !isSessionResumeEnabled()) { return { prompt: getFullPrompt(), usedIncremental: false }; @@ -339,8 +331,7 @@ export function resolvePromptForBackend(input: { const sessionKey = buildSessionKey(input.workspaceDirectory, input.model, anchor); const sessionKeyHash = sanitizeSessionKey(sessionKey); const toolFingerprint = buildToolFingerprint(input.tools); - const subagentFingerprint = input.subagentNames.slice().sort().join(","); - const resumeChatId = getResumeChatId(sessionKey, contentPrefix, toolFingerprint, subagentFingerprint); + const resumeChatId = getResumeChatId(sessionKey, contentPrefix, toolFingerprint); const resumeChatIdHash = resumeChatId ? sanitizeSessionKey(resumeChatId) : undefined; if (!resumeChatId) { const isContinuation = input.messages.some((m: any) => m?.role === "assistant"); @@ -349,7 +340,7 @@ export function resolvePromptForBackend(input: { sessionKeyHash, }); } - return { prompt: getFullPrompt(), sessionKey, usedIncremental: false, contentPrefix, recordContentPrefix, toolFingerprint, subagentFingerprint }; + return { prompt: getFullPrompt(), sessionKey, usedIncremental: false, contentPrefix, recordContentPrefix, toolFingerprint }; } const incremental = buildIncrementalPrompt(input.messages); @@ -367,14 +358,14 @@ export function resolvePromptForBackend(input: { fullPromptChars: getFullPrompt().length, }); } - return { prompt: incremental, resumeChatId, sessionKey, usedIncremental: true, contentPrefix, recordContentPrefix, toolFingerprint, subagentFingerprint }; + return { prompt: incremental, resumeChatId, sessionKey, usedIncremental: true, contentPrefix, recordContentPrefix, toolFingerprint }; } log.info("Session resume active but incremental prompt unavailable; using full prompt", { sessionKeyHash, resumeChatIdHash, }); - return { prompt: getFullPrompt(), resumeChatId, sessionKey, usedIncremental: false, contentPrefix, recordContentPrefix, toolFingerprint, subagentFingerprint }; + return { prompt: getFullPrompt(), resumeChatId, sessionKey, usedIncremental: false, contentPrefix, recordContentPrefix, toolFingerprint }; } /** @@ -389,7 +380,6 @@ export function captureResumeChatIdFromEvent( workspaceDirectory: string, contentPrefix?: string, toolFingerprint?: string, - subagentFingerprint?: string, ): void { if (!sessionKey || !isSessionResumeEnabled()) return; const chatId = event.session_id; @@ -400,7 +390,6 @@ export function captureResumeChatIdFromEvent( chatId.trim(), contentPrefix ?? "", toolFingerprint, - subagentFingerprint, ); return; } @@ -424,7 +413,6 @@ export function captureResumeChatIdFromOutput( workspaceDirectory: string, contentPrefix?: string, toolFingerprint?: string, - subagentFingerprint?: string, ): void { if (!sessionKey || !isSessionResumeEnabled() || !output) return; for (const line of output.split(/\r?\n/)) { @@ -437,7 +425,6 @@ export function captureResumeChatIdFromOutput( workspaceDirectory, contentPrefix, toolFingerprint, - subagentFingerprint, ); } } @@ -473,6 +460,28 @@ function isSuccessfulResultEvent(event: StreamJsonEvent): boolean { return isResult(event) && event.is_error !== true && event.subtype !== "error"; } +function createAssistantTextEvent(text: string): StreamJsonEvent { + return { + type: "assistant", + message: { + role: "assistant", + content: [{ type: "text", text }], + }, + }; +} + +function createAssistantThinkingEvent( + event: StreamJsonAssistantEvent, +): StreamJsonAssistantEvent { + return { + ...event, + message: { + ...event.message, + content: event.message.content.filter((content) => content.type === "thinking"), + }, + }; +} + function shouldTreatCursorAgentFailureAsDiagnostic( errSource: string, sawSuccessfulStreamOutput: boolean, @@ -493,7 +502,6 @@ function warnIfResumeNotCaptured( sessionResumeKeyHash: string | undefined, sessionResumeContentPrefix: string | undefined, sessionResumeToolFingerprint: string | undefined, - sessionResumeSubagentFingerprint: string | undefined, model: string, ): void { if ( @@ -503,7 +511,6 @@ function warnIfResumeNotCaptured( sessionResumeKey, sessionResumeContentPrefix, sessionResumeToolFingerprint, - sessionResumeSubagentFingerprint, ) ) { log.warn("Session resume enabled but no session_id captured from cursor-agent response; resume will not activate on the next turn", { @@ -1274,7 +1281,6 @@ async function ensureCursorProxyServer(workspaceDirectory: string, toolRouter?: const toolLoopGuard = createToolLoopGuard(messages, TOOL_LOOP_MAX_REPEAT); const boundaryContext = createBoundaryRuntimeContext("bun-handler"); - const subagentNames = readSubagentNames(); const model = boundaryContext.run("resolveRuntimeModel", (boundary) => boundary.resolveRuntimeModel(body?.model, body?.cursorModel), ); @@ -1286,7 +1292,6 @@ async function ensureCursorProxyServer(workspaceDirectory: string, toolRouter?: backend, messages, tools, - subagentNames, model, workspaceDirectory, }); @@ -1298,7 +1303,6 @@ async function ensureCursorProxyServer(workspaceDirectory: string, toolRouter?: contentPrefix: sessionResumeContentPrefix, recordContentPrefix: sessionResumeRecordContentPrefix, toolFingerprint: sessionResumeToolFingerprint, - subagentFingerprint: sessionResumeSubagentFingerprint, } = resolvedPrompt; reqPerf.mark("prompt-built"); const sessionResumeKeyHash = sessionResumeKey ? sanitizeSessionKey(sessionResumeKey) : undefined; @@ -1358,14 +1362,12 @@ async function ensureCursorProxyServer(workspaceDirectory: string, toolRouter?: workspaceDirectory, sessionResumeRecordContentPrefix, sessionResumeToolFingerprint, - sessionResumeSubagentFingerprint, ); warnIfResumeNotCaptured( sessionResumeKey, sessionResumeKeyHash, sessionResumeRecordContentPrefix, sessionResumeToolFingerprint, - sessionResumeSubagentFingerprint, model, ); const meta = { @@ -1493,6 +1495,9 @@ async function ensureCursorProxyServer(workspaceDirectory: string, toolRouter?: const reader = (child.stdout as ReadableStream).getReader(); const converter = new StreamToSseConverter(model, { id, created }); const lineBuffer = new LineBuffer(); + const bridgeDetector = bridgeJsonEnabled + ? new BridgeJsonStreamDetector(allowedToolNames, toolSchemaMap.get("write")) + : null; const emitToolCallAndTerminate = (toolCall: OpenAiToolCall) => { log.debug("Intercepted OpenCode tool call (stream)", { name: toolCall.function.name, @@ -1514,6 +1519,45 @@ async function ensureCursorProxyServer(workspaceDirectory: string, toolRouter?: // ignore } }; + const emitBridgeEvent = (event: StreamJsonEvent) => { + const sseChunks = converter.handleEvent(event); + if (sseChunks.length > 0) { + sawSuccessfulStreamOutput = true; + } + for (const sse of sseChunks) { + enqueueSse(sse); + } + }; + const emitBridgeText = (text: string) => { + emitBridgeEvent(createAssistantTextEvent(text)); + }; + const flushBridgeText = () => { + const text = bridgeDetector?.flush() ?? ""; + if (text) { + emitBridgeText(text); + } + }; + const handleBridgeAssistantEvent = (event: StreamJsonEvent): boolean => { + if (!bridgeDetector || !isAssistantText(event)) { + return false; + } + if (isThinking(event)) { + emitBridgeEvent(createAssistantThinkingEvent(event)); + } + const decision = bridgeDetector.push(event); + if (decision.action === "tool_call") { + emitToolCallAndTerminate(decision.toolCall); + return true; + } + if (decision.action === "buffer") { + return true; + } + if (decision.text !== undefined) { + emitBridgeText(decision.text); + return true; + } + return false; + }; const emitTerminalAssistantErrorAndTerminate = (message: string) => { if (streamTerminated) { return; @@ -1550,7 +1594,6 @@ async function ensureCursorProxyServer(workspaceDirectory: string, toolRouter?: workspaceDirectory, sessionResumeRecordContentPrefix, sessionResumeToolFingerprint, - sessionResumeSubagentFingerprint, ); if (isResult(event)) { @@ -1560,19 +1603,14 @@ async function ensureCursorProxyServer(workspaceDirectory: string, toolRouter?: } } - if (bridgeJsonEnabled && isAssistantText(event)) { - const bridgeToolCall = extractBridgeToolCallFromText( - extractText(event), - allowedToolNames, - toolSchemaMap.get("write"), - ); - if (bridgeToolCall) { - emitToolCallAndTerminate(bridgeToolCall); - break; - } + if (handleBridgeAssistantEvent(event)) { + if (streamTerminated) break; + continue; } if (event.type === "tool_call") { + flushBridgeText(); + bridgeDetector?.reset(); perf.mark("tool-call"); const result = await handleToolLoopEventWithFallback({ event: event as any, @@ -1649,7 +1687,6 @@ async function ensureCursorProxyServer(workspaceDirectory: string, toolRouter?: workspaceDirectory, sessionResumeRecordContentPrefix, sessionResumeToolFingerprint, - sessionResumeSubagentFingerprint, ); if (isResult(event)) { usage = extractOpenAiUsageFromResult(event) ?? usage; @@ -1657,18 +1694,13 @@ async function ensureCursorProxyServer(workspaceDirectory: string, toolRouter?: sawSuccessfulStreamOutput = true; } } - if (bridgeJsonEnabled && isAssistantText(event)) { - const bridgeToolCall = extractBridgeToolCallFromText( - extractText(event), - allowedToolNames, - toolSchemaMap.get("write"), - ); - if (bridgeToolCall) { - emitToolCallAndTerminate(bridgeToolCall); - break; - } + if (handleBridgeAssistantEvent(event)) { + if (streamTerminated) break; + continue; } if (event.type === "tool_call") { + flushBridgeText(); + bridgeDetector?.reset(); const result = await handleToolLoopEventWithFallback({ event: event as any, boundary: boundaryContext.getBoundary(), @@ -1728,6 +1760,7 @@ async function ensureCursorProxyServer(workspaceDirectory: string, toolRouter?: return; } + flushBridgeText(); const exitCode = await child.exited; if (exitCode !== 0) { const stderrText = await new Response(child.stderr).text(); @@ -1768,7 +1801,6 @@ async function ensureCursorProxyServer(workspaceDirectory: string, toolRouter?: sessionResumeKeyHash, sessionResumeRecordContentPrefix, sessionResumeToolFingerprint, - sessionResumeSubagentFingerprint, model, ); @@ -1891,7 +1923,6 @@ async function ensureCursorProxyServer(workspaceDirectory: string, toolRouter?: const toolLoopGuard = createToolLoopGuard(messages, TOOL_LOOP_MAX_REPEAT); const boundaryContext = createBoundaryRuntimeContext("node-handler"); - const subagentNames = readSubagentNames(); const model = boundaryContext.run("resolveRuntimeModel", (boundary) => boundary.resolveRuntimeModel(bodyData?.model, bodyData?.cursorModel), ); @@ -1903,7 +1934,6 @@ async function ensureCursorProxyServer(workspaceDirectory: string, toolRouter?: backend, messages, tools, - subagentNames, model, workspaceDirectory, }); @@ -1915,7 +1945,6 @@ async function ensureCursorProxyServer(workspaceDirectory: string, toolRouter?: contentPrefix: sessionResumeContentPrefix, recordContentPrefix: sessionResumeRecordContentPrefix, toolFingerprint: sessionResumeToolFingerprint, - subagentFingerprint: sessionResumeSubagentFingerprint, } = resolvedPrompt; reqPerf.mark("prompt-built"); const sessionResumeKeyHashNode = sessionResumeKey ? sanitizeSessionKey(sessionResumeKey) : undefined; @@ -1984,14 +2013,12 @@ async function ensureCursorProxyServer(workspaceDirectory: string, toolRouter?: workspaceDirectory, sessionResumeRecordContentPrefix, sessionResumeToolFingerprint, - sessionResumeSubagentFingerprint, ); warnIfResumeNotCaptured( sessionResumeKey, sessionResumeKeyHashNode, sessionResumeRecordContentPrefix, sessionResumeToolFingerprint, - sessionResumeSubagentFingerprint, model, ); const meta = { @@ -2103,6 +2130,9 @@ async function ensureCursorProxyServer(workspaceDirectory: string, toolRouter?: const converter = new StreamToSseConverter(model, { id, created }); const lineBuffer = new LineBuffer(); + const bridgeDetector = bridgeJsonEnabled + ? new BridgeJsonStreamDetector(allowedToolNames, toolSchemaMap.get("write")) + : null; const toolMapper = new ToolMapper(); const toolSessionId = id; const passThroughTracker = new PassThroughTracker(); @@ -2177,6 +2207,45 @@ async function ensureCursorProxyServer(workspaceDirectory: string, toolRouter?: // ignore } }; + const emitBridgeEvent = (event: StreamJsonEvent) => { + const sseChunks = converter.handleEvent(event); + if (sseChunks.length > 0) { + sawSuccessfulStreamOutput = true; + } + for (const sse of sseChunks) { + writeSse(sse); + } + }; + const emitBridgeText = (text: string) => { + emitBridgeEvent(createAssistantTextEvent(text)); + }; + const flushBridgeText = () => { + const text = bridgeDetector?.flush() ?? ""; + if (text) { + emitBridgeText(text); + } + }; + const handleBridgeAssistantEvent = (event: StreamJsonEvent): boolean => { + if (!bridgeDetector || !isAssistantText(event)) { + return false; + } + if (isThinking(event)) { + emitBridgeEvent(createAssistantThinkingEvent(event)); + } + const decision = bridgeDetector.push(event); + if (decision.action === "tool_call") { + emitToolCallAndTerminate(decision.toolCall); + return true; + } + if (decision.action === "buffer") { + return true; + } + if (decision.text !== undefined) { + emitBridgeText(decision.text); + return true; + } + return false; + }; const chunkQueue: Buffer[] = []; let draining = false; @@ -2196,7 +2265,6 @@ async function ensureCursorProxyServer(workspaceDirectory: string, toolRouter?: workspaceDirectory, sessionResumeRecordContentPrefix, sessionResumeToolFingerprint, - sessionResumeSubagentFingerprint, ); if (isResult(event)) { @@ -2206,19 +2274,14 @@ async function ensureCursorProxyServer(workspaceDirectory: string, toolRouter?: } } - if (bridgeJsonEnabled && isAssistantText(event)) { - const bridgeToolCall = extractBridgeToolCallFromText( - extractText(event), - allowedToolNames, - toolSchemaMap.get("write"), - ); - if (bridgeToolCall) { - emitToolCallAndTerminate(bridgeToolCall); - break; - } + if (handleBridgeAssistantEvent(event)) { + if (streamTerminated || res.writableEnded) break; + continue; } if (event.type === "tool_call") { + flushBridgeText(); + bridgeDetector?.reset(); perf.mark("tool-call"); const result = await handleToolLoopEventWithFallback({ event: event as any, @@ -2291,6 +2354,7 @@ async function ensureCursorProxyServer(workspaceDirectory: string, toolRouter?: await processLines(lineBuffer.flush()); if (streamTerminated || res.writableEnded) return; + flushBridgeText(); perf.mark("request:done"); perf.summarize(); const stderrText = Buffer.concat(stderrChunks).toString().trim(); @@ -2331,7 +2395,6 @@ async function ensureCursorProxyServer(workspaceDirectory: string, toolRouter?: sessionResumeKeyHashNode, sessionResumeRecordContentPrefix, sessionResumeToolFingerprint, - sessionResumeSubagentFingerprint, model, ); @@ -2969,10 +3032,8 @@ export const CursorPlugin: Plugin = async ({ $, directory, worktree, client, ser if (!providerMatch) { return; } - const subagentNames = readSubagentNames(); const systemMessage = buildAvailableToolsSystemMessage( lastToolNames, lastToolMap, mcpToolDefs, mcpToolSummaries, - subagentNames, ); if (!systemMessage) return; output.system = output.system || []; diff --git a/src/proxy/bridge-json.ts b/src/proxy/bridge-json.ts index 5371e8e..3990b15 100644 --- a/src/proxy/bridge-json.ts +++ b/src/proxy/bridge-json.ts @@ -1,6 +1,12 @@ import { createHash } from "node:crypto"; import { parseStreamJsonLine } from "../streaming/parser.js"; -import { extractText, isAssistantText } from "../streaming/types.js"; +import { MixedDeltaTracker } from "../streaming/delta-tracker.js"; +import { + extractText, + isAssistantText, + isPartialStreamDelta, + type StreamJsonAssistantEvent, +} from "../streaming/types.js"; import type { OpenAiToolCall } from "./tool-loop.js"; export const BRIDGE_JSON_ENV = "CURSOR_ACP_BRIDGE_JSON"; @@ -10,11 +16,119 @@ For file changes through opencode-cursor, read any needed files first, then resp {"name":"write","arguments":{"path":"relative/path","content":"complete file contents"}} Use this only for a single complete-file write. Otherwise answer normally or use the available tool format.`; +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"}} +Use this only when delegating through OpenCode. Otherwise answer normally.`; + type BridgePromptOptions = { allowedToolNames: Set; env?: Record; }; +export type BridgeStreamDecision = + | { action: "buffer" } + | { action: "passthrough"; text?: string } + | { action: "tool_call"; toolCall: OpenAiToolCall }; + +export class BridgeJsonStreamDetector { + private state: "undecided" | "candidate" | "passthrough" = "undecided"; + private buffer = ""; + private readonly tracker = new MixedDeltaTracker(); + + constructor( + private readonly allowedToolNames: Set, + private readonly writeSchema?: unknown, + ) {} + + push(event: StreamJsonAssistantEvent): BridgeStreamDecision { + const text = extractText(event); + const delta = this.tracker.nextText(text, isPartialStreamDelta(event)); + if (!delta) { + return this.state === "passthrough" + ? { action: "passthrough" } + : { action: "buffer" }; + } + if (this.state === "passthrough") { + return { action: "passthrough" }; + } + + const hadBufferedText = this.buffer.length > 0; + this.buffer += delta; + + if (this.state === "undecided") { + const meaningful = this.buffer.trimStart(); + if (!meaningful || meaningful === "`" || meaningful === "``") { + return { action: "buffer" }; + } + if (meaningful.startsWith("{") || meaningful.startsWith("```")) { + this.state = "candidate"; + } else { + const withheld = this.buffer; + this.buffer = ""; + this.state = "passthrough"; + return hadBufferedText + ? { action: "passthrough", text: withheld } + : { action: "passthrough" }; + } + } + + const trimmed = this.buffer.trimStart(); + if (trimmed.startsWith("```")) { + const infoLineEnd = trimmed.indexOf("\n", 3); + if (infoLineEnd < 0) { + return { action: "buffer" }; + } + const info = trimmed.slice(3, infoLineEnd).trim(); + if (info && info.toLowerCase() !== "json") { + return this.releaseBuffer(); + } + } + + // 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( + this.buffer, + this.allowedToolNames, + this.writeSchema, + ); + if (toolCall) { + this.buffer = ""; + this.state = "passthrough"; + return { action: "tool_call", toolCall }; + } + + if (containsCompleteJson(this.buffer)) { + return this.releaseBuffer(); + } + return { action: "buffer" }; + } + + flush(): string { + if (this.state === "passthrough" || !this.buffer) { + return ""; + } + const text = this.buffer; + this.buffer = ""; + this.state = "passthrough"; + return text; + } + + reset(): void { + this.state = "undecided"; + this.buffer = ""; + this.tracker.reset(); + } + + private releaseBuffer(): BridgeStreamDecision { + const text = this.buffer; + this.buffer = ""; + this.state = "passthrough"; + return { action: "passthrough", text }; + } +} + export function isBridgeJsonEnabled(env: Record = process.env): boolean { const raw = env[BRIDGE_JSON_ENV]; if (raw === undefined) { @@ -25,13 +139,24 @@ export function isBridgeJsonEnabled(env: Record = pr } export function applyBridgeJsonPrompt(prompt: string, options: BridgePromptOptions): string { - if (!isBridgeJsonEnabled(options.env) || !resolveAllowedWriteToolName(options.allowedToolNames)) { + if (!isBridgeJsonEnabled(options.env)) { return prompt; } - if (prompt.includes("opencode bridge mode is active")) { - return prompt; + + let result = prompt; + if ( + resolveAllowedWriteToolName(options.allowedToolNames) + && !result.includes("opencode bridge mode is active") + ) { + result = result ? `${BRIDGE_JSON_CONTEXT}\n\n${result}` : BRIDGE_JSON_CONTEXT; + } + if ( + options.allowedToolNames.has("task") + && !result.includes("OpenCode Task bridge mode is active") + ) { + result = result ? `${result}\n\n${TASK_BRIDGE_JSON_CONTEXT}` : TASK_BRIDGE_JSON_CONTEXT; } - return prompt ? `${BRIDGE_JSON_CONTEXT}\n\n${prompt}` : BRIDGE_JSON_CONTEXT; + return result; } export function extractBridgeToolCallFromText( @@ -39,11 +164,6 @@ export function extractBridgeToolCallFromText( allowedToolNames: Set, writeSchema?: unknown, ): OpenAiToolCall | null { - const writeToolName = resolveAllowedWriteToolName(allowedToolNames); - if (!writeToolName) { - return null; - } - const jsonText = extractStrictJsonText(text); if (!jsonText) { return null; @@ -56,7 +176,18 @@ export function extractBridgeToolCallFromText( return null; } - if (!isRecord(parsed) || parsed.name !== "write" || !isRecord(parsed.arguments)) { + if (!isRecord(parsed) || !isRecord(parsed.arguments)) { + return null; + } + + if (parsed.name === "task") { + return allowedToolNames.has("task") + ? buildTaskToolCall(jsonText, parsed.arguments) + : null; + } + + const writeToolName = resolveAllowedWriteToolName(allowedToolNames); + if (parsed.name !== "write" || !writeToolName) { return null; } @@ -78,45 +209,58 @@ export function extractBridgeToolCallFromText( }; } +function buildTaskToolCall( + jsonText: string, + args: Record, +): OpenAiToolCall | null { + if ( + !isNonEmptyString(args.description) + || !isNonEmptyString(args.prompt) + || !isNonEmptyString(args.subagent_type) + || (args.task_id !== undefined && typeof args.task_id !== "string") + || (args.command !== undefined && typeof args.command !== "string") + ) { + return null; + } + + return { + id: `call_bridge_${shortHash(jsonText)}`, + type: "function", + function: { + name: "task", + arguments: JSON.stringify(args), + }, + }; +} + export function extractBridgeToolCallFromStreamOutput( output: string, allowedToolNames: Set, writeSchema?: unknown, ): OpenAiToolCall | null { - if (!output || !resolveAllowedWriteToolName(allowedToolNames)) { + if (!output) { return null; } + const detector = new BridgeJsonStreamDetector(allowedToolNames, writeSchema); for (const line of output.split("\n")) { const event = parseStreamJsonLine(line); - if (!event || !isAssistantText(event)) { + if (!event) { continue; } - const text = extractText(event); - const toolCall = extractBridgeToolCallFromText(text, allowedToolNames, writeSchema) - ?? extractBridgeToolCallFromTrailingLine(text, allowedToolNames, writeSchema); - if (toolCall) { - return toolCall; + if (isAssistantText(event)) { + const decision = detector.push(event); + if (decision.action === "tool_call") { + return decision.toolCall; + } + } else if (event.type === "tool_call") { + detector.reset(); } } return null; } -function extractBridgeToolCallFromTrailingLine( - text: string, - allowedToolNames: Set, - writeSchema?: unknown, -): OpenAiToolCall | null { - const lines = text.trim().split(/\r?\n/).map((line) => line.trim()).filter(Boolean); - const lastLine = lines.at(-1); - if (!lastLine || !lastLine.startsWith("{") || !lastLine.endsWith("}")) { - return null; - } - - return extractBridgeToolCallFromText(lastLine, allowedToolNames, writeSchema); -} - function buildWriteArguments(path: string, content: string, writeSchema: unknown): Record { if (isRecord(writeSchema) && isRecord(writeSchema.properties)) { const properties = writeSchema.properties; @@ -154,6 +298,19 @@ function extractStrictJsonText(text: string): string | null { return fenced ? fenced[1].trim() : null; } +function containsCompleteJson(text: string): boolean { + const jsonText = extractStrictJsonText(text); + if (!jsonText) { + return false; + } + try { + JSON.parse(jsonText); + return true; + } catch { + return false; + } +} + function shortHash(value: string): string { return createHash("sha256").update(value).digest("hex").slice(0, 12); } @@ -161,3 +318,7 @@ function shortHash(value: string): string { function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } + +function isNonEmptyString(value: unknown): value is string { + return typeof value === "string" && value.trim().length > 0; +} diff --git a/src/proxy/prompt-builder.ts b/src/proxy/prompt-builder.ts index 9c48d6d..89b9a48 100644 --- a/src/proxy/prompt-builder.ts +++ b/src/proxy/prompt-builder.ts @@ -113,7 +113,7 @@ function formatAssistantToolCall( * Handles role:"tool" result messages and assistant tool_calls that * plain text flattening would silently drop. */ -export function buildPromptFromMessages(messages: Array, tools: Array, subagentNames: string[] = []): string { +export function buildPromptFromMessages(messages: Array, tools: Array): string { if (log.isDebugEnabled()) { const messageSummary = messages.map((m: any, i: number) => { const role = m?.role ?? "?"; @@ -171,15 +171,6 @@ export function buildPromptFromMessages(messages: Array, tools: Array, if (tools.length > 0) { lines.push(buildToolSchemaBlock(tools)); - const hasTaskTool = tools.some((t: any) => { - const name = (t?.function?.name ?? t?.name ?? "").toLowerCase(); - return name === "task"; - }); - if (hasTaskTool && subagentNames.length > 0) { - lines.push( - `When calling the task tool, set subagent_type to one of: ${subagentNames.join(", ")}. Do not omit this parameter.` - ); - } } for (const message of messages) { diff --git a/src/proxy/session-resume.ts b/src/proxy/session-resume.ts index 6eaad85..e967fa9 100644 --- a/src/proxy/session-resume.ts +++ b/src/proxy/session-resume.ts @@ -33,8 +33,6 @@ interface SessionResumeEntry { contentPrefix: string; /** Fingerprint of the tool schema active when the session was created. */ toolFingerprint?: string; - /** Fingerprint of the subagent list active when the session was created. */ - subagentFingerprint?: string; updatedAt: number; } @@ -165,17 +163,17 @@ export function isSessionResumeEnabled(): boolean { /** * Look up a cached cursor-agent chat ID for the given session key. * - * Validates TTL, content prefix, and tool/subagent fingerprints. A request that + * Validates TTL, content prefix, and tool fingerprint. A request that * supplies a non-empty fingerprint will evict a cached entry that was recorded * without that fingerprint (or with a different one), preventing a stale chat - * from being resumed with an incompatible tool/subagent schema. Returns + * from being resumed with incompatible tool metadata. Task descriptions include + * the active subagent list, so the tool fingerprint covers agent changes. Returns * undefined and evicts stale entries on any mismatch. */ export function getResumeChatId( sessionKey: string, expectedPrefix?: string, toolFingerprint?: string, - subagentFingerprint?: string, ): string | undefined { const entry = cache.get(sessionKey); if (!entry) return undefined; @@ -198,10 +196,6 @@ export function getResumeChatId( evictEntry(sessionKey, "toolFingerprintMismatch", {}, "warn"); return undefined; } - if ((subagentFingerprint || entry.subagentFingerprint) && entry.subagentFingerprint !== subagentFingerprint) { - evictEntry(sessionKey, "subagentFingerprintMismatch", {}, "warn"); - return undefined; - } // Refresh LRU order on a successful read. cache.delete(sessionKey); cache.set(sessionKey, entry); @@ -217,7 +211,6 @@ export function hasResumeChatId( sessionKey: string, expectedPrefix?: string, toolFingerprint?: string, - subagentFingerprint?: string, ): boolean { const entry = cache.get(sessionKey); if (!entry) return false; @@ -226,9 +219,6 @@ export function hasResumeChatId( if ((toolFingerprint || entry.toolFingerprint) && entry.toolFingerprint !== toolFingerprint) { return false; } - if ((subagentFingerprint || entry.subagentFingerprint) && entry.subagentFingerprint !== subagentFingerprint) { - return false; - } return !!entry.chatId; } @@ -243,7 +233,6 @@ export function recordResumeChatId( chatId: string, contentPrefix: string, toolFingerprint?: string, - subagentFingerprint?: string, ): void { if (!chatId) return; const trimmed = chatId.trim(); @@ -260,7 +249,6 @@ export function recordResumeChatId( chatId: trimmed, contentPrefix, toolFingerprint, - subagentFingerprint, updatedAt: Date.now(), }); while (cache.size > DEFAULT_MAX_ENTRIES) { diff --git a/tests/integration/opencode-loop.integration.test.ts b/tests/integration/opencode-loop.integration.test.ts index bf307d8..55c8670 100644 --- a/tests/integration/opencode-loop.integration.test.ts +++ b/tests/integration/opencode-loop.integration.test.ts @@ -68,6 +68,29 @@ const WRITE_TOOL = { }, }; +const TASK_TOOL = { + type: "function", + function: { + name: "task", + description: [ + "Launch an OpenCode subagent.", + "- general: built in", + "- global-proof: global", + "- project-proof: project local", + ].join("\n"), + parameters: { + type: "object", + properties: { + description: { type: "string" }, + prompt: { type: "string" }, + subagent_type: { type: "string" }, + }, + required: ["description", "prompt", "subagent_type"], + additionalProperties: false, + }, + }, +}; + const OPENCODE_EDIT_TOOL = { type: "function", function: { @@ -303,6 +326,115 @@ process.stdin.on("end", () => { }, }, ]; + } else if (scenario === "assistant-bridge-task-partials") { + events = [ + { + type: "assistant", + timestamp_ms: now + 1, + message: { + role: "assistant", + content: [{ type: "text", text: "{\\"name\\":\\"task\\"," }], + }, + }, + { + type: "assistant", + timestamp_ms: now + 2, + message: { + role: "assistant", + content: [{ type: "text", text: "\\"arguments\\":{\\"description\\":\\"Run project proof\\"," }], + }, + }, + { + type: "assistant", + timestamp_ms: now + 3, + message: { + role: "assistant", + content: [{ type: "text", text: "\\"prompt\\":\\"Follow your configured instructions.\\"," }], + }, + }, + { + type: "assistant", + timestamp_ms: now + 4, + message: { + role: "assistant", + content: [{ type: "text", text: "\\"subagent_type\\":\\"project-proof\\"}}" }], + }, + }, + { type: "result", subtype: "success", is_error: false }, + ]; + } else if (scenario === "assistant-bridge-task-mixed-thinking") { + events = [ + { + type: "assistant", + timestamp_ms: now + 1, + message: { + role: "assistant", + content: [ + { type: "thinking", thinking: "planning " }, + { type: "text", text: "{\\"name\\":\\"task\\",\\"arguments\\":{\\"description\\":\\"Run project proof\\"," }, + ], + }, + }, + { + type: "assistant", + timestamp_ms: now + 2, + message: { + role: "assistant", + content: [ + { type: "thinking", thinking: "delegation" }, + { + type: "text", + text: "\\"prompt\\":\\"Follow your configured instructions.\\",\\"subagent_type\\":\\"project-proof\\"}}", + }, + ], + }, + }, + { type: "result", subtype: "success", is_error: false }, + ]; + } else if (scenario === "assistant-bridge-malformed-partials") { + events = [ + { + type: "assistant", + timestamp_ms: now + 1, + message: { role: "assistant", content: [{ type: "text", text: "{not " }] }, + }, + { + type: "assistant", + timestamp_ms: now + 2, + message: { role: "assistant", content: [{ type: "text", text: "valid json" }] }, + }, + ]; + } else if (scenario === "assistant-python-fence-partials") { + events = [ + { + type: "assistant", + timestamp_ms: now + 1, + message: { role: "assistant", content: [{ type: "text", text: "\`\`\`py" }] }, + }, + { + type: "assistant", + timestamp_ms: now + 2, + message: { role: "assistant", content: [{ type: "text", text: "thon\\n" }] }, + }, + { + type: "assistant", + timestamp_ms: now + 3, + message: { role: "assistant", content: [{ type: "text", text: "print('ok')\\n\`\`\`" }] }, + }, + ]; + } else if (scenario === "assistant-json-answer-partials") { + events = [ + { + type: "assistant", + timestamp_ms: now + 1, + message: { role: "assistant", content: [{ type: "text", text: "{\\"answer\\":" }] }, + }, + { + type: "assistant", + timestamp_ms: now + 2, + message: { role: "assistant", content: [{ type: "text", text: "42}" }] }, + }, + ]; } else { events = [ { @@ -332,6 +464,7 @@ type StreamChunk = { choices?: Array<{ delta?: { content?: string; + reasoning_content?: string; tool_calls?: Array<{ function?: { name?: string; @@ -750,6 +883,120 @@ describe("OpenCode-owned tool loop integration", () => { expect(allContent).not.toContain('"name":"write"'); }); + it("reassembles streaming Task bridge JSON without leaking fragments", async () => { + process.env.MOCK_CURSOR_SCENARIO = "assistant-bridge-task-partials"; + process.env.MOCK_CURSOR_PROMPT_FILE = promptFile; + + const response = await requestCompletion(baseURL, { + model: "auto", + stream: true, + tools: [TASK_TOOL], + messages: [{ role: "user", content: "Delegate to project-proof" }], + }); + + const body = await response.text(); + const chunks = parseJsonChunks(parseSseData(body)); + const toolDelta = chunks.find((chunk) => chunk.choices?.[0]?.delta?.tool_calls?.length); + const toolCall = toolDelta?.choices?.[0]?.delta?.tool_calls?.[0]; + + expect(toolCall?.function?.name).toBe("task"); + expect(JSON.parse(toolCall?.function?.arguments ?? "{}")).toEqual({ + description: "Run project proof", + prompt: "Follow your configured instructions.", + subagent_type: "project-proof", + }); + expect(chunks.map((chunk) => chunk.choices?.[0]?.finish_reason).filter(Boolean)) + .toContain("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"'); + + const promptText = readFileSync(promptFile, "utf8"); + expect(promptText).toContain("project-proof"); + expect(promptText).toContain("Do not invoke Cursor's built-in Task tool"); + expect(promptText).not.toContain(["When calling", "the task tool"].join(" ")); + }); + + 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 = ""; + + const response = await requestCompletion(baseURL, { + model: "auto", + stream: true, + tools: [TASK_TOOL], + messages: [{ role: "user", content: "Delegate to project-proof" }], + }); + + const chunks = parseJsonChunks(parseSseData(await response.text())); + const reasoning = chunks + .map((chunk) => chunk.choices?.[0]?.delta?.reasoning_content) + .filter((value): value is string => typeof value === "string") + .join(""); + const toolDelta = chunks.find((chunk) => chunk.choices?.[0]?.delta?.tool_calls?.length); + const allContent = chunks + .map((chunk) => chunk.choices?.[0]?.delta?.content) + .filter((value): value is string => typeof value === "string") + .join(""); + + expect(reasoning).toBe("planning delegation"); + expect(toolDelta?.choices?.[0]?.delta?.tool_calls?.[0]?.function?.name).toBe("task"); + expect(allContent).not.toContain('"name":"task"'); + }); + + it("reassembles non-stream Task bridge JSON", async () => { + process.env.MOCK_CURSOR_SCENARIO = "assistant-bridge-task-partials"; + process.env.MOCK_CURSOR_PROMPT_FILE = ""; + + const response = await requestCompletion(baseURL, { + model: "auto", + stream: false, + tools: [TASK_TOOL], + messages: [{ role: "user", content: "Delegate to project-proof" }], + }); + + const json: any = await response.json(); + expect(json.choices?.[0]?.message?.tool_calls?.[0]?.function?.name).toBe("task"); + expect(json.choices?.[0]?.finish_reason).toBe("tool_calls"); + }); + + for (const fallback of [ + { + scenario: "assistant-bridge-malformed-partials", + expected: "{not valid json", + }, + { + scenario: "assistant-python-fence-partials", + expected: "```python\nprint('ok')\n```", + }, + { + scenario: "assistant-json-answer-partials", + expected: '{"answer":42}', + }, + ]) { + it(`passes ${fallback.scenario} through exactly once`, async () => { + process.env.MOCK_CURSOR_SCENARIO = fallback.scenario; + process.env.MOCK_CURSOR_PROMPT_FILE = ""; + + const response = await requestCompletion(baseURL, { + model: "auto", + stream: true, + tools: [TASK_TOOL], + messages: [{ role: "user", content: "Answer normally" }], + }); + + const chunks = parseJsonChunks(parseSseData(await response.text())); + const allContent = chunks + .map((chunk) => chunk.choices?.[0]?.delta?.content) + .filter((value): value is string => typeof value === "string") + .join(""); + expect(allContent).toBe(fallback.expected); + expect(chunks.some((chunk) => chunk.choices?.[0]?.delta?.tool_calls?.length)).toBe(false); + }); + } + it("skips streaming edit when write tool is not offered", async () => { process.env.MOCK_CURSOR_SCENARIO = "tool-edit-invalid"; process.env.MOCK_CURSOR_PROMPT_FILE = ""; diff --git a/tests/unit/mcp-config.test.ts b/tests/unit/mcp-config.test.ts deleted file mode 100644 index 8bbba9c..0000000 --- a/tests/unit/mcp-config.test.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { describe, it, expect, beforeEach } from "bun:test"; -import { readSubagentNames, _resetSubagentCache } from "../../src/mcp/config.js"; - -describe("readSubagentNames", () => { - beforeEach(() => { - _resetSubagentCache(); - }); - it("returns only mode:subagent agents when some exist", () => { - const config = JSON.stringify({ - agent: { - build: { mode: "primary", model: "openai/gpt-5" }, - codemachine: { mode: "subagent", model: "kimi/kimi-k2" }, - review: { mode: "subagent", model: "google/gemini" }, - }, - }); - expect(readSubagentNames({ configJson: config })).toEqual(["codemachine", "review"]); - }); - - it("returns all agents when none have mode:subagent", () => { - const config = JSON.stringify({ - agent: { - build: { mode: "primary", model: "openai/gpt-5" }, - plan: { mode: "primary", model: "zai/glm" }, - }, - }); - expect(readSubagentNames({ configJson: config })).toEqual(["build", "plan"]); - }); - - it("returns general-purpose when agent section is empty object", () => { - const config = JSON.stringify({ agent: {} }); - expect(readSubagentNames({ configJson: config })).toEqual(["general-purpose"]); - }); - - it("returns general-purpose when agent section is absent", () => { - const config = JSON.stringify({ mcp: {} }); - expect(readSubagentNames({ configJson: config })).toEqual(["general-purpose"]); - }); - - it("returns general-purpose when config file is unreadable", () => { - expect(readSubagentNames({ configJson: undefined, existsSync: () => false })).toEqual(["general-purpose"]); - }); - - it("returns general-purpose when config is malformed JSON", () => { - expect(readSubagentNames({ configJson: "{ bad json" })).toEqual(["general-purpose"]); - }); - - it("caches filesystem results across calls", () => { - let readCount = 0; - const deps = { - existsSync: () => true, - readFileSync: () => { - readCount++; - return JSON.stringify({ agent: { bot: { mode: "subagent" } } }); - }, - env: { OPENCODE_CONFIG: "/tmp/test.json" } as NodeJS.ProcessEnv, - }; - - const first = readSubagentNames(deps); - const second = readSubagentNames(deps); - expect(first).toEqual(["bot"]); - expect(second).toEqual(["bot"]); - expect(readCount).toBe(1); - }); - - it("bypasses cache when configJson is provided", () => { - const config1 = JSON.stringify({ agent: { a: { mode: "subagent" } } }); - const config2 = JSON.stringify({ agent: { b: { mode: "subagent" } } }); - - expect(readSubagentNames({ configJson: config1 })).toEqual(["a"]); - expect(readSubagentNames({ configJson: config2 })).toEqual(["b"]); - }); - - it("returns fresh data after cache reset", () => { - let callNum = 0; - const deps = { - existsSync: () => true, - readFileSync: () => { - callNum++; - const name = callNum === 1 ? "first" : "second"; - return JSON.stringify({ agent: { [name]: { mode: "subagent" } } }); - }, - env: { OPENCODE_CONFIG: "/tmp/test.json" } as NodeJS.ProcessEnv, - }; - - expect(readSubagentNames(deps)).toEqual(["first"]); - _resetSubagentCache(); - expect(readSubagentNames(deps)).toEqual(["second"]); - }); -}); diff --git a/tests/unit/plugin-system-message.test.ts b/tests/unit/plugin-system-message.test.ts index c0119de..60f38cc 100644 --- a/tests/unit/plugin-system-message.test.ts +++ b/tests/unit/plugin-system-message.test.ts @@ -1,33 +1,19 @@ import { describe, it, expect } from "bun:test"; import { buildAvailableToolsSystemMessage } from "../../src/plugin.js"; -describe("buildAvailableToolsSystemMessage — subagentNames injection", () => { - it("includes subagent names in task guidance", () => { +describe("buildAvailableToolsSystemMessage", () => { + it("does not add a filesystem-derived subagent list", () => { const msg = buildAvailableToolsSystemMessage( ["task", "read"], [{ id: "task", name: "task" }], [], [], - ["codemachine", "review"], - ); - expect(msg).toContain("codemachine"); - expect(msg).toContain("review"); - expect(msg).toContain("subagent_type"); - }); - - it("omits task guidance when subagentNames is empty", () => { - const msg = buildAvailableToolsSystemMessage( - ["task"], - [], - [], - [], - [], ); expect(msg).not.toContain("subagent_type"); }); - it("returns null when no tools and no subagentNames", () => { - const msg = buildAvailableToolsSystemMessage([], [], [], [], []); + it("returns null when no tools are available", () => { + const msg = buildAvailableToolsSystemMessage([], [], [], []); expect(msg).toBeNull(); }); }); diff --git a/tests/unit/proxy-prompt-builder.test.ts b/tests/unit/proxy-prompt-builder.test.ts deleted file mode 100644 index f79cbf4..0000000 --- a/tests/unit/proxy-prompt-builder.test.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { describe, it, expect } from "bun:test"; -import { buildPromptFromMessages } from "../../src/proxy/prompt-builder.js"; - -describe("buildPromptFromMessages — subagent_type injection", () => { - const taskTool = { function: { name: "task", description: "spawn a subagent", parameters: {} } }; - const otherTool = { function: { name: "read", description: "read a file", parameters: {} } }; - - it("injects guidance when tools include task and subagentNames provided", () => { - const prompt = buildPromptFromMessages( - [{ role: "user", content: "analyze this repo" }], - [taskTool], - ["general-purpose", "codemachine"], - ); - expect(prompt).toContain("general-purpose"); - expect(prompt).toContain("codemachine"); - expect(prompt).toContain("subagent_type"); - }); - - it("does not inject guidance when tools do not include task", () => { - const prompt = buildPromptFromMessages( - [{ role: "user", content: "read a file" }], - [otherTool], - ["general-purpose"], - ); - expect(prompt).not.toContain("subagent_type"); - }); - - it("does not inject guidance when subagentNames is empty", () => { - const prompt = buildPromptFromMessages( - [{ role: "user", content: "analyze" }], - [taskTool], - [], - ); - expect(prompt).not.toContain("subagent_type"); - }); - - it("works without third parameter (backwards compat)", () => { - expect(() => buildPromptFromMessages( - [{ role: "user", content: "hello" }], - [otherTool], - )).not.toThrow(); - }); -}); diff --git a/tests/unit/proxy/bridge-json.test.ts b/tests/unit/proxy/bridge-json.test.ts index 77104ba..2c58114 100644 --- a/tests/unit/proxy/bridge-json.test.ts +++ b/tests/unit/proxy/bridge-json.test.ts @@ -1,11 +1,39 @@ import { describe, expect, it } from "bun:test"; import { applyBridgeJsonPrompt, + BridgeJsonStreamDetector, extractBridgeToolCallFromStreamOutput, extractBridgeToolCallFromText, isBridgeJsonEnabled, } from "../../../src/proxy/bridge-json.js"; +const delta = (text: string) => ({ + type: "assistant" as const, + timestamp_ms: Date.now(), + message: { + role: "assistant" as const, + content: [{ type: "text" as const, text }], + }, +}); + +const snapshot = (text: string) => ({ + type: "assistant" as const, + model_call_id: "call-1", + message: { + role: "assistant" as const, + content: [{ type: "text" as const, text }], + }, +}); + +const TASK_JSON = JSON.stringify({ + name: "task", + arguments: { + description: "Run project proof", + prompt: "Follow your configured instructions.", + subagent_type: "project-proof", + }, +}); + describe("proxy/bridge-json", () => { it("extracts a strict write bridge response into an OpenAI tool call", () => { const toolCall = extractBridgeToolCallFromText( @@ -45,6 +73,60 @@ describe("proxy/bridge-json", () => { expect(toolCall).toBeNull(); }); + it("extracts a valid offered task bridge response", () => { + const call = extractBridgeToolCallFromText(TASK_JSON, new Set(["task"])); + + expect(call?.function.name).toBe("task"); + expect(JSON.parse(call?.function.arguments ?? "{}")).toEqual({ + description: "Run project proof", + prompt: "Follow your configured instructions.", + subagent_type: "project-proof", + }); + }); + + it("rejects task bridge responses when task is not offered", () => { + expect(extractBridgeToolCallFromText(TASK_JSON, new Set(["read"]))).toBeNull(); + }); + + for (const field of ["description", "prompt", "subagent_type"] as const) { + for (const invalid of [undefined, "", " ", 42]) { + it(`rejects task bridge responses with invalid ${field}: ${String(invalid)}`, () => { + const parsed = JSON.parse(TASK_JSON); + if (invalid === undefined) { + delete parsed.arguments[field]; + } else { + parsed.arguments[field] = invalid; + } + + expect( + extractBridgeToolCallFromText(JSON.stringify(parsed), new Set(["task"])), + ).toBeNull(); + }); + } + } + + it("preserves compatible optional task fields", () => { + const parsed = JSON.parse(TASK_JSON); + parsed.arguments.task_id = "task-123"; + parsed.arguments.command = "continue"; + parsed.arguments.future_option = { enabled: true }; + + const call = extractBridgeToolCallFromText(JSON.stringify(parsed), new Set(["task"])); + + expect(JSON.parse(call?.function.arguments ?? "{}")).toEqual(parsed.arguments); + }); + + for (const field of ["task_id", "command"] as const) { + it(`rejects a non-string optional ${field}`, () => { + const parsed = JSON.parse(TASK_JSON); + parsed.arguments[field] = 42; + + expect( + extractBridgeToolCallFromText(JSON.stringify(parsed), new Set(["task"])), + ).toBeNull(); + }); + } + it("extracts a later bridge response from stream-json output after prelude text", () => { const output = [ JSON.stringify({ @@ -80,7 +162,7 @@ describe("proxy/bridge-json", () => { expect(toolCall?.function.arguments).toBe('{"path":"demo.txt","content":"after read"}'); }); - it("extracts trailing bridge JSON from a streamed assistant message with prose before it", () => { + it("does not extract trailing bridge JSON after ordinary prose", () => { const output = JSON.stringify({ type: "assistant", message: { @@ -99,8 +181,25 @@ describe("proxy/bridge-json", () => { const toolCall = extractBridgeToolCallFromStreamOutput(output, new Set(["write"])); - expect(toolCall?.function.name).toBe("write"); - expect(toolCall?.function.arguments).toBe('{"path":"demo.txt","content":"after prose"}'); + expect(toolCall).toBeNull(); + }); + + it("extracts a split-delta task bridge response from stream output", () => { + const output = [ + delta('{"name":"task",'), + delta('"arguments":{"description":"Run project proof",'), + delta('"prompt":"Follow your configured instructions.",'), + delta('"subagent_type":"project-proof"}}'), + ].map(JSON.stringify).join("\n"); + + const call = extractBridgeToolCallFromStreamOutput(output, new Set(["task"])); + + expect(call?.function.name).toBe("task"); + expect(JSON.parse(call?.function.arguments ?? "{}")).toEqual({ + description: "Run project proof", + prompt: "Follow your configured instructions.", + subagent_type: "project-proof", + }); }); it("accepts contents as a bridge write content alias", () => { @@ -164,6 +263,34 @@ describe("proxy/bridge-json", () => { expect(isBridgeJsonEnabled({ CURSOR_ACP_BRIDGE_JSON: "false" })).toBe(false); }); + it("adds task bridge instructions only when task is offered", () => { + const basePrompt = + "SYSTEM: respond with a tool_call in the standard OpenAI format.\nUSER: delegate"; + const taskPrompt = applyBridgeJsonPrompt(basePrompt, { + allowedToolNames: new Set(["task"]), + env: {}, + }); + const readPrompt = applyBridgeJsonPrompt(basePrompt, { + allowedToolNames: new Set(["read"]), + env: {}, + }); + const disabled = applyBridgeJsonPrompt(basePrompt, { + allowedToolNames: new Set(["task"]), + env: { CURSOR_ACP_BRIDGE_JSON: "0" }, + }); + + expect(taskPrompt).toContain("Do not invoke Cursor's built-in Task tool"); + expect(taskPrompt).toContain('"name":"task"'); + expect(taskPrompt).toContain("overrides the earlier generic"); + expect(taskPrompt.indexOf("standard OpenAI")).toBeLessThan( + taskPrompt.indexOf("overrides the earlier generic"), + ); + expect(taskPrompt).toContain("Do not add id, type, or function fields"); + expect(taskPrompt).toContain("do not stringify arguments"); + expect(readPrompt).toBe(basePrompt); + expect(disabled).toBe(basePrompt); + }); + it("appends bridge instructions when only oc_write is available", () => { const prompt = applyBridgeJsonPrompt("USER: update demo.txt", { allowedToolNames: new Set(["oc_write"]), @@ -172,4 +299,114 @@ describe("proxy/bridge-json", () => { expect(prompt).toContain("opencode bridge mode"); }); + + describe("BridgeJsonStreamDetector", () => { + it("reassembles split Task JSON without leaking fragments", () => { + const detector = new BridgeJsonStreamDetector(new Set(["task"])); + + expect(detector.push(delta('{"name":"task",'))).toEqual({ action: "buffer" }); + expect(detector.push(delta('"arguments":{"description":"Run project proof",'))).toEqual({ + action: "buffer", + }); + expect(detector.push(delta('"prompt":"Follow your configured instructions.",'))).toEqual({ + action: "buffer", + }); + + 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(detector.flush()).toBe(""); + }); + + it("passes ordinary text through immediately", () => { + const detector = new BridgeJsonStreamDetector(new Set(["task"])); + + expect(detector.push(delta("Ordinary answer."))).toEqual({ action: "passthrough" }); + }); + + it("deduplicates cumulative snapshots", () => { + const detector = new BridgeJsonStreamDetector(new Set(["task"])); + + expect(detector.push(snapshot('{"name":"task",'))).toEqual({ action: "buffer" }); + const decision = detector.push(snapshot(TASK_JSON)); + + expect(decision.action).toBe("tool_call"); + if (decision.action === "tool_call") { + expect(JSON.parse(decision.toolCall.function.arguments)).toEqual( + JSON.parse(TASK_JSON).arguments, + ); + } + }); + + it("flushes malformed JSON exactly once", () => { + const detector = new BridgeJsonStreamDetector(new Set(["task"])); + + expect(detector.push(delta("{not "))).toEqual({ action: "buffer" }); + expect(detector.push(delta("json"))).toEqual({ action: "buffer" }); + expect(detector.flush()).toBe("{not json"); + expect(detector.flush()).toBe(""); + }); + + it("preserves held whitespace before ordinary text", () => { + const detector = new BridgeJsonStreamDetector(new Set(["task"])); + + expect(detector.push(delta(" "))).toEqual({ action: "buffer" }); + expect(detector.push(delta("answer"))).toEqual({ + action: "passthrough", + text: " answer", + }); + }); + + it("resets between assistant phases", () => { + const detector = new BridgeJsonStreamDetector(new Set(["task"])); + + expect(detector.push(delta("{incomplete"))).toEqual({ action: "buffer" }); + detector.reset(); + expect(detector.push(delta("later answer"))).toEqual({ action: "passthrough" }); + expect(detector.flush()).toBe(""); + }); + + it("releases a non-JSON fence when its info line completes", () => { + const detector = new BridgeJsonStreamDetector(new Set(["task"])); + + expect(detector.push(delta("```py"))).toEqual({ action: "buffer" }); + expect(detector.push(delta("thon\n"))).toEqual({ + action: "passthrough", + text: "```python\n", + }); + expect(detector.push(delta("print('ok')\n```"))).toEqual({ action: "passthrough" }); + expect(detector.flush()).toBe(""); + }); + + it("releases complete non-envelope JSON immediately", () => { + const detector = new BridgeJsonStreamDetector(new Set(["task"])); + + expect(detector.push(delta('{"answer":42}'))).toEqual({ + action: "passthrough", + text: '{"answer":42}', + }); + expect(detector.flush()).toBe(""); + }); + + it("buffers JSON followed by prose and flushes it verbatim", () => { + const detector = new BridgeJsonStreamDetector(new Set(["task"])); + const response = '{"answer":42} followed by prose'; + + expect(detector.push(delta(response))).toEqual({ action: "buffer" }); + expect(detector.flush()).toBe(response); + expect(detector.flush()).toBe(""); + }); + + it("releases a complete envelope for an unoffered tool", () => { + const detector = new BridgeJsonStreamDetector(new Set(["task"])); + const write = '{"name":"write","arguments":{"path":"demo.txt","content":"hello"}}'; + + expect(detector.push(delta(write))).toEqual({ + action: "passthrough", + text: write, + }); + }); + }); }); diff --git a/tests/unit/proxy/plugin-resume.test.ts b/tests/unit/proxy/plugin-resume.test.ts index 280838d..c473ed2 100644 --- a/tests/unit/proxy/plugin-resume.test.ts +++ b/tests/unit/proxy/plugin-resume.test.ts @@ -24,7 +24,6 @@ describe("plugin resume orchestration", () => { backend: "cursor-agent" as const, messages: [{ role: "user", content: "Remember BETA" }], tools: [] as any[], - subagentNames: [] as string[], model: "gpt-5", workspaceDirectory: "/workspace", }; @@ -355,20 +354,40 @@ describe("plugin resume orchestration", () => { expect(followUp.prompt).toContain("write"); }); - it("falls back to full prompt when subagent list changes", () => { + it("invalidates Task metadata through the tool fingerprint alone", () => { process.env.CURSOR_ACP_SESSION_RESUME = "1"; - const { sessionKey, contentPrefix, subagentFingerprint } = resolvePromptForBackend({ + + const task = (agent: string) => ({ + type: "function", + function: { + name: "task", + description: `Available subagents: ${agent}`, + parameters: { + type: "object", + properties: { subagent_type: { type: "string" } }, + required: ["subagent_type"], + }, + }, + }); + + const first = resolvePromptForBackend({ + ...baseInput, + tools: [task("global-proof")], + }); + const second = resolvePromptForBackend({ ...baseInput, - subagentNames: ["agent-a"], + tools: [task("project-proof")], }); + + expect(first.toolFingerprint).not.toBe(second.toolFingerprint); + captureResumeChatIdFromEvent( { type: "system", session_id: "chat-abc" } as any, - sessionKey, + first.sessionKey, "gpt-5", "/workspace", - contentPrefix, - undefined, - subagentFingerprint, + first.contentPrefix, + first.toolFingerprint, ); const followUp = resolvePromptForBackend({ @@ -378,7 +397,7 @@ describe("plugin resume orchestration", () => { { role: "assistant", content: "Got it." }, { role: "user", content: "What was the codeword?" }, ], - subagentNames: ["agent-a", "agent-b"], + tools: [task("project-proof")], }); expect(followUp.resumeChatId).toBeUndefined(); expect(followUp.usedIncremental).toBe(false); diff --git a/tests/unit/proxy/prompt-builder.test.ts b/tests/unit/proxy/prompt-builder.test.ts index 59a2403..fcf3b01 100644 --- a/tests/unit/proxy/prompt-builder.test.ts +++ b/tests/unit/proxy/prompt-builder.test.ts @@ -48,6 +48,39 @@ describe("buildPromptFromMessages", () => { expect(result).toContain("USER: Read foo.txt"); }); + it("uses the OpenCode task description without appending a separate subagent list", () => { + const task = { + type: "function", + function: { + name: "task", + description: [ + "Launch an OpenCode subagent.", + "- general: built in", + "- global-proof: global", + "- project-proof: project local", + ].join("\n"), + parameters: { + type: "object", + properties: { + description: { type: "string" }, + prompt: { type: "string" }, + subagent_type: { type: "string" }, + }, + required: ["description", "prompt", "subagent_type"], + }, + }, + }; + + const prompt = buildPromptFromMessages( + [{ role: "user", content: "delegate" }], + [task], + ); + + expect(prompt).toContain("global-proof"); + expect(prompt).toContain("project-proof"); + expect(prompt).not.toContain(["When calling", "the task tool"].join(" ")); + }); + it("handles role:tool result messages", () => { const messages = [ { role: "user", content: "Read the file" }, diff --git a/tests/unit/proxy/session-resume.test.ts b/tests/unit/proxy/session-resume.test.ts index a43636e..1a7d515 100644 --- a/tests/unit/proxy/session-resume.test.ts +++ b/tests/unit/proxy/session-resume.test.ts @@ -244,23 +244,11 @@ describe("session-resume", () => { expect(getResumeChatId(key)).toBeUndefined(); }); - it("evicts entry on subagent fingerprint mismatch", () => { - const key = buildSessionKey("/workspace", "gpt-5", "abc123"); - recordResumeChatId(key, "chat-uuid-1", "hello", undefined, "agents-v1"); - expect(getResumeChatId(key, "hello", undefined, "agents-v1")).toBe("chat-uuid-1"); - expect(getResumeChatId(key, "hello", undefined, "agents-v2")).toBeUndefined(); - expect(getResumeChatId(key)).toBeUndefined(); - }); - it("evicts entries recorded without a fingerprint when a request fingerprint is supplied", () => { const key = buildSessionKey("/workspace", "gpt-5", "abc123"); recordResumeChatId(key, "chat-uuid-1", "hello"); expect(getResumeChatId(key, "hello", "any-fp")).toBeUndefined(); expect(getResumeChatId(key)).toBeUndefined(); - - recordResumeChatId(key, "chat-uuid-2", "hello"); - expect(getResumeChatId(key, "hello", undefined, "any-subagent")).toBeUndefined(); - expect(getResumeChatId(key)).toBeUndefined(); }); it("evicts entries with a fingerprint when the request supplies none", () => { @@ -268,10 +256,6 @@ describe("session-resume", () => { recordResumeChatId(key, "chat-uuid-1", "hello", "fp-v1"); expect(getResumeChatId(key, "hello")).toBeUndefined(); expect(getResumeChatId(key)).toBeUndefined(); - - recordResumeChatId(key, "chat-uuid-2", "hello", undefined, "agents-v1"); - expect(getResumeChatId(key, "hello")).toBeUndefined(); - expect(getResumeChatId(key)).toBeUndefined(); }); it("hasResumeChatId reports presence without refreshing LRU order", () => {