From 1031bf841fe91e1c6fb42ddc68fa09ba6a5ba988 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Fri, 3 Jul 2026 00:28:34 -0500 Subject: [PATCH] feat(opencode): add code-mode MCP adapter --- packages/opencode/package.json | 1 + packages/opencode/src/mcp/index.ts | 19 + packages/opencode/src/mcp/invoke.ts | 65 ++ packages/opencode/src/permission/index.ts | 12 + packages/opencode/src/tool/code-mode.ts | 428 +++++++++ packages/opencode/test/session/prompt.test.ts | 1 + .../test/session/snapshot-tool-race.test.ts | 1 + .../test/tool/code-mode-integration.test.ts | 354 +++++++ packages/opencode/test/tool/code-mode.test.ts | 861 ++++++++++++++++++ 9 files changed, 1742 insertions(+) create mode 100644 packages/opencode/src/mcp/invoke.ts create mode 100644 packages/opencode/src/tool/code-mode.ts create mode 100644 packages/opencode/test/tool/code-mode-integration.test.ts create mode 100644 packages/opencode/test/tool/code-mode.test.ts diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 55d9e91ac0..2375a28471 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -84,6 +84,7 @@ "@octokit/graphql": "9.0.2", "@octokit/rest": "catalog:", "@openauthjs/openauth": "catalog:", + "@opencode-ai/codemode": "workspace:*", "@opencode-ai/llm": "workspace:*", "@opencode-ai/plugin": "workspace:*", "@opencode-ai/protocol": "workspace:*", diff --git a/packages/opencode/src/mcp/index.ts b/packages/opencode/src/mcp/index.ts index e574e20fba..d336da48e3 100644 --- a/packages/opencode/src/mcp/index.ts +++ b/packages/opencode/src/mcp/index.ts @@ -159,6 +159,12 @@ export interface Interface { readonly clients: () => Effect.Effect> readonly instructions: () => Effect.Effect readonly tools: () => Effect.Effect> + /** + * Raw MCP tool definitions keyed identically to {@link tools} (`toolName(client, name)`). + * Unlike {@link tools}, these retain the original `inputSchema`/`outputSchema`, which code + * mode uses to render tool signatures (including return types) to the model. + */ + readonly defs: () => Effect.Effect> readonly prompts: () => Effect.Effect> readonly resources: (clientName?: string) => Effect.Effect> readonly resourceTemplates: ( @@ -680,6 +686,18 @@ const layer = Layer.effect( return result }) + const defs = Effect.fn("MCP.defs")(function* () { + const result: Record = {} + const s = yield* InstanceState.get(state) + for (const [clientName, listed] of Object.entries(s.defs)) { + if (s.status[clientName]?.status !== "connected") continue + for (const mcpTool of listed) { + result[McpCatalog.toolName(clientName, mcpTool.name)] = mcpTool + } + } + return result + }) + function collectFromConnected( s: State, listFn: (c: Client, timeout?: number) => Promise, @@ -982,6 +1000,7 @@ const layer = Layer.effect( clients, instructions, tools, + defs, prompts, resources, resourceTemplates, diff --git a/packages/opencode/src/mcp/invoke.ts b/packages/opencode/src/mcp/invoke.ts new file mode 100644 index 0000000000..52bc5e4f73 --- /dev/null +++ b/packages/opencode/src/mcp/invoke.ts @@ -0,0 +1,65 @@ +import type { ToolExecutionOptions } from "ai" +import { Effect } from "effect" +import type { Plugin } from "@/plugin" +import type { Tool } from "@/tool/tool" + +/** + * The shared middle of every raw MCP tool invocation: plugin `tool.execute.before` + * hook → permission ask → dispatch through the ai-sdk tool's execute inside the + * `Tool.execute` tracing span → plugin `tool.execute.after` hook. Used by both the + * legacy per-tool registration in `SessionTools.resolve` and code-mode child calls, + * so MCP tools execute identically on either path. + * + * Returns the RAW result the ai-sdk execute resolved with — callers own their + * shaping edge (model-facing text/attachment shaping + truncation on the legacy + * path, `toSandboxResult` for code-mode child calls). The after hook fires here + * with that same raw result, which is exactly what the legacy loop always passed + * (the raw MCP result, not the shaped `{title, output, metadata}`), so the hook + * payload cannot drift between callers. + * + * `callID` is the hook/span identity — an opaque string nothing parses. Legacy + * passes the ai-sdk `toolCallId`; code-mode child calls pass a synthetic + * `${parentCallID}/${n}`. `options.toolCallId` is what the ai-sdk execute sees and + * stays each caller's existing value. Failure semantics belong to the caller: hook + * failures, permission denials, and tool failures all propagate — the legacy path + * lets them fail the tool call as before; code mode converts them into catchable + * in-program tool errors at its edge. + */ +export const invoke = Effect.fn("McpInvoke.invoke")(function* (input: { + plugin: Plugin.Interface + key: string + execute: (args: any, options: ToolExecutionOptions) => R | PromiseLike + args: any + callID: string + options: ToolExecutionOptions + sessionID: string + messageID: string + ask: Tool.Context["ask"] +}) { + yield* input.plugin.trigger( + "tool.execute.before", + { tool: input.key, sessionID: input.sessionID, callID: input.callID }, + { args: input.args }, + ) + const result: R = yield* Effect.gen(function* () { + yield* input.ask({ permission: input.key, metadata: {}, patterns: ["*"], always: ["*"] }) + return yield* Effect.promise(() => Promise.resolve(input.execute(input.args, input.options))) + }).pipe( + Effect.withSpan("Tool.execute", { + attributes: { + "tool.name": input.key, + "tool.call_id": input.callID, + "session.id": input.sessionID, + "message.id": input.messageID, + }, + }), + ) + yield* input.plugin.trigger( + "tool.execute.after", + { tool: input.key, sessionID: input.sessionID, callID: input.callID, args: input.args }, + result, + ) + return result +}) + +export * as McpInvoke from "./invoke" diff --git a/packages/opencode/src/permission/index.ts b/packages/opencode/src/permission/index.ts index 396d192018..44afb77843 100644 --- a/packages/opencode/src/permission/index.ts +++ b/packages/opencode/src/permission/index.ts @@ -213,6 +213,18 @@ export function disabled(tools: string[], ruleset: PermissionV1.Ruleset): Set(tools: Record, ruleset: PermissionV1.Ruleset): Record { + const hidden = disabled(Object.keys(tools), ruleset) + return Object.fromEntries(Object.entries(tools).filter(([name]) => !hidden.has(name))) +} + export const node = LayerNode.make({ service: Service, layer: layer, deps: [EventV2Bridge.node] }) export * as Permission from "." diff --git a/packages/opencode/src/tool/code-mode.ts b/packages/opencode/src/tool/code-mode.ts new file mode 100644 index 0000000000..bef135b03a --- /dev/null +++ b/packages/opencode/src/tool/code-mode.ts @@ -0,0 +1,428 @@ +import * as Tool from "./tool" +import type { Tool as AITool } from "ai" +import type { Tool as MCPToolDef } from "@modelcontextprotocol/sdk/types.js" +import { Cause, Effect, Schema } from "effect" +import { + CodeMode, + Tool as SandboxTool, + toolError, + type ExecuteResult, + type JsonSchema, + type ToolDefinition, +} from "@opencode-ai/codemode" +import { MCP } from "@/mcp" +import { McpCatalog } from "@/mcp/catalog" +import { McpInvoke } from "@/mcp/invoke" +import { Agent } from "@/agent/agent" +import { Session } from "@/session/session" +import { Permission } from "@/permission" +import { Plugin } from "@/plugin" + +export const CODE_MODE_TOOL = "execute" + +// OpenCode sets NO execution limits: no timeout, no tool-call cap, and no CodeMode output +// truncation. Cancelling the tool call aborts `ctx.abort`, which wins the race below and +// interrupts the execution fiber — structured concurrency takes the program and its +// in-flight child calls down with it; every child call is permission-gated anyway. Output +// bounding is OpenCode's native tool-output truncation (Tool.define's shared wrapper), +// which applies to `execute` like any other tool and dumps the full output to a file when +// it triggers. + +// The static base description. The full usage guide and the grouped, permission-filtered +// tool catalog are appended per agent by the registry (`describeCodeMode`, the same +// composition point `describeTask` uses), so `plugin.trigger("tool.definition")` sees this +// base first, exactly like the task tool. +const DESCRIPTION = [ + "Execute a JavaScript/TypeScript program that orchestrates the connected MCP tools inside a confined runtime.", + "The full usage guide and the catalog of available tools follow below.", +].join("\n") + +export const Parameters = Schema.Struct({ + code: Schema.String.annotate({ + description: [ + "JavaScript source to execute.", + "Inside CodeMode, `tools` contains only the MCP/CodeMode tools listed in this execute tool's description; top-level opencode tools like bash, read, or lsp are not available unless listed there.", + "Call available tools using the exact signatures shown in this execute tool's description, compose the results, and `return` the final value.", + ].join(" "), + }), +}) + +/** One child tool call, surfaced live so the UI can render a per-call line that + * updates as the program runs. `tool` is the dotted path (e.g. `github.create_issue`). */ +export type CallEntry = { tool: string; status: "running" | "completed" | "error"; input?: Record } + +type Metadata = { + toolCalls: CallEntry[] + error?: boolean +} + +/** + * A tool-result attachment: identical to a session `FilePart` (minus the ids) and + * carrying the actual bytes (`url`, often a base64 `data:` URL), so it lowers 1:1 into + * `Tool.ExecuteResult.attachments`. Attachments never enter the sandbox — media stripped + * from child tool results is accumulated host-side and returned on the outer `execute` + * result, where the existing attachment plumbing turns it into visible images/files. + */ +export type Attachment = NonNullable[number] + +/** One MCP tool in the grouped catalog: the flat `server_tool` key split into its + * namespace (`server`) and local name, with the raw JSON Schemas used for rendering. */ +export type CatalogEntry = { + path: string + key: string + server: string + local: string + description: string + tool: AITool + inputSchema: JsonSchema + outputSchema?: JsonSchema +} + +/** Render-only cast: MCP definitions carry JSON Schema documents already. */ +const toJsonSchema = (schema: unknown): JsonSchema => schema as JsonSchema + +/** The input schema for entries without a cached MCP definition, recovered from the + * ai-sdk tool when possible so signatures stay informative. */ +function fallbackInputSchema(tool: AITool): JsonSchema { + const schema = (tool.inputSchema as { jsonSchema?: unknown } | undefined)?.jsonSchema + if (schema && typeof schema === "object") return toJsonSchema(schema) + return { type: "object", properties: {} } +} + +/** + * Group the flat `server_tool` catalog into per-server namespaces. `servers` are + * the sanitized MCP client names; the longest matching prefix wins so a server + * named `a_b` beats `a` for the key `a_b_tool`. `mcpDefs` carries the raw MCP + * definitions (keyed identically) so each entry retains its original + * `inputSchema`/`outputSchema` for signature rendering. + */ +export function groupByServer( + mcpTools: Record, + servers: readonly string[], + mcpDefs: Record = {}, +): Map { + const byLongest = [...servers].sort((a, b) => b.length - a.length) + const groups = new Map() + for (const key of Object.keys(mcpTools).sort((a, b) => a.localeCompare(b))) { + const server = byLongest.find((name) => key.startsWith(name + "_")) ?? (key.includes("_") ? key.slice(0, key.indexOf("_")) : key) + const local = server && key.startsWith(server + "_") ? key.slice(server.length + 1) : key + const def = mcpDefs[key] + const entry: CatalogEntry = { + path: `${server}.${local}`, + key, + server, + local, + description: mcpTools[key]!.description ?? def?.description ?? "", + tool: mcpTools[key]!, + inputSchema: def?.inputSchema ? toJsonSchema(def.inputSchema) : fallbackInputSchema(mcpTools[key]!), + ...(def?.outputSchema ? { outputSchema: toJsonSchema(def.outputSchema) } : {}), + } + groups.set(server, [...(groups.get(server) ?? []), entry]) + } + return groups +} + +/** The executable catalog for a (already permission-filtered) MCP tool set: grouped + * entries, minus any without an ai-sdk execute function. */ +export function buildCatalog( + mcpTools: Record, + mcpDefs: Record, + servers: readonly string[], +): CatalogEntry[] { + return [...groupByServer(mcpTools, servers, mcpDefs).values()].flat().filter((entry) => entry.tool.execute !== undefined) +} + +/** + * The model-facing usage guide plus grouped catalog for the given MCP tool set: the + * CodeMode instructions for this tool tree (syntax guide + tool signatures, or the + * namespace overview + search for large catalogs). Callers pass an already + * permission-filtered tool set — hard-denied tools never enter the catalog. The preview + * tree's runs are placeholders — rendering never invokes them. + */ +export function catalogInstructions( + mcpTools: Record, + mcpDefs: Record, + servers: readonly string[], +): string { + const catalog = buildCatalog(mcpTools, mcpDefs, servers) + return CodeMode.make({ + tools: toolTree(catalog, () => () => Effect.fail(toolError("Tool preview is not executable."))), + }).instructions() +} + +function displayInput(input: unknown): Record | undefined { + if (input === null || input === undefined) return + if (typeof input === "object" && !Array.isArray(input)) { + const value = input as Record + if (Object.keys(value).length > 0) return value + return + } + return { input } +} + +const lastSegment = (uri: string) => { + const trimmed = uri.split(/[?#]/, 1)[0]!.replace(/\/+$/, "") + const segment = trimmed.slice(trimmed.lastIndexOf("/") + 1) + return segment.length > 0 ? segment : undefined +} + +const dataUrl = (mime: string, base64: string) => `data:${mime};base64,${base64}` + +/** The stand-in payload for a media-only tool result, so the program knows the call + * succeeded even though the media itself never enters the sandbox. */ +const mediaMarker = (files: number, images: number) => { + const noun = files === images ? "image" : "file" + return `[${files} ${noun}${files === 1 ? "" : "s"} attached to the result]` +} + +/** + * Reduce a raw MCP tool result to the value the sandbox sees. Structured content is + * preferred; otherwise text blocks are joined. Media blocks (image/audio/resource + * blob/resource_link) NEVER enter the sandbox: they are stripped into `collect`, the + * per-execution attachment accumulator, and a tool that returned ONLY media yields a + * small text marker instead. Lenient — never throws on unexpected shapes. + */ +export function toSandboxResult(raw: unknown, collect: (attachment: Attachment) => void): unknown { + if (raw === null || typeof raw !== "object") return raw + const record = raw as { structuredContent?: unknown; content?: unknown } + const content = Array.isArray(record.content) ? record.content : [] + const text: string[] = [] + let files = 0 + let images = 0 + const push = (attachment: Attachment) => { + files += 1 + if (attachment.mime.startsWith("image/")) images += 1 + collect(attachment) + } + for (const item of content) { + if (!item || typeof item !== "object") continue + const block = item as Record + switch (block.type) { + case "text": + if (typeof block.text === "string") text.push(block.text) + break + case "image": + case "audio": + if (typeof block.data === "string" && typeof block.mimeType === "string") { + push({ type: "file", mime: block.mimeType, url: dataUrl(block.mimeType, block.data) }) + } + break + case "resource": { + const res = block.resource as Record | undefined + if (res && typeof res === "object") { + const mime = typeof res.mimeType === "string" ? res.mimeType : "application/octet-stream" + const uri = typeof res.uri === "string" ? res.uri : undefined + if (typeof res.blob === "string") { + push({ type: "file", mime, url: dataUrl(mime, res.blob), filename: uri ? lastSegment(uri) : undefined }) + } else if (typeof res.text === "string") { + text.push(res.text) + } + } + break + } + case "resource_link": + if (typeof block.uri === "string") { + push({ + type: "file", + mime: typeof block.mimeType === "string" ? block.mimeType : "application/octet-stream", + url: block.uri, + filename: typeof block.name === "string" ? block.name : lastSegment(block.uri), + }) + } + break + } + } + + if (record.structuredContent !== undefined && record.structuredContent !== null) return record.structuredContent + if (text.length > 0) return text.join("\n") + if (files > 0) return mediaMarker(files, images) + if (Array.isArray(record.content)) return null // MCP-shaped result with nothing extractable + return raw +} + +/** + * Append captured `console.*` output to the model-facing text as a trailing `Logs:` section, + * so a program's diagnostics ride back alongside its result — on success AND on error. + * Returns the text unchanged when nothing was logged. This is the sandbox's only + * stdout-like channel — it goes to the model, not the user. + */ +export function withLogs(output: string, logs: ReadonlyArray = []): string { + if (logs.length === 0) return output + const section = "Logs:\n" + logs.join("\n") + return output.length > 0 ? `${output}\n\n${section}` : section +} + +/** Coerce the program's return value to model-facing text without ever failing on shape. */ +export function formatValue(value: unknown): string { + if (typeof value === "string") return value + if (value === undefined) return "undefined" + try { + return JSON.stringify(value, null, 2) ?? String(value) + } catch { + return String(value) + } +} + +type Run = (input: unknown) => Effect.Effect + +/** Build the `tools..` tree CodeMode executes against, one + * `Tool.make` definition per MCP tool with its render-only JSON Schemas. */ +function toolTree(catalog: readonly CatalogEntry[], run: (entry: CatalogEntry) => Run) { + const tree: Record> = {} + for (const entry of catalog) { + const namespace = (tree[entry.server] ??= {}) + namespace[entry.local] = SandboxTool.make({ + description: entry.description, + input: entry.inputSchema, + output: entry.outputSchema, + run: run(entry), + }) + } + return tree +} + +/** Failures inside a child call — plugin hook failures, permission denials, and tool + * failures alike — become safe, catchable in-program errors via toolError, so a + * program can try/catch one call without the whole execution dying. Interruption + * (user cancel) keeps propagating as interruption. */ +const toCatchable = (effect: Effect.Effect) => + effect.pipe( + Effect.catchCause((cause) => { + if (Cause.hasInterruptsOnly(cause)) return Effect.interrupt + const error = Cause.squash(cause) + return Effect.fail(toolError(error instanceof Error ? error.message : String(error), error)) + }), + ) + +export const CodeModeTool = Tool.define( + CODE_MODE_TOOL, + Effect.gen(function* () { + const mcp = yield* MCP.Service + const agents = yield* Agent.Service + const sessions = yield* Session.Service + const plugin = yield* Plugin.Service + + const init: Tool.DefWithoutID = { + description: DESCRIPTION, + parameters: Parameters, + execute: Effect.fn("CodeMode.execute")(function* (params, ctx) { + // Already cancelled: don't start the program at all. (The mid-flight case is the + // race below; racing alone would still let the program run its first steps.) + if (ctx.abort.aborted) { + return { + title: CODE_MODE_TOOL, + metadata: { toolCalls: [], error: true }, + output: "Execution cancelled.", + } satisfies Tool.ExecuteResult + } + // A fresh MCP snapshot per execution, so the runtime tracks live tool-list + // changes, filtered with the same merged agent+session ruleset that gates + // `ctx.ask` (see SessionTools.context). A hard-denied tool never enters the + // tree, so it is not dispatchable even if the model guesses its name — the + // program gets the normal unknown-tool diagnostic, not a permission error. + const agent = yield* agents.get(ctx.agent) + const session = yield* sessions.get(ctx.sessionID).pipe(Effect.orDie) + const ruleset = Permission.merge(agent.permission, session.permission ?? []) + const mcpTools = Permission.visibleTools(yield* mcp.tools(), ruleset) + const servers = Object.keys(yield* mcp.clients()).map(McpCatalog.sanitize) + const catalog = buildCatalog(mcpTools, yield* mcp.defs(), servers) + + const calls: CallEntry[] = [] + // Media stripped from child tool results accumulates here for the life of the + // call; the bytes never enter the sandbox (see toSandboxResult). + const attachments: Attachment[] = [] + const collect = (attachment: Attachment) => void attachments.push(attachment) + // Stream the current call list to the UI. Sent on every status change so the + // tool part shows each child call appearing and resolving while the program runs. + const publish = () => ctx.metadata({ title: CODE_MODE_TOOL, metadata: { toolCalls: calls.map((c) => ({ ...c })) } }) + + // One CodeMode tool per MCP tool, running the same shared middle as legacy + // per-tool registration (McpInvoke.invoke: plugin before hook → permission + // ask → Tool.execute span → dispatch through the ai-sdk wrapper, which owns + // callTool timeouts/progress and turns an MCP isError into a thrown Error → + // plugin after hook), so plugins observe child calls too. Each child gets a + // synthetic hook/span callID `${parentCallID}/${n}` (per-execution counter, + // opaque — nothing parses it); the ai-sdk toolCallId is unchanged. Failures — + // hook, denial, or tool — fail only that child call as a safe, catchable + // in-program error (toCatchable); the raw result is then shaped for the sandbox. + let childCalls = 0 + const callTool = (entry: CatalogEntry) => (input: unknown) => + toCatchable( + Effect.gen(function* () { + childCalls += 1 + const raw = yield* McpInvoke.invoke({ + plugin, + key: entry.key, + execute: entry.tool.execute!, + args: input ?? {}, + callID: `${ctx.callID ?? entry.key}/${childCalls}`, + options: { toolCallId: ctx.callID ?? entry.key, abortSignal: ctx.abort, messages: [] }, + sessionID: ctx.sessionID, + messageID: ctx.messageID, + ask: ctx.ask, + }) + return toSandboxResult(raw, collect) + }), + ) + + const runtime = CodeMode.make({ + tools: toolTree(catalog, callTool), + onToolCallStart: ({ index, name, input }) => + Effect.suspend(() => { + const shown = displayInput(input) + calls[index] = { tool: name, status: "running", ...(shown ? { input: shown } : {}) } + return publish() + }), + onToolCallEnd: ({ index, outcome }) => + Effect.suspend(() => { + const current = calls[index] + if (current) calls[index] = { ...current, status: outcome === "success" ? "completed" : "error" } + return publish() + }), + }) + + // The shared tool runner does not wire ctx.abort to fiber interruption (it runs + // tools via Effect.runPromise with no abort handling), so without this race the + // program would keep running after the user cancels. The abort signal winning the + // race interrupts the execution fiber; the cancelled result keeps the runner's + // post-abort bookkeeping (completeToolCall) on its normal path. + const cancelled = Effect.callback((resume) => { + const onAbort = () => + resume( + Effect.succeed({ + ok: false, + error: { kind: "ExecutionFailure", message: "Execution cancelled." }, + toolCalls: calls.map((call) => ({ name: call.tool })), + }), + ) + if (ctx.abort.aborted) return onAbort() + ctx.abort.addEventListener("abort", onAbort, { once: true }) + return Effect.sync(() => ctx.abort.removeEventListener("abort", onAbort)) + }) + + const result = yield* Effect.raceFirst(runtime.execute(params.code), cancelled) + const logs = result.logs ?? [] + const attached = attachments.length > 0 ? { attachments } : {} + + if (result.ok) { + return { + title: CODE_MODE_TOOL, + metadata: { toolCalls: calls }, + output: withLogs(formatValue(result.value), logs), + ...attached, + } satisfies Tool.ExecuteResult + } + // Diagnostics may carry suggestions (e.g. pointing an unknown tool at + // discovery); append the ones the message doesn't already contain. + const hints = (result.error.suggestions ?? []).filter((hint) => !result.error.message.includes(hint)) + return { + title: CODE_MODE_TOOL, + metadata: { toolCalls: calls, error: true }, + output: withLogs([result.error.message, ...hints].join("\n"), logs), + ...attached, + } satisfies Tool.ExecuteResult + }), + } + return init + }), +) diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts index 491ad06aaf..01b1af5b99 100644 --- a/packages/opencode/test/session/prompt.test.ts +++ b/packages/opencode/test/session/prompt.test.ts @@ -117,6 +117,7 @@ function makeMcp(instructions: MCP.ServerInstructions[] = []) { clients: () => Effect.succeed({}), instructions: () => Effect.succeed(instructions), tools: () => Effect.succeed({}), + defs: () => Effect.succeed({}), prompts: () => Effect.succeed({}), resources: () => Effect.succeed({}), resourceTemplates: () => Effect.succeed({}), diff --git a/packages/opencode/test/session/snapshot-tool-race.test.ts b/packages/opencode/test/session/snapshot-tool-race.test.ts index 1265237840..3bbb4b1fc5 100644 --- a/packages/opencode/test/session/snapshot-tool-race.test.ts +++ b/packages/opencode/test/session/snapshot-tool-race.test.ts @@ -39,6 +39,7 @@ const mcp = Layer.succeed( clients: () => Effect.succeed({}), instructions: () => Effect.succeed([]), tools: () => Effect.succeed({}), + defs: () => Effect.succeed({}), prompts: () => Effect.succeed({}), resources: () => Effect.succeed({}), resourceTemplates: () => Effect.succeed({}), diff --git a/packages/opencode/test/tool/code-mode-integration.test.ts b/packages/opencode/test/tool/code-mode-integration.test.ts new file mode 100644 index 0000000000..c2cec6c668 --- /dev/null +++ b/packages/opencode/test/tool/code-mode-integration.test.ts @@ -0,0 +1,354 @@ +import { beforeAll, describe, expect, test } from "bun:test" +import { CodeModeTool, catalogInstructions } from "@/tool/code-mode" +import { McpCatalog } from "@/mcp/catalog" +import { Agent } from "@/agent/agent" +import { MCP } from "@/mcp" +import { Plugin } from "@/plugin" +import { Session } from "@/session/session" +import { Tool } from "@/tool/tool" +import * as Truncate from "@/tool/truncate" +import { MessageID, SessionID } from "@/session/schema" +import { Server } from "@modelcontextprotocol/sdk/server/index.js" +import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js" +import type { Client } from "@modelcontextprotocol/sdk/client/index.js" +import { + CallToolRequestSchema, + LATEST_PROTOCOL_VERSION, + ListToolsRequestSchema, + type Tool as MCPToolDef, +} from "@modelcontextprotocol/sdk/types.js" +import type { Tool as AITool } from "ai" +import { Effect, Layer } from "effect" + +// A 1x1 transparent PNG, base64-encoded, used to exercise image attachments. +const PNG = + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==" + +const SERVER = "fixtures" + +const ctx: Tool.Context = { + sessionID: SessionID.make("ses_code-mode-int"), + messageID: MessageID.make("msg_code-mode-int"), + agent: "build", + abort: new AbortController().signal, + callID: "call_code_mode_int", + messages: [], + metadata: () => Effect.void, + ask: () => Effect.void, +} + +/** + * A minimal JSON-RPC MCP client speaking the real protocol over the in-memory transport. + * Deliberately NOT the SDK `Client`: `test/mcp/lifecycle.test.ts` (and friends) replace + * `@modelcontextprotocol/sdk/client/index.js` via bun's `mock.module`, which is + * process-global and irreversible — in a full `bun test` run (CI) any later import of the + * SDK Client gets the mock, whose `listTools` returns a canned `test_tool`. Speaking raw + * JSON-RPC keeps this suite's "real server, real transport, real protocol" property while + * being immune to that contamination. Only the surface `convertTool`/this file use is + * implemented: connect handshake, tools/list, tools/call. + */ +class RawJsonRpcClient { + private nextId = 1 + private pending = new Map void; reject: (error: Error) => void }>() + + constructor(private transport: InMemoryTransport) {} + + async connect() { + this.transport.onmessage = (message) => { + const msg = message as { id?: number; result?: unknown; error?: { message: string } } + if (msg.id === undefined) return // notifications/requests from the server are not needed here + const entry = this.pending.get(msg.id) + if (!entry) return + this.pending.delete(msg.id) + if (msg.error) entry.reject(new Error(msg.error.message)) + else entry.resolve(msg.result) + } + await this.transport.start() + await this.request("initialize", { + protocolVersion: LATEST_PROTOCOL_VERSION, + capabilities: {}, + clientInfo: { name: "test-client", version: "1.0.0" }, + }) + await this.transport.send({ jsonrpc: "2.0", method: "notifications/initialized" }) + } + + private request(method: string, params: unknown): Promise { + const id = this.nextId++ + const result = new Promise((resolve, reject) => this.pending.set(id, { resolve, reject })) + void this.transport.send({ jsonrpc: "2.0", id, method, params } as never) + return result + } + + listTools() { + return this.request("tools/list", {}) + } + + /** The `convertTool` surface: schema/options (timeouts, progress) are SDK-client + * concerns and are ignored here — the server never sees them. */ + callTool(params: { name: string; arguments?: Record }, _schema?: unknown, _options?: unknown) { + return this.request("tools/call", params) + } +} + +// A real MCP server, exposed over an in-memory transport, with a representative mix +// of tools: plain text, structured data (with an outputSchema), an image, and a +// failing tool. Tools are defined with raw JSON Schema so outputSchema is exact. +const TOOL_DEFS: MCPToolDef[] = [ + { + name: "get_text", + description: "Greet someone and return the greeting as text", + inputSchema: { type: "object", properties: { name: { type: "string" } }, required: ["name"] }, + }, + { + name: "add", + description: "Add two numbers and return the structured sum", + inputSchema: { type: "object", properties: { a: { type: "number" }, b: { type: "number" } }, required: ["a", "b"] }, + outputSchema: { type: "object", properties: { sum: { type: "number" } }, required: ["sum"] }, + }, + { + name: "screenshot", + description: "Capture a screenshot and return it as an image", + inputSchema: { type: "object", properties: {} }, + }, + { + name: "boom", + description: "A tool that always fails", + inputSchema: { type: "object", properties: {} }, + }, +] as MCPToolDef[] + +function handleCall(name: string, args: Record) { + switch (name) { + case "get_text": + return { content: [{ type: "text", text: `hello ${args.name}` }] } + case "add": { + const sum = (args.a as number) + (args.b as number) + return { content: [{ type: "text", text: String(sum) }], structuredContent: { sum } } + } + case "screenshot": + return { content: [{ type: "image", data: PNG, mimeType: "image/png" }] } + case "boom": + return { content: [{ type: "text", text: "kaboom" }], isError: true } + default: + return { content: [{ type: "text", text: `unknown tool ${name}` }], isError: true } + } +} + +let tool: Awaited>["tool"] +let description: string + +async function buildTool() { + const server = new Server({ name: SERVER, version: "1.0.0" }, { capabilities: { tools: {} } }) + server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOL_DEFS })) + server.setRequestHandler(CallToolRequestSchema, async (req) => + handleCall(req.params.name, (req.params.arguments ?? {}) as Record), + ) + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair() + await server.connect(serverTransport) + const client = new RawJsonRpcClient(clientTransport) + await client.connect() + + const listed = (await client.listTools()).tools as MCPToolDef[] + const mcpTools: Record = {} + const mcpDefs: Record = {} + for (const def of listed) { + const key = McpCatalog.toolName(SERVER, def.name) + mcpDefs[key] = def + mcpTools[key] = McpCatalog.convertTool(def, client as unknown as Client) + } + + // Truncate echoes its input so assertions read the exact program output; Agent/Session + // supply the (empty) permission rulesets the execute path merges; MCP serves the tools + // this real in-memory server listed — the same snapshot shape the live service returns. + const layer = Layer.mergeAll( + Layer.mock(Plugin.Service, { + trigger: (((_name: unknown, _input: unknown, output: unknown) => + Effect.succeed(output)) as Plugin.Interface["trigger"]), + }), + Layer.mock(Truncate.Service, { + output: (text: string) => Effect.succeed({ content: text, truncated: false as const }), + }), + Layer.mock(Agent.Service, { get: () => Effect.succeed({ name: "build", permission: [] } as any) }), + Layer.mock(Session.Service, { get: () => Effect.succeed({ permission: [] } as any) }), + Layer.mock(MCP.Service, { + tools: () => Effect.succeed(mcpTools), + defs: () => Effect.succeed(mcpDefs), + clients: () => Effect.succeed({ [SERVER]: {} as any }), + }), + ) + return { + tool: await Effect.runPromise(CodeModeTool.pipe(Effect.flatMap(Tool.init), Effect.provide(layer))), + // The catalog section the registry appends to the base description (describeCodeMode). + description: catalogInstructions(mcpTools, mcpDefs, [SERVER]), + } +} + +const run = (code: string) => Effect.runPromise(tool.execute({ code }, ctx)) + +beforeAll(async () => { + const built = await buildTool() + tool = built.tool + description = built.description +}) + +describe("code mode integration (real MCP server)", () => { + test("the appended catalog inlines full signatures with real MCP schemas", () => { + expect(description).toContain("Available tools (COMPLETE list") + expect(description).toContain("- fixtures (4 tools)") + expect(description).toContain( + "tools.fixtures.add(input: { a: number; b: number }): Promise<{ sum: number }>", + ) + expect(description).toContain("tools.fixtures.get_text(input: { name: string }): Promise") + expect(description).toContain("// Add two numbers and return the structured sum") + // Small catalog: everything is inline, so no discovery tool is advertised. + expect(description).not.toContain("$codemode") + // The workflow section is present with placeholder-only call forms. + expect(description).toContain("## Workflow") + expect(description).toContain("`const res = await tools..(input)`") + expect(description).not.toContain("total_count") + }) + + test("calls a text tool and receives its text as the native result", async () => { + const out = await run("const r = await tools.fixtures.get_text({ name: 'world' }); return r") + expect(out.output).toBe("hello world") + expect(out.metadata.toolCalls).toEqual([ + { tool: "fixtures.get_text", status: "completed", input: { name: "world" } }, + ]) + expect(out.attachments).toBeUndefined() + }) + + test("exposes structured data natively from a tool with an outputSchema", async () => { + const out = await run("const r = await tools.fixtures.add({ a: 2, b: 3 }); return r.sum") + expect(out.output).toBe("5") + }) + + test("composes multiple structured calls and returns a plain object", async () => { + const out = await run(` + const first = await tools.fixtures.add({ a: 1, b: 2 }) + const second = await tools.fixtures.add({ a: first.sum, b: 10 }) + return { total: second.sum } + `) + expect(JSON.parse(out.output)).toEqual({ total: 13 }) + expect(out.metadata.toolCalls).toEqual([ + { tool: "fixtures.add", status: "completed", input: { a: 1, b: 2 } }, + { tool: "fixtures.add", status: "completed", input: { a: 3, b: 10 } }, + ]) + }) + + test("an image result becomes an execute attachment and a marker in the sandbox", async () => { + const out = await run("return await tools.fixtures.screenshot({})") + expect(out.output).toBe("[1 image attached to the result]") + expect(out.attachments).toEqual([{ type: "file", mime: "image/png", url: `data:image/png;base64,${PNG}` }]) + }) + + test("image bytes never enter the sandbox or the model-facing output", async () => { + const out = await run(` + const shot = await tools.fixtures.screenshot({}) + return { sawMarker: typeof shot === 'string' && shot.includes('attached'), value: shot } + `) + expect(JSON.parse(out.output)).toEqual({ + sawMarker: true, + value: "[1 image attached to the result]", + }) + expect(out.output).not.toContain(PNG) + // The stripped image still arrives as a real attachment. + expect(out.attachments).toHaveLength(1) + }) + + test("attachments accumulate even when the program returns something else", async () => { + const out = await run("await tools.fixtures.screenshot({}); return 'captured'") + expect(out.output).toBe("captured") + expect(out.attachments).toHaveLength(1) + }) + + test("runs calls in parallel and accumulates every attachment", async () => { + const out = await run(` + const both = await Promise.all([tools.fixtures.screenshot({}), tools.fixtures.screenshot({})]) + return 'two shots: ' + both.length + `) + expect(out.output).toBe("two shots: 2") + expect(out.attachments).toHaveLength(2) + expect(out.metadata.toolCalls.map((c) => c.tool)).toEqual(["fixtures.screenshot", "fixtures.screenshot"]) + }) + + test("propagates an MCP isError into the program as a catchable error", async () => { + const out = await run("try { await tools.fixtures.boom({}) } catch (e) { return 'caught: ' + e.message }") + expect(out.output).toBe("caught: kaboom") + }) + + test("an uncaught MCP error surfaces as a failed execution", async () => { + const out = await run("await tools.fixtures.boom({}); return 'unreachable'") + expect(out.metadata.error).toBe(true) + expect(out.output).toContain("kaboom") + }) + + test("console output is captured and appended as a Logs section after the result", async () => { + const out = await run(` + console.log("looking up", { name: "world" }) + const r = await tools.fixtures.get_text({ name: "world" }) + console.warn("got", r) + return r + `) + expect(out.output).toBe('hello world\n\nLogs:\nlooking up {"name":"world"}\n[warn] got hello world') + expect(out.metadata.error).toBeUndefined() + }) + + test("console output is preserved on the error path", async () => { + const out = await run(` + console.log("before the throw") + await tools.fixtures.boom({}) + return "unreachable" + `) + expect(out.metadata.error).toBe(true) + expect(out.output).toContain("kaboom") + expect(out.output).toContain("Logs:\nbefore the throw") + }) + + test("a program that logs nothing gets no Logs section", async () => { + const out = await run("return 'quiet'") + expect(out.output).toBe("quiet") + expect(out.output).not.toContain("Logs:") + }) + + test("console does not consume the tool-call metadata (logging is not a tool call)", async () => { + const out = await run("console.log('hi'); console.error('bye'); return 'ok'") + expect(out.output).toBe("ok\n\nLogs:\nhi\n[error] bye") + expect(out.metadata.toolCalls).toEqual([]) + }) + + test("asks permission for each MCP call, keyed by the flat catalog name", async () => { + const asked: string[] = [] + const permCtx: Tool.Context = { ...ctx, ask: (req: any) => Effect.sync(() => void asked.push(req.permission)) } + await Effect.runPromise( + tool.execute( + { + code: ` + await tools.fixtures.add({ a: 1, b: 1 }) + await tools.fixtures.get_text({ name: 'x' }) + return 'done' + `, + }, + permCtx, + ), + ) + expect(asked).toEqual(["fixtures_add", "fixtures_get_text"]) + }) + + test("streams running/completed metadata for child calls over a real transport", async () => { + const snapshots: Array<{ toolCalls: { tool: string; status: string; input?: Record }[] }> = [] + const recordingCtx: Tool.Context = { + ...ctx, + metadata: (val: any) => Effect.sync(() => void snapshots.push(val.metadata)), + } + await Effect.runPromise( + tool.execute({ code: "await tools.fixtures.add({ a: 1, b: 2 }); return 'done'" }, recordingCtx), + ) + expect(snapshots).toContainEqual({ + toolCalls: [{ tool: "fixtures.add", status: "running", input: { a: 1, b: 2 } }], + }) + expect(snapshots).toContainEqual({ + toolCalls: [{ tool: "fixtures.add", status: "completed", input: { a: 1, b: 2 } }], + }) + }) +}) diff --git a/packages/opencode/test/tool/code-mode.test.ts b/packages/opencode/test/tool/code-mode.test.ts new file mode 100644 index 0000000000..bf1ee0d802 --- /dev/null +++ b/packages/opencode/test/tool/code-mode.test.ts @@ -0,0 +1,861 @@ +import { describe, expect, test } from "bun:test" +import { + CODE_MODE_TOOL, + CodeModeTool, + Parameters, + catalogInstructions, + formatValue, + groupByServer, + toSandboxResult, + withLogs, + type Attachment, +} from "@/tool/code-mode" +import type { Tool as MCPToolDef } from "@modelcontextprotocol/sdk/types.js" +import type { PermissionV1 } from "@opencode-ai/core/v1/permission" +import { Agent } from "@/agent/agent" +import { MCP } from "@/mcp" +import { Permission } from "@/permission" +import { Plugin } from "@/plugin" +import { Session } from "@/session/session" +import { Tool } from "@/tool/tool" +import * as Truncate from "@/tool/truncate" +import { McpCatalog } from "@/mcp/catalog" +import { MessageID, SessionID } from "@/session/schema" +import type { Tool as AITool } from "ai" +import { Effect, Layer, Schema } from "effect" + +const ctx: Tool.Context = { + sessionID: SessionID.make("ses_code-mode"), + messageID: MessageID.make("msg_code-mode"), + agent: "build", + abort: new AbortController().signal, + callID: "call_code_mode", + messages: [], + metadata: () => Effect.void, + ask: () => Effect.void, +} + +// Build a real MCP-derived AI SDK tool over a fake transport, so the adapter exercises +// the same `convertTool` execution path that `mcp.tools()` produces at runtime. +function mcpTool( + name: string, + handler: (args: Record) => unknown, + inputSchema: Record = { type: "object", properties: {} }, +): AITool { + const client = { + callTool: async (params: { arguments?: Record }) => handler(params.arguments ?? {}), + } + return McpCatalog.convertTool({ name, description: name, inputSchema } as any, client as any) +} + +// Truncate echoes its input so assertions read the exact program output. Agent.get is +// consulted by the shared wrapper during truncation AND at execute time for the +// permission ruleset that filters the dispatchable tool tree; Session.get supplies the +// (empty) session-level ruleset half of that merge. Plugin.trigger defaults to the +// pass-through the real service uses when no plugin implements a hook; tests observing +// or failing hooks override it. +function harness(input: { + mcpTools: Record + defs?: Record + servers: string[] + permission?: PermissionV1.Rule[] + trigger?: Plugin.Interface["trigger"] +}) { + return Layer.mergeAll( + Layer.mock(Plugin.Service, { + trigger: input.trigger ?? (((_name, _input, output) => Effect.succeed(output)) as Plugin.Interface["trigger"]), + }), + Layer.mock(Truncate.Service, { + output: (text: string) => Effect.succeed({ content: text, truncated: false as const }), + }), + Layer.mock(Agent.Service, { + get: () => Effect.succeed({ name: "build", permission: input.permission ?? [] } as any), + }), + Layer.mock(Session.Service, { + get: () => Effect.succeed({ permission: [] } as any), + }), + Layer.mock(MCP.Service, { + tools: () => Effect.succeed(input.mcpTools), + defs: () => Effect.succeed(input.defs ?? {}), + clients: () => Effect.succeed(Object.fromEntries(input.servers.map((name) => [name, {} as any]))), + }), + ) +} + +// Derive sanitized server namespaces from the catalog keys, mirroring how the registry +// passes `Object.keys(mcp.clients()).map(sanitize)`. +function serverNames(mcpTools: Record, servers?: string[]) { + return servers ?? [...new Set(Object.keys(mcpTools).map((key) => key.split("_")[0]!))] +} + +function build( + mcpTools: Record, + defs: Record = {}, + servers?: string[], + permission?: PermissionV1.Rule[], + trigger?: Plugin.Interface["trigger"], +) { + const names = serverNames(mcpTools, servers) + return Effect.runPromise( + CodeModeTool.pipe( + Effect.flatMap(Tool.init), + Effect.provide(harness({ mcpTools, defs, servers: names, permission, trigger })), + ), + ) +} + +// The agent-facing description, as the registry composes it (`describeCodeMode`): +// permission-filtered tool set → grouped catalog → CodeMode instructions. +function describeFor( + mcpTools: Record, + defs: Record = {}, + servers?: string[], + permission: PermissionV1.Rule[] = [], +) { + return catalogInstructions(Permission.visibleTools(mcpTools, permission), defs, serverNames(mcpTools, servers)) +} + +describe("code mode execute", () => { + test("defines execute input with an Effect schema", async () => { + const decode = Schema.decodeUnknownEffect(Parameters) + await expect(Effect.runPromise(decode({ code: "return 1" }))).resolves.toEqual({ code: "return 1" }) + await expect(Effect.runPromise(decode({}))).rejects.toThrow() + }) + + test("groups multi-underscore server names by longest matching prefix", () => { + const groups = groupByServer({ my_server_do_thing: mcpTool("do_thing", () => "") }, ["my_server"]) + expect([...groups.keys()]).toEqual(["my_server"]) + expect(groups.get("my_server")![0]).toMatchObject({ + path: "my_server.do_thing", + local: "do_thing", + key: "my_server_do_thing", + }) + }) + + test("groupByServer uses the whole key as the server name when it has no underscore", () => { + const groups = groupByServer({ standalone: mcpTool("standalone", () => "") }, []) + expect([...groups.keys()]).toEqual(["standalone"]) + expect(groups.get("standalone")![0]).toMatchObject({ + path: "standalone.standalone", + server: "standalone", + local: "standalone", + key: "standalone", + }) + }) + + test("groupByServer carries the raw MCP schemas for rendering", () => { + const defs: Record = { + weather_current: { + name: "current", + inputSchema: { type: "object", properties: { city: { type: "string" } }, required: ["city"] }, + outputSchema: { type: "object", properties: { tempC: { type: "number" } }, required: ["tempC"] }, + } as any, + } + const groups = groupByServer({ weather_current: mcpTool("current", () => "") }, ["weather"], defs) + const entry = groups.get("weather")![0]! + expect(entry.inputSchema).toEqual(defs.weather_current!.inputSchema as any) + expect(entry.outputSchema).toEqual(defs.weather_current!.outputSchema as any) + }) + + test("the static base description carries no catalog; the registry appends it", async () => { + const tool = await build({ github_list_issues: mcpTool("list_issues", () => "") }) + expect(tool.id).toBe(CODE_MODE_TOOL) + expect(tool.description).toContain("confined runtime") + expect(tool.description).not.toContain("Available tools") + expect(tool.description).not.toContain("list_issues") + }) + + test("small catalogs inline every full signature in the appended catalog", () => { + const description = describeFor({ + github_create_issue: mcpTool("create_issue", () => "", { + type: "object", + properties: { title: { type: "string" }, body: { type: "string" } }, + required: ["title"], + }), + github_list_issues: mcpTool("list_issues", () => ""), + linear_search: mcpTool("search", () => ""), + }) + + expect(description).toContain("Available tools (COMPLETE list") + expect(description).toContain("- github (2 tools)") + expect(description).toContain("- linear (1 tool)") + expect(description).toContain( + "tools.github.create_issue(input: { title: string; body?: string }): Promise", + ) + expect(description).toContain("tools.github.list_issues(") + expect(description).toContain("tools.linear.search(") + // A schema with no properties renders as an empty object, not `{ }`. + expect(description).toContain("tools.linear.search(input: {}): Promise") + // Fully inlined catalog: no discovery round-trip is needed or advertised. + expect(description).not.toContain("$codemode") + expect(description).not.toContain("Browse one namespace") + // The workflow/rules sections use placeholder call forms only — the example machinery + // never cherry-picks a catalog tool or fabricates result fields. + expect(description).toContain("## Workflow") + expect(description).toContain("1. Pick a tool from the list under `## Available tools`") + expect(description).toContain('`const data = typeof res === "string" ? JSON.parse(res) : res` - most tools return JSON as a string') + expect(description).toContain("Return only the fields you need") + expect(description).not.toContain("total_count") + }) + + test("signatures render the declared outputSchema as the return type", () => { + const defs: Record = { + weather_current: { + name: "current", + inputSchema: { type: "object", properties: { city: { type: "string" } }, required: ["city"] }, + outputSchema: { + type: "object", + properties: { tempC: { type: "number" }, summary: { type: "string" } }, + required: ["tempC"], + }, + } as any, + } + const description = describeFor({ weather_current: mcpTool("current", () => "") }, defs) + expect(description).toContain( + "tools.weather.current(input: { city: string }): Promise<{ tempC: number; summary?: string }>", + ) + }) + + test("large catalogs inline a budgeted PARTIAL list plus runtime search", async () => { + const tools: Record = {} + const filler = "a searchable description of this operation that consumes catalog budget ".repeat(3) + for (let i = 0; i < 150; i++) { + const client = { callTool: async () => ({ content: [] }) } + tools[`alpha_op_${i}`] = McpCatalog.convertTool( + { + name: `op_${i}`, + description: `${filler}${i}`, + inputSchema: { type: "object", properties: { value: { type: "string" }, count: { type: "number" } } }, + } as any, + client as any, + ) + } + tools["zeta_only_tool"] = mcpTool("only_tool", () => "", { + type: "object", + properties: { topic: { type: "string", description: "Subject to look up" } }, + required: ["topic"], + }) + const description = describeFor(tools, {}, ["alpha", "zeta"]) + + // Every namespace is listed with counts; signatures inline round-robin across + // namespaces (cheapest-first within each) until the budget runs out, and the + // description states exactly how comprehensive the list is. Round-robin fairness: + // the small zeta namespace is fully shown even though alpha alone could exhaust + // the whole budget. + expect(description).toContain("Available tools (PARTIAL - ") + expect(description).toMatch(/- alpha \(150 tools, \d+ shown\)/) + expect(description).toContain("- zeta (1 tool)\n") + expect(description).toContain("tools.zeta.only_tool(input: { topic: string }): Promise") + expect(description).toContain("tools.$codemode.search(") + // PARTIAL catalogs put search first in the workflow and advertise namespace browsing. + expect(description).toContain("1. Find a tool (skip when it is already listed below)") + expect(description).toContain('- Browse one namespace: `await tools.$codemode.search({ query: "", namespace: "" })`.') + expect(description).not.toContain("total_count") + // All op lines cost the same estimated tokens (chars/4 rounds away the 1- vs 3-digit + // name difference), so the path tiebreak decides: the lexicographically-first ops made + // the cut and the lexicographic tail (op_99 is maximal) did not. + expect(description).toContain("tools.alpha.op_0(") + expect(description).not.toContain("tools.alpha.op_99(") + + // The runtime search tool works in-program and returns complete signatures. + const tool = await build(tools, {}, ["alpha", "zeta"]) + const out = await Effect.runPromise( + tool.execute({ code: "return await tools.$codemode.search({ query: 'only tool', limit: 3 })" }, ctx), + ) + const result = JSON.parse(out.output) + // Search-result paths carry the `tools.` prefix so each is directly usable as a call site. + expect(result.items.map((i: any) => i.path)).toContain("tools.zeta.only_tool") + expect(result.items[0].signature).toContain("tools.") + // Search results render the pretty multiline signature: MCP input-property + // descriptions ride along as JSDoc field comments. The inline catalog stays compact. + const signature = result.items.find((i: any) => i.path === "tools.zeta.only_tool").signature + expect(signature).toContain("tools.zeta.only_tool(input: {\n") + expect(signature).toContain(" /** Subject to look up */\n topic: string") + expect(description).not.toContain("/**") + expect(out.metadata.toolCalls).toEqual([ + { tool: "$codemode.search", status: "completed", input: { query: "only tool", limit: 3 } }, + ]) + }) + + test("runs plain JavaScript and returns the value as text", async () => { + const tool = await build({}) + const output = await Effect.runPromise(tool.execute({ code: "return 1 + 2" }, ctx)) + expect(output.output).toBe("3") + expect(output.metadata.toolCalls).toEqual([]) + }) + + test("Object.keys(tools) enumerates the MCP server namespaces", async () => { + const tool = await build({ + github_list_issues: mcpTool("list_issues", () => ""), + linear_search: mcpTool("search", () => ""), + }) + const output = await Effect.runPromise( + tool.execute({ code: "const namespaces = Object.keys(tools); return { namespaces, count: namespaces.length }" }, ctx), + ) + expect(JSON.parse(output.output)).toEqual({ namespaces: ["github", "linear"], count: 2 }) + }) + + test("calls a namespaced MCP tool and flows its text result back into the program", async () => { + const seen: Record[] = [] + const tool = await build({ + greeter_hello: mcpTool("hello", (args) => { + seen.push(args) + return { content: [{ type: "text", text: `hello ${args.name}` }] } + }), + }) + + const output = await Effect.runPromise( + tool.execute({ code: "const r = await tools.greeter.hello({ name: 'world' }); return r.toUpperCase()" }, ctx), + ) + + expect(seen).toEqual([{ name: "world" }]) + expect(output.output).toBe("HELLO WORLD") + expect(output.metadata.toolCalls).toEqual([ + { tool: "greeter.hello", status: "completed", input: { name: "world" } }, + ]) + }) + + test("exposes structured content as native data and composes multiple calls", async () => { + const tool = await build({ + math_add: mcpTool("add", (args) => ({ + content: [], + structuredContent: { sum: (args.a as number) + (args.b as number) }, + })), + }) + + const output = await Effect.runPromise( + tool.execute( + { + code: ` + const first = await tools.math.add({ a: 1, b: 2 }) + const second = await tools.math.add({ a: first.sum, b: 10 }) + return { total: second.sum } + `, + }, + ctx, + ), + ) + + expect(JSON.parse(output.output)).toEqual({ total: 13 }) + expect(output.metadata.toolCalls).toEqual([ + { tool: "math.add", status: "completed", input: { a: 1, b: 2 } }, + { tool: "math.add", status: "completed", input: { a: 3, b: 10 } }, + ]) + }) + + test("runs tool calls in parallel with Promise.all", async () => { + const tool = await build({ + echo_one: mcpTool("one", () => ({ content: [{ type: "text", text: "1" }] })), + echo_two: mcpTool("two", () => ({ content: [{ type: "text", text: "2" }] })), + }) + + const output = await Effect.runPromise( + tool.execute( + { code: "const [a, b] = await Promise.all([tools.echo.one({}), tools.echo.two({})]); return a + b" }, + ctx, + ), + ) + + expect(output.output).toBe("12") + expect(output.metadata.toolCalls.map((c) => c.tool).sort()).toEqual(["echo.one", "echo.two"]) + expect(output.metadata.toolCalls.every((c) => c.status === "completed")).toBe(true) + }) + + test("returns a readable error when the program throws", async () => { + const tool = await build({}) + const output = await Effect.runPromise(tool.execute({ code: "throw new Error('boom')" }, ctx)) + expect(output.output).toBe("Uncaught: boom") + expect(output.metadata.error).toBe(true) + }) + + test("reports an unknown tool as a failed execution", async () => { + const tool = await build({ known_tool: mcpTool("tool", () => "ok") }) + const output = await Effect.runPromise(tool.execute({ code: "return await tools.known.missing({})" }, ctx)) + expect(output.metadata.error).toBe(true) + expect(output.output).toContain("Unknown tool 'known.missing'") + }) + + test("propagates an MCP tool error into the program as a catchable failure", async () => { + const tool = await build({ + bad_tool: mcpTool("tool", () => ({ isError: true, content: [{ type: "text", text: "server exploded" }] })), + }) + const output = await Effect.runPromise( + tool.execute({ code: "try { await tools.bad.tool({}) } catch (e) { return 'caught: ' + e.message }" }, ctx), + ) + expect(output.output).toBe("caught: server exploded") + }) + + test("asks permission before each child tool call", async () => { + const asked: unknown[] = [] + const permissionCtx: Tool.Context = { ...ctx, ask: (req) => Effect.sync(() => void asked.push(req)) } + const ok = () => ({ content: [{ type: "text", text: "ok" }] }) + const tool = await build({ a_tool: mcpTool("a", ok), b_tool: mcpTool("b", ok) }) + + await Effect.runPromise( + tool.execute({ code: "await tools.a.tool({}); await tools.b.tool({}); return 'done'" }, permissionCtx), + ) + + expect(asked.map((req: any) => req.permission)).toEqual(["a_tool", "b_tool"]) + }) + + test("a denied permission fails the child call with a catchable message, not the whole execute", async () => { + const denyCtx: Tool.Context = { ...ctx, ask: () => Effect.die(new Error("permission denied by user")) } + const called: string[] = [] + const tool = await build({ + a_tool: mcpTool("a", () => { + called.push("a") + return { content: [{ type: "text", text: "ok" }] } + }), + }) + + const output = await Effect.runPromise( + tool.execute({ code: "try { await tools.a.tool({}) } catch (e) { return 'denied: ' + e.message }" }, denyCtx), + ) + + expect(output.output).toBe("denied: permission denied by user") + expect(output.metadata.error).toBeUndefined() + // The MCP tool itself never ran. + expect(called).toEqual([]) + expect(output.metadata.toolCalls).toEqual([{ tool: "a.tool", status: "error" }]) + }) + + test("child calls fire plugin tool.execute hooks with the MCP key and synthetic parent/N call ids", async () => { + const events: { name: string; input: any; output: any }[] = [] + const trigger = ((name: unknown, input: unknown, output: unknown) => + Effect.sync(() => { + events.push({ name: name as string, input, output }) + return output + })) as Plugin.Interface["trigger"] + const tool = await build( + { + a_tool: mcpTool("a", () => ({ content: [{ type: "text", text: "one" }] })), + b_tool: mcpTool("b", () => ({ content: [{ type: "text", text: "two" }] })), + }, + {}, + undefined, + undefined, + trigger, + ) + + const out = await Effect.runPromise( + tool.execute({ code: "await tools.a.tool({ x: 1 }); await tools.b.tool({}); return 'done'" }, ctx), + ) + + expect(out.output).toBe("done") + // callID is synthetic and per-execution: `${parentCallID}/${n}`, n starting at 1. + expect(events.map((e) => [e.name, e.input.tool, e.input.callID])).toEqual([ + ["tool.execute.before", "a_tool", "call_code_mode/1"], + ["tool.execute.after", "a_tool", "call_code_mode/1"], + ["tool.execute.before", "b_tool", "call_code_mode/2"], + ["tool.execute.after", "b_tool", "call_code_mode/2"], + ]) + const [before, after] = events + expect(before!.input.sessionID).toBe(ctx.sessionID) + expect(before!.output).toEqual({ args: { x: 1 } }) + expect(after!.input.args).toEqual({ x: 1 }) + // The after hook sees the raw MCP result — the same payload the legacy path passes. + expect(after!.output).toEqual({ content: [{ type: "text", text: "one" }] }) + }) + + test("a failing before hook fails only that child call as a catchable in-program error", async () => { + const trigger = ((name: unknown, input: any, output: unknown) => { + if (name === "tool.execute.before" && input.tool === "a_tool") return Effect.die(new Error("hook exploded")) + return Effect.succeed(output) + }) as Plugin.Interface["trigger"] + const called: string[] = [] + const record = (name: string) => () => { + called.push(name) + return { content: [{ type: "text", text: "ok" }] } + } + const tool = await build( + { a_tool: mcpTool("a", record("a")), b_tool: mcpTool("b", record("b")) }, + {}, + undefined, + undefined, + trigger, + ) + + const out = await Effect.runPromise( + tool.execute( + { + code: ` + let caught + try { await tools.a.tool({}) } catch (e) { caught = e.message } + const r = await tools.b.tool({}) + return caught + " / " + r + `, + }, + ctx, + ), + ) + + // The program handled the hook failure; the rest ran and the outer result is ok. + expect(out.metadata.error).toBeUndefined() + expect(out.output).toBe("hook exploded / ok") + // The before hook gates dispatch: the failed child's tool never executed. + expect(called).toEqual(["b"]) + }) + + test("streams live per-call metadata as a call starts and finishes", async () => { + const snapshots: Array<{ toolCalls: { tool: string; status: string; input?: Record }[] }> = [] + const recordingCtx: Tool.Context = { + ...ctx, + metadata: (val: any) => Effect.sync(() => void snapshots.push(val.metadata)), + } + const tool = await build({ greeter_hello: mcpTool("hello", () => ({ content: [{ type: "text", text: "hi" }] })) }) + + await Effect.runPromise( + tool.execute({ code: "await tools.greeter.hello({ name: 'Ada' }); return 'done'" }, recordingCtx), + ) + + // The UI sees the call appear as running, then resolve to completed. + expect(snapshots).toContainEqual({ + toolCalls: [{ tool: "greeter.hello", status: "running", input: { name: "Ada" } }], + }) + expect(snapshots).toContainEqual({ + toolCalls: [{ tool: "greeter.hello", status: "completed", input: { name: "Ada" } }], + }) + }) + + test("marks a failed child call as error in the live metadata", async () => { + const snapshots: Array<{ toolCalls: { tool: string; status: string; input?: Record }[] }> = [] + const recordingCtx: Tool.Context = { + ...ctx, + metadata: (val: any) => Effect.sync(() => void snapshots.push(val.metadata)), + } + const tool = await build({ + bad_tool: mcpTool("tool", () => ({ isError: true, content: [{ type: "text", text: "boom" }] })), + }) + + await Effect.runPromise( + tool.execute( + { code: "try { await tools.bad.tool({ reason: 'test' }) } catch (e) { return 'caught' }" }, + recordingCtx, + ), + ) + + expect(snapshots).toContainEqual({ toolCalls: [{ tool: "bad.tool", status: "error", input: { reason: "test" } }] }) + }) + + test("accumulates stripped media as execute attachments the sandbox never sees", async () => { + const tool = await build({ + shot_take: mcpTool("take", () => ({ + content: [{ type: "image", data: "PNGDATA", mimeType: "image/png" }], + structuredContent: { name: "shot.png" }, + })), + }) + + const out = await Effect.runPromise(tool.execute({ code: "return await tools.shot.take({})" }, ctx)) + // The program received the structured content; the media rode along host-side. + expect(JSON.parse(out.output)).toEqual({ name: "shot.png" }) + expect(out.attachments).toEqual([{ type: "file", mime: "image/png", url: "data:image/png;base64,PNGDATA" }]) + expect(out.output).not.toContain("PNGDATA") + }) + + test("a media-only result returns a text marker so the program knows it succeeded", async () => { + const tool = await build({ + shot_take: mcpTool("take", () => ({ content: [{ type: "image", data: "PNGDATA", mimeType: "image/png" }] })), + }) + const out = await Effect.runPromise(tool.execute({ code: "return await tools.shot.take({})" }, ctx)) + expect(out.output).toBe("[1 image attached to the result]") + expect(out.attachments).toEqual([{ type: "file", mime: "image/png", url: "data:image/png;base64,PNGDATA" }]) + }) + + test("attachments still flow when the program returns something else entirely", async () => { + const tool = await build({ + shot_take: mcpTool("take", () => ({ content: [{ type: "image", data: "PNGDATA", mimeType: "image/png" }] })), + }) + const out = await Effect.runPromise( + tool.execute({ code: "await tools.shot.take({}); return 'captured'" }, ctx), + ) + expect(out.output).toBe("captured") + expect(out.attachments).toHaveLength(1) + }) + + test("isolates the sandbox from host globals", async () => { + const tool = await build({}) + const output = await Effect.runPromise(tool.execute({ code: "return process.env" }, ctx)) + expect(output.metadata.error).toBe(true) + }) + + test("cancelling via ctx.abort interrupts the running program", async () => { + // The child call triggers the abort itself, deterministically, while the program + // swallows the call's failure and heads into an infinite loop. If abort did not + // interrupt the execution fiber, this test would hang on the busy loop. + const controller = new AbortController() + const tool = await build({ + host_trigger: mcpTool("trigger", () => { + controller.abort() + return new Promise(() => {}) + }), + }) + const output = await Effect.runPromise( + tool.execute( + { code: "try { await tools.host.trigger({}) } catch {} while (true) {}" }, + { ...ctx, abort: controller.signal }, + ), + ) + expect(output.output).toBe("Execution cancelled.") + expect(output.metadata.error).toBe(true) + expect(output.metadata.toolCalls).toEqual([{ tool: "host.trigger", status: "running" }]) + }) + + test("a pre-aborted signal cancels before the program runs", async () => { + const controller = new AbortController() + controller.abort() + const ran: string[] = [] + const tool = await build({ host_touch: mcpTool("touch", () => (ran.push("called"), "ok")) }) + const output = await Effect.runPromise( + tool.execute({ code: "return await tools.host.touch({})" }, { ...ctx, abort: controller.signal }), + ) + expect(output.output).toBe("Execution cancelled.") + expect(ran).toEqual([]) + }) + + test("leaves oversized results to OpenCode's native tool-output truncation", async () => { + // No CodeMode output limit is set, so the full result reaches the shared Tool.define + // wrapper intact (the harness Truncate fake passes it through un-truncated). + const tool = await build({}) + const output = await Effect.runPromise(tool.execute({ code: "return 'x'.repeat(40000)" }, ctx)) + expect(output.metadata.error).toBeUndefined() + expect(output.output).not.toContain("[result truncated:") + expect(output.output.length).toBeGreaterThanOrEqual(40_000) + }) + + test("appends logs after the result on success and after the message on error", async () => { + const tool = await build({}) + + const ok = await Effect.runPromise( + tool.execute({ code: "console.log('step one'); console.warn('careful'); return 'done'" }, ctx), + ) + expect(ok.output).toBe("done\n\nLogs:\nstep one\n[warn] careful") + + const err = await Effect.runPromise( + tool.execute({ code: "console.log('before the throw'); throw new Error('boom')" }, ctx), + ) + expect(err.metadata.error).toBe(true) + expect(err.output).toContain("Uncaught: boom") + expect(err.output).toContain("Logs:\nbefore the throw") + }) +}) + +describe("code mode permission visibility", () => { + const deny = (permission: string): PermissionV1.Rule => ({ permission, pattern: "*", action: "deny" }) + const askRule = (permission: string): PermissionV1.Rule => ({ permission, pattern: "*", action: "ask" }) + const ok = () => ({ content: [{ type: "text", text: "ok" }] }) + + test("a hard-denied tool never enters the catalog or its search index", () => { + const mcpTools = { + github_create_issue: mcpTool("create_issue", ok), + github_list_issues: mcpTool("list_issues", ok), + } + const description = describeFor(mcpTools, {}, ["github"], [deny("github_create_issue")]) + expect(description).toContain("tools.github.list_issues(") + expect(description).not.toContain("create_issue") + expect(description).toContain("- github (1 tool)") + }) + + test("an ask-level tool stays fully visible in the catalog", () => { + const mcpTools = { + github_create_issue: mcpTool("create_issue", ok), + github_list_issues: mcpTool("list_issues", ok), + } + const description = describeFor(mcpTools, {}, ["github"], [askRule("github_create_issue")]) + expect(description).toContain("tools.github.create_issue(") + expect(description).toContain("tools.github.list_issues(") + expect(description).toContain("- github (2 tools)") + }) + + test("a hard-denied tool is not dispatchable: the program gets the unknown-tool diagnostic", async () => { + const called: string[] = [] + const tool = await build( + { + github_create_issue: mcpTool("create_issue", () => { + called.push("create_issue") + return ok() + }), + github_list_issues: mcpTool("list_issues", ok), + }, + {}, + ["github"], + [deny("github_create_issue")], + ) + + const denied = await Effect.runPromise( + tool.execute({ code: "return await tools.github.create_issue({ title: 'x' })" }, ctx), + ) + expect(denied.metadata.error).toBe(true) + expect(denied.output).toContain("Unknown tool 'github.create_issue'") + expect(denied.output).not.toContain("permission") + expect(called).toEqual([]) + + // The rest of the namespace still works. + const allowed = await Effect.runPromise( + tool.execute({ code: "return await tools.github.list_issues({})" }, ctx), + ) + expect(allowed.metadata.error).toBeUndefined() + expect(allowed.output).toBe("ok") + }) + + test("an ask-level tool remains callable and still prompts via ctx.ask", async () => { + const asked: string[] = [] + const askCtx: Tool.Context = { ...ctx, ask: (req) => Effect.sync(() => void asked.push(req.permission)) } + const tool = await build( + { github_list_issues: mcpTool("list_issues", ok) }, + {}, + ["github"], + [askRule("github_list_issues")], + ) + const out = await Effect.runPromise( + tool.execute({ code: "return await tools.github.list_issues({})" }, askCtx), + ) + expect(out.output).toBe("ok") + expect(asked).toEqual(["github_list_issues"]) + }) + + test("Permission.visibleTools hides only hard denies, matching Permission.disabled", () => { + const tools = { a_tool: 1, b_tool: 2, c_tool: 3 } + const visible = Permission.visibleTools(tools, [ + deny("a_tool"), + askRule("b_tool"), + // A scoped (non-"*") deny does not hide the tool from the catalog. + { permission: "c_tool", pattern: "something", action: "deny" }, + ]) + expect(Object.keys(visible)).toEqual(["b_tool", "c_tool"]) + }) +}) + +describe("toSandboxResult", () => { + const collector = () => { + const attachments: Attachment[] = [] + return { attachments, collect: (a: Attachment) => void attachments.push(a) } + } + + test("prefers structuredContent over text", () => { + const { collect } = collector() + expect(toSandboxResult({ structuredContent: { x: 1 }, content: [{ type: "text", text: "hi" }] }, collect)).toEqual( + { x: 1 }, + ) + }) + + test("joins text content when no structured content is present", () => { + const { collect } = collector() + expect( + toSandboxResult( + { content: [{ type: "text", text: "one" }, { type: "text", text: "two" }] }, + collect, + ), + ).toBe("one\ntwo") + }) + + test("passes non-MCP values through untouched", () => { + const { collect } = collector() + expect(toSandboxResult("raw", collect)).toBe("raw") + expect(toSandboxResult(42, collect)).toBe(42) + expect(toSandboxResult(null, collect)).toBeNull() + expect(toSandboxResult({ some: "object" }, collect)).toEqual({ some: "object" }) + }) + + test("strips media into the accumulator; text stays the sandbox value", () => { + const { attachments, collect } = collector() + const value = toSandboxResult( + { + content: [ + { type: "text", text: "see image" }, + { type: "image", data: "AAAA", mimeType: "image/png" }, + ], + }, + collect, + ) + expect(value).toBe("see image") + expect(attachments).toEqual([{ type: "file", mime: "image/png", url: "data:image/png;base64,AAAA" }]) + }) + + test("a media-only result yields a marker; counts and nouns follow the content", () => { + const one = collector() + expect(toSandboxResult({ content: [{ type: "image", data: "A", mimeType: "image/png" }] }, one.collect)).toBe( + "[1 image attached to the result]", + ) + + const two = collector() + expect( + toSandboxResult( + { + content: [ + { type: "image", data: "A", mimeType: "image/png" }, + { type: "image", data: "B", mimeType: "image/jpeg" }, + ], + }, + two.collect, + ), + ).toBe("[2 images attached to the result]") + expect(two.attachments).toHaveLength(2) + + const mixed = collector() + expect( + toSandboxResult( + { + content: [ + { type: "image", data: "A", mimeType: "image/png" }, + { type: "audio", data: "B", mimeType: "audio/wav" }, + ], + }, + mixed.collect, + ), + ).toBe("[2 files attached to the result]") + }) + + test("extracts embedded resources: text inline, blobs as attachments with filenames", () => { + const { attachments, collect } = collector() + const value = toSandboxResult( + { + content: [ + { type: "resource", resource: { uri: "file:///tmp/notes.txt", mimeType: "text/plain", text: "note text" } }, + { type: "resource", resource: { uri: "file:///tmp/doc.pdf", mimeType: "application/pdf", blob: "PDF" } }, + ], + }, + collect, + ) + expect(value).toBe("note text") + expect(attachments).toEqual([ + { type: "file", mime: "application/pdf", url: "data:application/pdf;base64,PDF", filename: "doc.pdf" }, + ]) + }) + + test("collects resource_link blocks as external-URL attachments", () => { + const { attachments, collect } = collector() + const value = toSandboxResult( + { content: [{ type: "resource_link", uri: "https://example.com/report.csv", mimeType: "text/csv" }] }, + collect, + ) + expect(value).toBe("[1 file attached to the result]") + expect(attachments).toEqual([ + { type: "file", mime: "text/csv", url: "https://example.com/report.csv", filename: "report.csv" }, + ]) + }) + + test("an MCP-shaped result with nothing extractable becomes null", () => { + const { collect } = collector() + expect(toSandboxResult({ content: [] }, collect)).toBeNull() + expect(toSandboxResult({ content: [{ type: "mystery" }] }, collect)).toBeNull() + }) +}) + +describe("formatting helpers", () => { + test("formatValue", () => { + expect(formatValue("text")).toBe("text") + expect(formatValue({ a: 1 })).toBe(JSON.stringify({ a: 1 }, null, 2)) + expect(formatValue(null)).toBe("null") + expect(formatValue(undefined)).toBe("undefined") + }) + + test("withLogs", () => { + // No logs: output is returned untouched. + expect(withLogs("result", [])).toBe("result") + expect(withLogs("result")).toBe("result") + // Logs are appended as a trailing section, one line each. + expect(withLogs("result", ["a", "[warn] b"])).toBe("result\n\nLogs:\na\n[warn] b") + // Empty output still gets the section (no leading blank lines). + expect(withLogs("", ["[error] boom"])).toBe("Logs:\n[error] boom") + }) +})