From f356e4e793fa88695a6a2f2b060f8fa6eef0b642 Mon Sep 17 00:00:00 2001 From: OZoneGuy Date: Mon, 20 Jul 2026 15:12:53 -0400 Subject: [PATCH 1/9] Load subagent names from agents/ markdown files alongside JSON config. OpenCode also defines agents as *.md files under the config agents directory, so readSubagentNames now merges those definitions with opencode.json agents. Co-authored-by: Cursor --- src/mcp/config.ts | 114 +++++++++++++++++++++++++++------- tests/unit/mcp-config.test.ts | 48 ++++++++++++++ 2 files changed, 141 insertions(+), 21 deletions(-) diff --git a/src/mcp/config.ts b/src/mcp/config.ts index aeb99be..d598b0f 100644 --- a/src/mcp/config.ts +++ b/src/mcp/config.ts @@ -1,7 +1,9 @@ import { existsSync as nodeExistsSync, + readdirSync as nodeReaddirSync, readFileSync as nodeReadFileSync, } from "node:fs"; +import { dirname, join } from "node:path"; import { resolveOpenCodeConfigPath } from "../plugin-toggle.js"; import { createLogger } from "../utils/logger.js"; @@ -103,8 +105,10 @@ export function _resetSubagentCache(): void { interface ReadSubagentNamesDeps { configJson?: string; + configDir?: string; existsSync?: (path: string) => boolean; readFileSync?: (path: string, enc: BufferEncoding) => string; + readdirSync?: (path: string) => string[]; env?: NodeJS.ProcessEnv; } @@ -122,36 +126,67 @@ export function readSubagentNames(deps: ReadSubagentNamesDeps = {}): string[] { return result; } -function readSubagentNamesUncached(deps: ReadSubagentNamesDeps): string[] { - let raw: string; +function resolveAgentsDir(deps: ReadSubagentNamesDeps): string { + const configDir = deps.configDir + ?? dirname(resolveOpenCodeConfigPath(deps.env ?? process.env)); + return join(configDir, "agents"); +} - 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"]; - } +function parseAgentFrontmatter(content: string): { mode?: string; disable?: boolean } { + const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/); + if (!match) return {}; + + const meta: { mode?: string; disable?: boolean } = {}; + for (const line of match[1].split(/\r?\n/)) { + const entry = line.match(/^\s*([a-zA-Z_-]+)\s*:\s*(.+?)\s*$/); + if (!entry) continue; + const [, key, value] = entry; + if (key === "mode") meta.mode = value; + if (key === "disable" && value === "true") meta.disable = true; } + return meta; +} - let parsed: Record; +function readAgentsFromDirectory(deps: ReadSubagentNamesDeps): Record { + if (deps.configJson != null && deps.configDir == null && deps.readdirSync == null) { + return {}; + } + + const exists = deps.existsSync ?? nodeExistsSync; + const readFile = deps.readFileSync ?? nodeReadFileSync; + const readdir = deps.readdirSync ?? nodeReaddirSync; + const agentsDir = resolveAgentsDir(deps); + if (!exists(agentsDir)) return {}; + + let files: string[]; try { - parsed = JSON.parse(raw); + files = readdir(agentsDir); } catch { - return ["general-purpose"]; + return {}; } - const agentSection = parsed.agent; - if (!agentSection || typeof agentSection !== "object" || Array.isArray(agentSection)) { - return ["general-purpose"]; + const agents: Record = {}; + for (const file of files) { + if (!file.endsWith(".md")) continue; + + const filePath = join(agentsDir, file); + let content: string; + try { + content = readFile(filePath, "utf8"); + } catch { + continue; + } + + const meta = parseAgentFrontmatter(content); + if (meta.disable) continue; + + agents[file.slice(0, -3)] = meta.mode ? { mode: meta.mode } : {}; } - const agents = agentSection as Record; + return agents; +} + +function pickSubagentNames(agents: Record): string[] { const names = Object.keys(agents); if (names.length === 0) return ["general-purpose"]; @@ -164,6 +199,43 @@ function readSubagentNamesUncached(deps: ReadSubagentNamesDeps): string[] { return subagentNames.length > 0 ? subagentNames : names; } +function readAgentsFromConfigJson(raw: string): Record { + try { + const parsed = JSON.parse(raw) as Record; + const agentSection = parsed.agent; + if (!agentSection || typeof agentSection !== "object" || Array.isArray(agentSection)) { + return {}; + } + return { ...(agentSection as Record) }; + } catch { + return {}; + } +} + +function readSubagentNamesUncached(deps: ReadSubagentNamesDeps): string[] { + let agents: Record; + + if (deps.configJson != null) { + agents = readAgentsFromConfigJson(deps.configJson); + } else { + const exists = deps.existsSync ?? nodeExistsSync; + const readFile = deps.readFileSync ?? nodeReadFileSync; + const configPath = resolveOpenCodeConfigPath(deps.env ?? process.env); + if (!exists(configPath)) { + agents = {}; + } else { + try { + agents = readAgentsFromConfigJson(readFile(configPath, "utf8")); + } catch { + agents = {}; + } + } + } + + Object.assign(agents, readAgentsFromDirectory(deps)); + return pickSubagentNames(agents); +} + function isStringRecord(v: unknown): v is Record { return typeof v === "object" && v !== null && !Array.isArray(v); } diff --git a/tests/unit/mcp-config.test.ts b/tests/unit/mcp-config.test.ts index 8bbba9c..eab68d3 100644 --- a/tests/unit/mcp-config.test.ts +++ b/tests/unit/mcp-config.test.ts @@ -86,4 +86,52 @@ describe("readSubagentNames", () => { _resetSubagentCache(); expect(readSubagentNames(deps)).toEqual(["second"]); }); + + it("includes agents from agents/ directory", () => { + const deps = { + configDir: "/tmp/opencode", + existsSync: (p: string) => p === "/tmp/opencode/agents", + readdirSync: () => ["reviewer.md"], + readFileSync: (p: string) => { + if (p === "/tmp/opencode/agents/reviewer.md") { + return "---\nmode: subagent\ndescription: Reviews code\n---\nYou review code."; + } + return "{}"; + }, + }; + + expect(readSubagentNames(deps)).toEqual(["reviewer"]); + }); + + it("merges json agents with agents/ directory, directory wins on name clash", () => { + const deps = { + configJson: JSON.stringify({ agent: { build: { mode: "primary" }, review: { mode: "subagent" } } }), + configDir: "/tmp/opencode", + existsSync: (p: string) => p === "/tmp/opencode/agents", + readdirSync: () => ["review.md"], + readFileSync: (p: string) => { + if (p === "/tmp/opencode/agents/review.md") { + return "---\nmode: subagent\n---\nReview agent."; + } + return "{}"; + }, + }; + + expect(readSubagentNames(deps)).toEqual(["review"]); + }); + + it("skips disabled markdown agents", () => { + const deps = { + configDir: "/tmp/opencode", + existsSync: (p: string) => p === "/tmp/opencode/agents", + readdirSync: () => ["hidden.md", "active.md"], + readFileSync: (p: string) => { + if (p.endsWith("hidden.md")) return "---\ndisable: true\nmode: subagent\n---\n"; + if (p.endsWith("active.md")) return "---\nmode: subagent\n---\n"; + return "{}"; + }, + }; + + expect(readSubagentNames(deps)).toEqual(["active"]); + }); }); From 4759f1e23f612d9e748787ecfe8a692d7cbeaf07 Mon Sep 17 00:00:00 2001 From: Nomadcxx Date: Tue, 21 Jul 2026 13:49:35 +0000 Subject: [PATCH 2/9] feat(agents): align subagent loader with OpenCode agent discovery Read from both `agent/` and `agents/` (OpenCode globs {agent,agents}/**/*.md), scan recursively with path-based names, and parse quoted/alternate frontmatter values (`mode: "subagent"`, `disable: "true"`, `disable: yes`). Adds unit tests plus a real-filesystem test exercising both directories and nested agents on disk. --- src/mcp/config.ts | 59 +++++++++++++++++----------- tests/unit/mcp-config.test.ts | 73 +++++++++++++++++++++++++++++++++++ 2 files changed, 110 insertions(+), 22 deletions(-) diff --git a/src/mcp/config.ts b/src/mcp/config.ts index d598b0f..fea3877 100644 --- a/src/mcp/config.ts +++ b/src/mcp/config.ts @@ -108,7 +108,7 @@ interface ReadSubagentNamesDeps { configDir?: string; existsSync?: (path: string) => boolean; readFileSync?: (path: string, enc: BufferEncoding) => string; - readdirSync?: (path: string) => string[]; + readdirSync?: (path: string, options?: { recursive?: boolean }) => string[]; env?: NodeJS.ProcessEnv; } @@ -126,10 +126,21 @@ export function readSubagentNames(deps: ReadSubagentNamesDeps = {}): string[] { return result; } -function resolveAgentsDir(deps: ReadSubagentNamesDeps): string { +function resolveAgentDirs(deps: ReadSubagentNamesDeps): string[] { const configDir = deps.configDir ?? dirname(resolveOpenCodeConfigPath(deps.env ?? process.env)); - return join(configDir, "agents"); + // OpenCode loads agents from both `agent/` and `agents/` (glob {agent,agents}/**/*.md). + return [join(configDir, "agent"), join(configDir, "agents")]; +} + +function unquoteFrontmatterValue(value: string): string { + const match = value.match(/^(["'])(.*)\1$/); + return match ? match[2] : value; +} + +function isFrontmatterTrue(value: string): boolean { + const normalized = unquoteFrontmatterValue(value).toLowerCase(); + return normalized === "true" || normalized === "yes" || normalized === "on"; } function parseAgentFrontmatter(content: string): { mode?: string; disable?: boolean } { @@ -141,8 +152,8 @@ function parseAgentFrontmatter(content: string): { mode?: string; disable?: bool const entry = line.match(/^\s*([a-zA-Z_-]+)\s*:\s*(.+?)\s*$/); if (!entry) continue; const [, key, value] = entry; - if (key === "mode") meta.mode = value; - if (key === "disable" && value === "true") meta.disable = true; + if (key === "mode") meta.mode = unquoteFrontmatterValue(value); + if (key === "disable" && isFrontmatterTrue(value)) meta.disable = true; } return meta; } @@ -155,32 +166,36 @@ function readAgentsFromDirectory(deps: ReadSubagentNamesDeps): Record = {}; - for (const file of files) { - if (!file.endsWith(".md")) continue; + for (const agentsDir of resolveAgentDirs(deps)) { + if (!exists(agentsDir)) continue; - const filePath = join(agentsDir, file); - let content: string; + let files: string[]; try { - content = readFile(filePath, "utf8"); + files = readdir(agentsDir, { recursive: true }); } catch { continue; } - const meta = parseAgentFrontmatter(content); - if (meta.disable) continue; + for (const file of files) { + if (!file.endsWith(".md")) continue; - agents[file.slice(0, -3)] = meta.mode ? { mode: meta.mode } : {}; + const filePath = join(agentsDir, file); + let content: string; + try { + content = readFile(filePath, "utf8"); + } catch { + continue; + } + + const meta = parseAgentFrontmatter(content); + if (meta.disable) continue; + + // Match OpenCode's path-based agent name (forward slashes, no extension). + const name = file.slice(0, -3).split(/[\\/]/).join("/"); + agents[name] = meta.mode ? { mode: meta.mode } : {}; + } } return agents; diff --git a/tests/unit/mcp-config.test.ts b/tests/unit/mcp-config.test.ts index eab68d3..87550f4 100644 --- a/tests/unit/mcp-config.test.ts +++ b/tests/unit/mcp-config.test.ts @@ -1,4 +1,7 @@ import { describe, it, expect, beforeEach } from "bun:test"; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { readSubagentNames, _resetSubagentCache } from "../../src/mcp/config.js"; describe("readSubagentNames", () => { @@ -134,4 +137,74 @@ describe("readSubagentNames", () => { expect(readSubagentNames(deps)).toEqual(["active"]); }); + + it("includes agents from the singular agent/ directory", () => { + const deps = { + configDir: "/tmp/opencode", + existsSync: (p: string) => p === "/tmp/opencode/agent", + readdirSync: () => ["helper.md"], + readFileSync: () => "---\nmode: subagent\n---\n", + }; + + expect(readSubagentNames(deps)).toEqual(["helper"]); + }); + + it("scans agent directories recursively", () => { + let recursiveOption: unknown; + const deps = { + configDir: "/tmp/opencode", + existsSync: (p: string) => p === "/tmp/opencode/agents", + readdirSync: (_p: string, options?: { recursive?: boolean }) => { + recursiveOption = options; + return ["team/reviewer.md"]; + }, + readFileSync: () => "---\nmode: subagent\n---\n", + }; + + expect(readSubagentNames(deps)).toEqual(["team/reviewer"]); + expect(recursiveOption).toEqual({ recursive: true }); + }); + + it("recognizes quoted mode values", () => { + const deps = { + configDir: "/tmp/opencode", + existsSync: (p: string) => p === "/tmp/opencode/agents", + readdirSync: () => ["reviewer.md", "builder.md"], + readFileSync: (p: string) => + p.endsWith("reviewer.md") ? '---\nmode: "subagent"\n---\n' : "---\nmode: primary\n---\n", + }; + + expect(readSubagentNames(deps)).toEqual(["reviewer"]); + }); + + it("disables agents with quoted or yes disable values", () => { + const deps = { + configDir: "/tmp/opencode", + existsSync: (p: string) => p === "/tmp/opencode/agents", + readdirSync: () => ["hidden.md", "off.md", "active.md"], + readFileSync: (p: string) => { + if (p.endsWith("hidden.md")) return '---\ndisable: "true"\nmode: subagent\n---\n'; + if (p.endsWith("off.md")) return "---\ndisable: yes\nmode: subagent\n---\n"; + return "---\nmode: subagent\n---\n"; + }, + }; + + expect(readSubagentNames(deps)).toEqual(["active"]); + }); + + it("reads real agent markdown from disk across agent/ and agents/, including nested", () => { + const dir = mkdtempSync(join(tmpdir(), "oc-agents-")); + try { + mkdirSync(join(dir, "agent"), { recursive: true }); + mkdirSync(join(dir, "agents", "team"), { recursive: true }); + writeFileSync(join(dir, "agent", "reviewer.md"), "---\nmode: subagent\n---\nReview."); + writeFileSync(join(dir, "agents", "team", "builder.md"), '---\nmode: "subagent"\n---\nBuild.'); + + // configJson "{}" isolates the JSON side; the directory side uses the real fs. + const names = readSubagentNames({ configJson: "{}", configDir: dir }).sort(); + expect(names).toEqual(["reviewer", "team/builder"]); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); }); From 427b3c603a63929e1d824773ffc03aa97a9c7ee5 Mon Sep 17 00:00:00 2001 From: Nomadcxx Date: Tue, 21 Jul 2026 14:32:02 +0000 Subject: [PATCH 3/9] docs(agents): comment the test-only guard and frontmatter subset scope --- src/mcp/config.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/mcp/config.ts b/src/mcp/config.ts index fea3877..f808b70 100644 --- a/src/mcp/config.ts +++ b/src/mcp/config.ts @@ -143,6 +143,9 @@ function isFrontmatterTrue(value: string): boolean { return normalized === "true" || normalized === "yes" || normalized === "on"; } +// Reads only `mode` and `disable` from frontmatter — an intentional two-key subset, +// not a full YAML parser. Inline comments (`disable: true # off`) and block scalars +// are out of scope; upstream reads real YAML. function parseAgentFrontmatter(content: string): { mode?: string; disable?: boolean } { const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/); if (!match) return {}; @@ -159,6 +162,8 @@ function parseAgentFrontmatter(content: string): { mode?: string; disable?: bool } function readAgentsFromDirectory(deps: ReadSubagentNamesDeps): Record { + // Test-only shortcut: unit tests pass `configJson` alone (no directory deps) to + // stay hermetic and off the real filesystem. No production caller passes `configJson`. if (deps.configJson != null && deps.configDir == null && deps.readdirSync == null) { return {}; } From bd0e41df7598d98b7af6d0c7a4c2d85244e2e745 Mon Sep 17 00:00:00 2001 From: Nomadcxx Date: Tue, 21 Jul 2026 15:02:41 +0000 Subject: [PATCH 4/9] feat(agents): honor `disable` for JSON-defined agents OpenCode deletes JSON agents marked `disable: true` (agent/agent.ts). The loader passed them through, so markdown `disable` worked but JSON `disable` did not. Filter disabled JSON agents to match, with a regression test. --- src/mcp/config.ts | 10 +++++++++- tests/unit/mcp-config.test.ts | 13 +++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/src/mcp/config.ts b/src/mcp/config.ts index f808b70..e6fb785 100644 --- a/src/mcp/config.ts +++ b/src/mcp/config.ts @@ -226,7 +226,15 @@ function readAgentsFromConfigJson(raw: string): Record { if (!agentSection || typeof agentSection !== "object" || Array.isArray(agentSection)) { return {}; } - return { ...(agentSection as Record) }; + const agents: Record = {}; + for (const [name, entry] of Object.entries(agentSection as Record)) { + // Match OpenCode: a JSON agent with `disable: true` is dropped. + if (entry && typeof entry === "object" && (entry as Record).disable === true) { + continue; + } + agents[name] = entry; + } + return agents; } catch { return {}; } diff --git a/tests/unit/mcp-config.test.ts b/tests/unit/mcp-config.test.ts index 87550f4..23ec689 100644 --- a/tests/unit/mcp-config.test.ts +++ b/tests/unit/mcp-config.test.ts @@ -138,6 +138,19 @@ describe("readSubagentNames", () => { expect(readSubagentNames(deps)).toEqual(["active"]); }); + it("skips disabled JSON-defined agents", () => { + const deps = { + configJson: JSON.stringify({ + agent: { + build: { mode: "subagent" }, + legacy: { mode: "subagent", disable: true }, + }, + }), + }; + + expect(readSubagentNames(deps)).toEqual(["build"]); + }); + it("includes agents from the singular agent/ directory", () => { const deps = { configDir: "/tmp/opencode", From 773c5078262638de3c546b9257a4f4e38a4f0740 Mon Sep 17 00:00:00 2001 From: Nomadcxx Date: Fri, 24 Jul 2026 20:48:44 +1000 Subject: [PATCH 5/9] refactor: trust runtime task metadata --- package.json | 2 +- src/mcp/config.ts | 171 ----------------- src/plugin.ts | 45 +---- src/proxy/prompt-builder.ts | 11 +- src/proxy/session-resume.ts | 18 +- tests/unit/mcp-config.test.ts | 223 ----------------------- tests/unit/plugin-system-message.test.ts | 22 +-- tests/unit/proxy-prompt-builder.test.ts | 43 ----- tests/unit/proxy/plugin-resume.test.ts | 37 +++- tests/unit/proxy/prompt-builder.test.ts | 33 ++++ tests/unit/proxy/session-resume.test.ts | 16 -- 11 files changed, 76 insertions(+), 545 deletions(-) delete mode 100644 tests/unit/mcp-config.test.ts delete mode 100644 tests/unit/proxy-prompt-builder.test.ts 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 e6fb785..1b704aa 100644 --- a/src/mcp/config.ts +++ b/src/mcp/config.ts @@ -1,9 +1,7 @@ import { existsSync as nodeExistsSync, - readdirSync as nodeReaddirSync, readFileSync as nodeReadFileSync, } from "node:fs"; -import { dirname, join } from "node:path"; import { resolveOpenCodeConfigPath } from "../plugin-toggle.js"; import { createLogger } from "../utils/logger.js"; @@ -95,175 +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; - configDir?: string; - existsSync?: (path: string) => boolean; - readFileSync?: (path: string, enc: BufferEncoding) => string; - readdirSync?: (path: string, options?: { recursive?: boolean }) => 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 resolveAgentDirs(deps: ReadSubagentNamesDeps): string[] { - const configDir = deps.configDir - ?? dirname(resolveOpenCodeConfigPath(deps.env ?? process.env)); - // OpenCode loads agents from both `agent/` and `agents/` (glob {agent,agents}/**/*.md). - return [join(configDir, "agent"), join(configDir, "agents")]; -} - -function unquoteFrontmatterValue(value: string): string { - const match = value.match(/^(["'])(.*)\1$/); - return match ? match[2] : value; -} - -function isFrontmatterTrue(value: string): boolean { - const normalized = unquoteFrontmatterValue(value).toLowerCase(); - return normalized === "true" || normalized === "yes" || normalized === "on"; -} - -// Reads only `mode` and `disable` from frontmatter — an intentional two-key subset, -// not a full YAML parser. Inline comments (`disable: true # off`) and block scalars -// are out of scope; upstream reads real YAML. -function parseAgentFrontmatter(content: string): { mode?: string; disable?: boolean } { - const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/); - if (!match) return {}; - - const meta: { mode?: string; disable?: boolean } = {}; - for (const line of match[1].split(/\r?\n/)) { - const entry = line.match(/^\s*([a-zA-Z_-]+)\s*:\s*(.+?)\s*$/); - if (!entry) continue; - const [, key, value] = entry; - if (key === "mode") meta.mode = unquoteFrontmatterValue(value); - if (key === "disable" && isFrontmatterTrue(value)) meta.disable = true; - } - return meta; -} - -function readAgentsFromDirectory(deps: ReadSubagentNamesDeps): Record { - // Test-only shortcut: unit tests pass `configJson` alone (no directory deps) to - // stay hermetic and off the real filesystem. No production caller passes `configJson`. - if (deps.configJson != null && deps.configDir == null && deps.readdirSync == null) { - return {}; - } - - const exists = deps.existsSync ?? nodeExistsSync; - const readFile = deps.readFileSync ?? nodeReadFileSync; - const readdir = deps.readdirSync ?? nodeReaddirSync; - - const agents: Record = {}; - for (const agentsDir of resolveAgentDirs(deps)) { - if (!exists(agentsDir)) continue; - - let files: string[]; - try { - files = readdir(agentsDir, { recursive: true }); - } catch { - continue; - } - - for (const file of files) { - if (!file.endsWith(".md")) continue; - - const filePath = join(agentsDir, file); - let content: string; - try { - content = readFile(filePath, "utf8"); - } catch { - continue; - } - - const meta = parseAgentFrontmatter(content); - if (meta.disable) continue; - - // Match OpenCode's path-based agent name (forward slashes, no extension). - const name = file.slice(0, -3).split(/[\\/]/).join("/"); - agents[name] = meta.mode ? { mode: meta.mode } : {}; - } - } - - return agents; -} - -function pickSubagentNames(agents: Record): string[] { - 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 readAgentsFromConfigJson(raw: string): Record { - try { - const parsed = JSON.parse(raw) as Record; - const agentSection = parsed.agent; - if (!agentSection || typeof agentSection !== "object" || Array.isArray(agentSection)) { - return {}; - } - const agents: Record = {}; - for (const [name, entry] of Object.entries(agentSection as Record)) { - // Match OpenCode: a JSON agent with `disable: true` is dropped. - if (entry && typeof entry === "object" && (entry as Record).disable === true) { - continue; - } - agents[name] = entry; - } - return agents; - } catch { - return {}; - } -} - -function readSubagentNamesUncached(deps: ReadSubagentNamesDeps): string[] { - let agents: Record; - - if (deps.configJson != null) { - agents = readAgentsFromConfigJson(deps.configJson); - } else { - const exists = deps.existsSync ?? nodeExistsSync; - const readFile = deps.readFileSync ?? nodeReadFileSync; - const configPath = resolveOpenCodeConfigPath(deps.env ?? process.env); - if (!exists(configPath)) { - agents = {}; - } else { - try { - agents = readAgentsFromConfigJson(readFile(configPath, "utf8")); - } catch { - agents = {}; - } - } - } - - Object.assign(agents, readAgentsFromDirectory(deps)); - return pickSubagentNames(agents); -} - 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..dae6e20 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -59,7 +59,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 +121,6 @@ export function buildAvailableToolsSystemMessage( lastToolMap: Array<{ id: string; name: string }>, mcpToolDefs: any[], mcpToolSummaries?: McpToolSummary[], - subagentNames: string[] = [], ): string | null { const parts: string[] = []; @@ -166,12 +165,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 +286,6 @@ export interface ResolvedPrompt { contentPrefix?: string; recordContentPrefix?: string; toolFingerprint?: string; - subagentFingerprint?: string; } /** @@ -312,13 +304,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 +330,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 +339,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 +357,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 +379,6 @@ export function captureResumeChatIdFromEvent( workspaceDirectory: string, contentPrefix?: string, toolFingerprint?: string, - subagentFingerprint?: string, ): void { if (!sessionKey || !isSessionResumeEnabled()) return; const chatId = event.session_id; @@ -400,7 +389,6 @@ export function captureResumeChatIdFromEvent( chatId.trim(), contentPrefix ?? "", toolFingerprint, - subagentFingerprint, ); return; } @@ -424,7 +412,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 +424,6 @@ export function captureResumeChatIdFromOutput( workspaceDirectory, contentPrefix, toolFingerprint, - subagentFingerprint, ); } } @@ -493,7 +479,6 @@ function warnIfResumeNotCaptured( sessionResumeKeyHash: string | undefined, sessionResumeContentPrefix: string | undefined, sessionResumeToolFingerprint: string | undefined, - sessionResumeSubagentFingerprint: string | undefined, model: string, ): void { if ( @@ -503,7 +488,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 +1258,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 +1269,6 @@ async function ensureCursorProxyServer(workspaceDirectory: string, toolRouter?: backend, messages, tools, - subagentNames, model, workspaceDirectory, }); @@ -1298,7 +1280,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 +1339,12 @@ async function ensureCursorProxyServer(workspaceDirectory: string, toolRouter?: workspaceDirectory, sessionResumeRecordContentPrefix, sessionResumeToolFingerprint, - sessionResumeSubagentFingerprint, ); warnIfResumeNotCaptured( sessionResumeKey, sessionResumeKeyHash, sessionResumeRecordContentPrefix, sessionResumeToolFingerprint, - sessionResumeSubagentFingerprint, model, ); const meta = { @@ -1550,7 +1529,6 @@ async function ensureCursorProxyServer(workspaceDirectory: string, toolRouter?: workspaceDirectory, sessionResumeRecordContentPrefix, sessionResumeToolFingerprint, - sessionResumeSubagentFingerprint, ); if (isResult(event)) { @@ -1649,7 +1627,6 @@ async function ensureCursorProxyServer(workspaceDirectory: string, toolRouter?: workspaceDirectory, sessionResumeRecordContentPrefix, sessionResumeToolFingerprint, - sessionResumeSubagentFingerprint, ); if (isResult(event)) { usage = extractOpenAiUsageFromResult(event) ?? usage; @@ -1768,7 +1745,6 @@ async function ensureCursorProxyServer(workspaceDirectory: string, toolRouter?: sessionResumeKeyHash, sessionResumeRecordContentPrefix, sessionResumeToolFingerprint, - sessionResumeSubagentFingerprint, model, ); @@ -1891,7 +1867,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 +1878,6 @@ async function ensureCursorProxyServer(workspaceDirectory: string, toolRouter?: backend, messages, tools, - subagentNames, model, workspaceDirectory, }); @@ -1915,7 +1889,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 +1957,12 @@ async function ensureCursorProxyServer(workspaceDirectory: string, toolRouter?: workspaceDirectory, sessionResumeRecordContentPrefix, sessionResumeToolFingerprint, - sessionResumeSubagentFingerprint, ); warnIfResumeNotCaptured( sessionResumeKey, sessionResumeKeyHashNode, sessionResumeRecordContentPrefix, sessionResumeToolFingerprint, - sessionResumeSubagentFingerprint, model, ); const meta = { @@ -2196,7 +2167,6 @@ async function ensureCursorProxyServer(workspaceDirectory: string, toolRouter?: workspaceDirectory, sessionResumeRecordContentPrefix, sessionResumeToolFingerprint, - sessionResumeSubagentFingerprint, ); if (isResult(event)) { @@ -2331,7 +2301,6 @@ async function ensureCursorProxyServer(workspaceDirectory: string, toolRouter?: sessionResumeKeyHashNode, sessionResumeRecordContentPrefix, sessionResumeToolFingerprint, - sessionResumeSubagentFingerprint, model, ); @@ -2969,10 +2938,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/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/unit/mcp-config.test.ts b/tests/unit/mcp-config.test.ts deleted file mode 100644 index 23ec689..0000000 --- a/tests/unit/mcp-config.test.ts +++ /dev/null @@ -1,223 +0,0 @@ -import { describe, it, expect, beforeEach } from "bun:test"; -import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -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"]); - }); - - it("includes agents from agents/ directory", () => { - const deps = { - configDir: "/tmp/opencode", - existsSync: (p: string) => p === "/tmp/opencode/agents", - readdirSync: () => ["reviewer.md"], - readFileSync: (p: string) => { - if (p === "/tmp/opencode/agents/reviewer.md") { - return "---\nmode: subagent\ndescription: Reviews code\n---\nYou review code."; - } - return "{}"; - }, - }; - - expect(readSubagentNames(deps)).toEqual(["reviewer"]); - }); - - it("merges json agents with agents/ directory, directory wins on name clash", () => { - const deps = { - configJson: JSON.stringify({ agent: { build: { mode: "primary" }, review: { mode: "subagent" } } }), - configDir: "/tmp/opencode", - existsSync: (p: string) => p === "/tmp/opencode/agents", - readdirSync: () => ["review.md"], - readFileSync: (p: string) => { - if (p === "/tmp/opencode/agents/review.md") { - return "---\nmode: subagent\n---\nReview agent."; - } - return "{}"; - }, - }; - - expect(readSubagentNames(deps)).toEqual(["review"]); - }); - - it("skips disabled markdown agents", () => { - const deps = { - configDir: "/tmp/opencode", - existsSync: (p: string) => p === "/tmp/opencode/agents", - readdirSync: () => ["hidden.md", "active.md"], - readFileSync: (p: string) => { - if (p.endsWith("hidden.md")) return "---\ndisable: true\nmode: subagent\n---\n"; - if (p.endsWith("active.md")) return "---\nmode: subagent\n---\n"; - return "{}"; - }, - }; - - expect(readSubagentNames(deps)).toEqual(["active"]); - }); - - it("skips disabled JSON-defined agents", () => { - const deps = { - configJson: JSON.stringify({ - agent: { - build: { mode: "subagent" }, - legacy: { mode: "subagent", disable: true }, - }, - }), - }; - - expect(readSubagentNames(deps)).toEqual(["build"]); - }); - - it("includes agents from the singular agent/ directory", () => { - const deps = { - configDir: "/tmp/opencode", - existsSync: (p: string) => p === "/tmp/opencode/agent", - readdirSync: () => ["helper.md"], - readFileSync: () => "---\nmode: subagent\n---\n", - }; - - expect(readSubagentNames(deps)).toEqual(["helper"]); - }); - - it("scans agent directories recursively", () => { - let recursiveOption: unknown; - const deps = { - configDir: "/tmp/opencode", - existsSync: (p: string) => p === "/tmp/opencode/agents", - readdirSync: (_p: string, options?: { recursive?: boolean }) => { - recursiveOption = options; - return ["team/reviewer.md"]; - }, - readFileSync: () => "---\nmode: subagent\n---\n", - }; - - expect(readSubagentNames(deps)).toEqual(["team/reviewer"]); - expect(recursiveOption).toEqual({ recursive: true }); - }); - - it("recognizes quoted mode values", () => { - const deps = { - configDir: "/tmp/opencode", - existsSync: (p: string) => p === "/tmp/opencode/agents", - readdirSync: () => ["reviewer.md", "builder.md"], - readFileSync: (p: string) => - p.endsWith("reviewer.md") ? '---\nmode: "subagent"\n---\n' : "---\nmode: primary\n---\n", - }; - - expect(readSubagentNames(deps)).toEqual(["reviewer"]); - }); - - it("disables agents with quoted or yes disable values", () => { - const deps = { - configDir: "/tmp/opencode", - existsSync: (p: string) => p === "/tmp/opencode/agents", - readdirSync: () => ["hidden.md", "off.md", "active.md"], - readFileSync: (p: string) => { - if (p.endsWith("hidden.md")) return '---\ndisable: "true"\nmode: subagent\n---\n'; - if (p.endsWith("off.md")) return "---\ndisable: yes\nmode: subagent\n---\n"; - return "---\nmode: subagent\n---\n"; - }, - }; - - expect(readSubagentNames(deps)).toEqual(["active"]); - }); - - it("reads real agent markdown from disk across agent/ and agents/, including nested", () => { - const dir = mkdtempSync(join(tmpdir(), "oc-agents-")); - try { - mkdirSync(join(dir, "agent"), { recursive: true }); - mkdirSync(join(dir, "agents", "team"), { recursive: true }); - writeFileSync(join(dir, "agent", "reviewer.md"), "---\nmode: subagent\n---\nReview."); - writeFileSync(join(dir, "agents", "team", "builder.md"), '---\nmode: "subagent"\n---\nBuild.'); - - // configJson "{}" isolates the JSON side; the directory side uses the real fs. - const names = readSubagentNames({ configJson: "{}", configDir: dir }).sort(); - expect(names).toEqual(["reviewer", "team/builder"]); - } finally { - rmSync(dir, { recursive: true, force: true }); - } - }); -}); 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/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", () => { From 7e801afb5710b174498f7bc268655d87c7496fc5 Mon Sep 17 00:00:00 2001 From: Nomadcxx Date: Fri, 24 Jul 2026 20:53:14 +1000 Subject: [PATCH 6/9] feat: bridge delegated tasks through runtime --- src/proxy/bridge-json.ts | 225 ++++++++++++++++++++++---- tests/unit/proxy/bridge-json.test.ts | 234 ++++++++++++++++++++++++++- 2 files changed, 424 insertions(+), 35 deletions(-) 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/tests/unit/proxy/bridge-json.test.ts b/tests/unit/proxy/bridge-json.test.ts index 77104ba..601d175 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,105 @@ 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("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, + }); + }); + }); }); From 05d482e033271506a57f50cca953b4aa29784b3e Mon Sep 17 00:00:00 2001 From: Nomadcxx Date: Fri, 24 Jul 2026 20:55:40 +1000 Subject: [PATCH 7/9] fix: reassemble streamed task envelopes --- src/plugin.ts | 131 +++++++++--- .../opencode-loop.integration.test.ts | 190 ++++++++++++++++++ 2 files changed, 290 insertions(+), 31 deletions(-) diff --git a/src/plugin.ts b/src/plugin.ts index dae6e20..20e5f59 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -31,8 +31,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"; @@ -459,6 +459,16 @@ 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 shouldTreatCursorAgentFailureAsDiagnostic( errSource: string, sawSuccessfulStreamOutput: boolean, @@ -1472,6 +1482,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, @@ -1493,6 +1506,39 @@ async function ensureCursorProxyServer(workspaceDirectory: string, toolRouter?: // ignore } }; + const emitBridgeText = (text: string) => { + const sseChunks = converter.handleEvent(createAssistantTextEvent(text)); + if (sseChunks.length > 0) { + sawSuccessfulStreamOutput = true; + } + for (const sse of sseChunks) { + enqueueSse(sse); + } + }; + const flushBridgeText = () => { + const text = bridgeDetector?.flush() ?? ""; + if (text) { + emitBridgeText(text); + } + }; + const handleBridgeAssistantEvent = (event: StreamJsonEvent): boolean => { + if (!bridgeDetector || !isAssistantText(event)) { + return false; + } + 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; @@ -1538,19 +1584,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, @@ -1634,18 +1675,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(), @@ -1705,6 +1741,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(); @@ -2074,6 +2111,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(); @@ -2148,6 +2188,39 @@ async function ensureCursorProxyServer(workspaceDirectory: string, toolRouter?: // ignore } }; + const emitBridgeText = (text: string) => { + const sseChunks = converter.handleEvent(createAssistantTextEvent(text)); + if (sseChunks.length > 0) { + sawSuccessfulStreamOutput = true; + } + for (const sse of sseChunks) { + writeSse(sse); + } + }; + const flushBridgeText = () => { + const text = bridgeDetector?.flush() ?? ""; + if (text) { + emitBridgeText(text); + } + }; + const handleBridgeAssistantEvent = (event: StreamJsonEvent): boolean => { + if (!bridgeDetector || !isAssistantText(event)) { + return false; + } + 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; @@ -2176,19 +2249,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, @@ -2261,6 +2329,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(); diff --git a/tests/integration/opencode-loop.integration.test.ts b/tests/integration/opencode-loop.integration.test.ts index bf307d8..09f29c6 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,86 @@ 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-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 = [ { @@ -750,6 +853,93 @@ 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("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 = ""; From 18ea35a7c7e7ba91aa0534d13708092cab85b4fb Mon Sep 17 00:00:00 2001 From: Nomadcxx Date: Fri, 24 Jul 2026 21:33:01 +1000 Subject: [PATCH 8/9] fix: preserve reasoning during bridge buffering --- src/plugin.ts | 33 +++++++++-- .../opencode-loop.integration.test.ts | 57 +++++++++++++++++++ 2 files changed, 86 insertions(+), 4 deletions(-) diff --git a/src/plugin.ts b/src/plugin.ts index 20e5f59..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 { @@ -469,6 +470,18 @@ function createAssistantTextEvent(text: string): StreamJsonEvent { }; } +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, @@ -1506,8 +1519,8 @@ async function ensureCursorProxyServer(workspaceDirectory: string, toolRouter?: // ignore } }; - const emitBridgeText = (text: string) => { - const sseChunks = converter.handleEvent(createAssistantTextEvent(text)); + const emitBridgeEvent = (event: StreamJsonEvent) => { + const sseChunks = converter.handleEvent(event); if (sseChunks.length > 0) { sawSuccessfulStreamOutput = true; } @@ -1515,6 +1528,9 @@ async function ensureCursorProxyServer(workspaceDirectory: string, toolRouter?: enqueueSse(sse); } }; + const emitBridgeText = (text: string) => { + emitBridgeEvent(createAssistantTextEvent(text)); + }; const flushBridgeText = () => { const text = bridgeDetector?.flush() ?? ""; if (text) { @@ -1525,6 +1541,9 @@ async function ensureCursorProxyServer(workspaceDirectory: string, toolRouter?: 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); @@ -2188,8 +2207,8 @@ async function ensureCursorProxyServer(workspaceDirectory: string, toolRouter?: // ignore } }; - const emitBridgeText = (text: string) => { - const sseChunks = converter.handleEvent(createAssistantTextEvent(text)); + const emitBridgeEvent = (event: StreamJsonEvent) => { + const sseChunks = converter.handleEvent(event); if (sseChunks.length > 0) { sawSuccessfulStreamOutput = true; } @@ -2197,6 +2216,9 @@ async function ensureCursorProxyServer(workspaceDirectory: string, toolRouter?: writeSse(sse); } }; + const emitBridgeText = (text: string) => { + emitBridgeEvent(createAssistantTextEvent(text)); + }; const flushBridgeText = () => { const text = bridgeDetector?.flush() ?? ""; if (text) { @@ -2207,6 +2229,9 @@ async function ensureCursorProxyServer(workspaceDirectory: string, toolRouter?: 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); diff --git a/tests/integration/opencode-loop.integration.test.ts b/tests/integration/opencode-loop.integration.test.ts index 09f29c6..55c8670 100644 --- a/tests/integration/opencode-loop.integration.test.ts +++ b/tests/integration/opencode-loop.integration.test.ts @@ -362,6 +362,35 @@ process.stdin.on("end", () => { }, { 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 = [ { @@ -435,6 +464,7 @@ type StreamChunk = { choices?: Array<{ delta?: { content?: string; + reasoning_content?: string; tool_calls?: Array<{ function?: { name?: string; @@ -889,6 +919,33 @@ describe("OpenCode-owned tool loop integration", () => { 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 = ""; From e1da7ce2af36795ab11f8891bbdc777950fe6662 Mon Sep 17 00:00:00 2001 From: Nomadcxx Date: Fri, 24 Jul 2026 21:33:20 +1000 Subject: [PATCH 9/9] test: pin delayed bridge fallback --- tests/unit/proxy/bridge-json.test.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/tests/unit/proxy/bridge-json.test.ts b/tests/unit/proxy/bridge-json.test.ts index 601d175..2c58114 100644 --- a/tests/unit/proxy/bridge-json.test.ts +++ b/tests/unit/proxy/bridge-json.test.ts @@ -390,6 +390,15 @@ describe("proxy/bridge-json", () => { 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"}}';