Compare commits

...

2 Commits

Author SHA1 Message Date
Aiden Cline caf2d59d7f feat(opencode): expose code-mode execute behind experimental flag 2026-07-03 00:29:54 -05:00
Aiden Cline 1031bf841f feat(opencode): add code-mode MCP adapter 2026-07-03 00:28:34 -05:00
16 changed files with 2092 additions and 30 deletions
+1
View File
@@ -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:*",
@@ -43,6 +43,7 @@ export class Service extends ConfigService.Service<Service>()("@opencode/Runtime
experimentalBackgroundSubagents: enabledByExperimental("OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS"),
experimentalLspTy: bool("OPENCODE_EXPERIMENTAL_LSP_TY"),
experimentalLspTool: enabledByExperimental("OPENCODE_EXPERIMENTAL_LSP_TOOL"),
experimentalCodeMode: enabledByExperimental("OPENCODE_EXPERIMENTAL_CODE_MODE"),
experimentalOxfmt: enabledByExperimental("OPENCODE_EXPERIMENTAL_OXFMT"),
experimentalPlanMode: enabledByExperimental("OPENCODE_EXPERIMENTAL_PLAN_MODE"),
experimentalEventSystem: enabledByExperimental("OPENCODE_EXPERIMENTAL_EVENT_SYSTEM"),
+19
View File
@@ -159,6 +159,12 @@ export interface Interface {
readonly clients: () => Effect.Effect<Record<string, MCPClient>>
readonly instructions: () => Effect.Effect<ServerInstructions[]>
readonly tools: () => Effect.Effect<Record<string, Tool>>
/**
* 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<Record<string, MCPToolDef>>
readonly prompts: () => Effect.Effect<Record<string, PromptInfo & { client: string }>>
readonly resources: (clientName?: string) => Effect.Effect<Record<string, ResourceInfo & { client: string }>>
readonly resourceTemplates: (
@@ -680,6 +686,18 @@ const layer = Layer.effect(
return result
})
const defs = Effect.fn("MCP.defs")(function* () {
const result: Record<string, MCPToolDef> = {}
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<T extends { name: string }>(
s: State,
listFn: (c: Client, timeout?: number) => Promise<T[]>,
@@ -982,6 +1000,7 @@ const layer = Layer.effect(
clients,
instructions,
tools,
defs,
prompts,
resources,
resourceTemplates,
+65
View File
@@ -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* <R>(input: {
plugin: Plugin.Interface
key: string
execute: (args: any, options: ToolExecutionOptions) => R | PromiseLike<R>
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"
+12
View File
@@ -213,6 +213,18 @@ export function disabled(tools: string[], ruleset: PermissionV1.Ruleset): Set<st
)
}
/**
* The shared tool-visibility predicate: drop every tool a hard deny hides
* ({@link disabled} semantics — a matching `deny` rule with pattern `"*"`).
* Ask-level rules leave a tool fully visible and callable (it prompts at call
* time). Used both when preparing the LLM tool list (request prep) and when
* building/dispatching the code-mode MCP catalog, so the two cannot drift.
*/
export function visibleTools<T>(tools: Record<string, T>, ruleset: PermissionV1.Ruleset): Record<string, T> {
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 "."
+3 -3
View File
@@ -206,11 +206,11 @@ export const prepare = Effect.fn("LLMRequestPrep.prepare")(function* (input: Pre
})
function resolveTools(input: Pick<PrepareInput, "tools" | "agent" | "permission" | "user">) {
const disabled = Permission.disabled(
Object.keys(input.tools),
const visible = Permission.visibleTools(
input.tools,
Permission.merge(input.agent.permission, input.permission ?? []),
)
return Record.filter(input.tools, (_, k) => input.user.tools?.[k] !== false && !disabled.has(k))
return Record.filter(visible, (_, k) => input.user.tools?.[k] !== false)
}
export function hasToolCalls(messages: ModelMessage[]): boolean {
+2
View File
@@ -1237,6 +1237,8 @@ const layer = Layer.effect(
Effect.provideService(ToolRegistry.Service, registry),
Effect.provideService(MCP.Service, mcp),
Effect.provideService(Truncate.Service, truncate),
Effect.provideService(Agent.Service, agents),
Effect.provideService(RuntimeFlags.Service, flags),
)
if (lastUser.format?.type === "json_schema") {
+30 -26
View File
@@ -3,6 +3,7 @@ import { SessionV1 } from "@opencode-ai/core/v1/session"
import { Provider } from "@/provider/provider"
import { ProviderTransform } from "@/provider/transform"
import { MCP } from "@/mcp"
import { McpInvoke } from "@/mcp/invoke"
import { Permission } from "@/permission"
import { Tool } from "@/tool/tool"
import { ToolJsonSchema } from "@/tool/json-schema"
@@ -21,6 +22,7 @@ import { EffectBridge } from "@/effect/bridge"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { ModelV2 } from "@opencode-ai/core/model"
import { isRecord } from "@/util/record"
import { RuntimeFlags } from "@/effect/runtime-flags"
const MCP_RESOURCE_TOOLS = {
list: "list_mcp_resources",
@@ -52,6 +54,7 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: {
const registry = yield* ToolRegistry.Service
const mcp = yield* MCP.Service
const truncate = yield* Truncate.Service
const flags = yield* RuntimeFlags.Service
const context = (args: Record<string, unknown>, options: ToolExecutionOptions): Tool.Context => ({
sessionID: input.session.id,
@@ -86,11 +89,20 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: {
.pipe(Effect.orDie),
})
for (const item of yield* registry.tools({
const mcpTools = yield* mcp.tools()
// When code mode is enabled and MCP tools are present, the registry exposes them
// through the single code-mode `execute` tool (ToolRegistry.tools), so raw per-MCP
// registration is suppressed via the early return below. Code mode is experimental
// and off by default.
const codeMode = flags.experimentalCodeMode && Object.keys(mcpTools).length > 0
const registryTools = yield* registry.tools({
modelID: ModelV2.ID.make(input.model.api.id),
providerID: input.model.providerID,
agent: input.agent,
})) {
permission: input.session.permission,
})
for (const item of registryTools) {
const schema = ProviderTransform.schema(input.model, ToolJsonSchema.fromTool(item))
tools[item.id] = tool({
description: item.description,
@@ -381,7 +393,9 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: {
})
}
for (const [key, item] of Object.entries(yield* mcp.tools())) {
if (codeMode) return tools
for (const [key, item] of Object.entries(mcpTools)) {
const execute = item.execute
if (!execute) continue
@@ -392,29 +406,19 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: {
run.promise(
Effect.gen(function* () {
const ctx = context(args, opts)
yield* plugin.trigger(
"tool.execute.before",
{ tool: key, sessionID: ctx.sessionID, callID: opts.toolCallId },
{ args },
)
const result: Awaited<ReturnType<NonNullable<typeof execute>>> = yield* Effect.gen(function* () {
yield* ctx.ask({ permission: key, metadata: {}, patterns: ["*"], always: ["*"] })
return yield* Effect.promise(() => execute(args, opts))
}).pipe(
Effect.withSpan("Tool.execute", {
attributes: {
"tool.name": key,
"tool.call_id": opts.toolCallId,
"session.id": ctx.sessionID,
"message.id": input.processor.message.id,
},
}),
)
yield* plugin.trigger(
"tool.execute.after",
{ tool: key, sessionID: ctx.sessionID, callID: opts.toolCallId, args },
result,
)
// Shared MCP middle (before hook → permission ask → Tool.execute span →
// dispatch → after hook); this caller keeps the model-facing shaping edge below.
const result: Awaited<ReturnType<NonNullable<typeof execute>>> = yield* McpInvoke.invoke({
plugin,
key,
execute,
args,
callID: opts.toolCallId,
options: opts,
sessionID: ctx.sessionID,
messageID: input.processor.message.id,
ask: ctx.ask,
})
const textParts: string[] = []
const attachments: Omit<SessionV1.FilePart, "id" | "sessionID" | "messageID">[] = []
+428
View File
@@ -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<string, unknown> }
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<Tool.ExecuteResult["attachments"]>[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<string, AITool>,
servers: readonly string[],
mcpDefs: Record<string, MCPToolDef> = {},
): Map<string, CatalogEntry[]> {
const byLongest = [...servers].sort((a, b) => b.length - a.length)
const groups = new Map<string, CatalogEntry[]>()
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<string, AITool>,
mcpDefs: Record<string, MCPToolDef>,
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<string, AITool>,
mcpDefs: Record<string, MCPToolDef>,
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<string, unknown> | undefined {
if (input === null || input === undefined) return
if (typeof input === "object" && !Array.isArray(input)) {
const value = input as Record<string, unknown>
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<string, unknown>
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<string, unknown> | 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> = []): 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<unknown, unknown>
/** Build the `tools.<server>.<tool>` 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<string, Record<string, ToolDefinition>> = {}
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 = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
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<typeof Parameters, Metadata> = {
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<Metadata>
}
// 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<ExecuteResult>((resume) => {
const onAbort = () =>
resume(
Effect.succeed<ExecuteResult>({
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<Metadata>
}
// 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<Metadata>
}),
}
return init
}),
)
+30 -1
View File
@@ -26,6 +26,9 @@ import { Plugin } from "../plugin"
import { Provider } from "@/provider/provider"
import { WebSearchTool } from "./websearch"
import { CodeModeTool, catalogInstructions } from "./code-mode"
import { MCP } from "@/mcp"
import { McpCatalog } from "@/mcp/catalog"
import { LspTool } from "./lsp"
import * as Truncate from "./truncate"
import { ApplyPatchTool } from "./apply_patch"
@@ -51,6 +54,7 @@ import { BackgroundJob } from "@/background/job"
import { RuntimeFlags } from "@/effect/runtime-flags"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { ModelV2 } from "@opencode-ai/core/model"
import type { PermissionV1 } from "@opencode-ai/core/v1/permission"
export function webSearchEnabled(providerID: ProviderV2.ID, flags = { exa: false, parallel: false }) {
return providerID === ProviderV2.ID.opencode || flags.exa || flags.parallel
@@ -74,6 +78,7 @@ export interface Interface {
providerID: ProviderV2.ID
modelID: ModelV2.ID
agent: Agent.Info
permission?: PermissionV1.Ruleset
}) => Effect.Effect<Tool.Def[]>
}
@@ -87,6 +92,7 @@ const layer = Layer.effect(
const agents = yield* Agent.Service
const truncate = yield* Truncate.Service
const flags = yield* RuntimeFlags.Service
const mcp = yield* MCP.Service
const invalid = yield* InvalidTool
const task = yield* TaskTool
@@ -104,6 +110,7 @@ const layer = Layer.effect(
const greptool = yield* GrepTool
const patchtool = yield* ApplyPatchTool
const skilltool = yield* SkillTool
const codemode = yield* CodeModeTool
const agent = yield* Agent.Service
const state = yield* InstanceState.make<State>(
@@ -211,6 +218,7 @@ const layer = Layer.effect(
question: Tool.init(question),
lsp: Tool.init(lsptool),
plan: Tool.init(plan),
codemode: Tool.init(codemode),
})
return {
@@ -232,6 +240,7 @@ const layer = Layer.effect(
tool.patch,
...(flags.experimentalLspTool ? [tool.lsp] : []),
...(flags.experimentalPlanMode && flags.client === "cli" ? [tool.plan] : []),
...(flags.experimentalCodeMode ? [tool.codemode] : []),
],
task: tool.task,
read: tool.read,
@@ -263,11 +272,25 @@ const layer = Layer.effect(
return ["Available agent types and the tools they have access to:", description].join("\n")
})
// The grouped MCP-tool catalog appended to the code-mode base description, built
// fresh per turn so it tracks live tool-list changes. Hard-denied tools (the shared
// Permission.visibleTools predicate over the agent's ruleset) never enter the
// catalog, its inlined signatures, or the in-program search index.
const describeCodeMode = Effect.fn("ToolRegistry.describeCodeMode")(function* (agent: Agent.Info, permission?: PermissionV1.Ruleset) {
const visible = Permission.visibleTools(yield* mcp.tools(), Permission.merge(agent.permission, permission ?? []))
const servers = Object.keys(yield* mcp.clients()).map(McpCatalog.sanitize)
return catalogInstructions(visible, yield* mcp.defs(), servers)
})
const tools: Interface["tools"] = Effect.fn("ToolRegistry.tools")(function* (input) {
// Consulted once before the synchronous filter: code mode registers only when the
// experimental flag is on AND at least one MCP tool is connected.
const mcpToolCount = flags.experimentalCodeMode ? Object.keys(yield* mcp.tools()).length : 0
const filtered = (yield* all()).filter((tool) => {
if (tool.id === WebSearchTool.id) {
return webSearchEnabled(input.providerID, { exa: flags.enableExa, parallel: flags.enableParallel })
}
if (tool.id === CodeModeTool.id) return flags.experimentalCodeMode && mcpToolCount > 0
const usePatch =
input.modelID.includes("gpt-") && !input.modelID.includes("oss") && !input.modelID.includes("gpt-4")
@@ -292,7 +315,11 @@ const layer = Layer.effect(
: undefined
return {
id: tool.id,
description: [output.description, tool.id === TaskTool.id ? yield* describeTask(input.agent) : undefined]
description: [
output.description,
tool.id === TaskTool.id ? yield* describeTask(input.agent) : undefined,
tool.id === CodeModeTool.id ? yield* describeCodeMode(input.agent, input.permission) : undefined,
]
.filter(Boolean)
.join("\n"),
parameters: output.parameters,
@@ -314,6 +341,7 @@ const layer = Layer.effect(
}),
)
function isZodType(value: unknown): value is z.ZodType {
return typeof value === "object" && value !== null && "_zod" in value
}
@@ -406,6 +434,7 @@ export const node = LayerNode.make({
LSP.node,
Instruction.node,
FSUtil.node,
MCP.node,
EventV2Bridge.node,
httpClient,
CrossSpawnSpawner.node,
@@ -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({}),
@@ -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({}),
@@ -0,0 +1,164 @@
import { afterEach, describe, expect } from "bun:test"
import path from "path"
import { Effect, Layer } from "effect"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { SessionTools } from "@/session/tools"
import { ToolRegistry } from "@/tool/registry"
import { Agent } from "@/agent/agent"
import { Config } from "@/config/config"
import { MCP } from "@/mcp"
import { McpCatalog } from "@/mcp/catalog"
import { Permission } from "@/permission"
import { Plugin } from "@/plugin"
import * as Truncate from "@/tool/truncate"
import { RuntimeFlags } from "@/effect/runtime-flags"
import { InstanceState } from "@/effect/instance-state"
import { MessageID, SessionID } from "@/session/schema"
import { disposeAllInstances } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
import { TestConfig } from "../fixture/config"
import type { Tool as AITool } from "ai"
const configLayer = TestConfig.layer({
directories: () => InstanceState.directory.pipe(Effect.map((dir) => [path.join(dir, ".opencode")])),
})
// Fake MCP.Service built through the same `convertTool` path the real service uses,
// so the raw registration loop dispatches through a genuine ai-sdk execute. The fake
// client answers every call with a small text result.
function fakeMcpLayer(tools: Record<string, { description: string }>) {
const client = { callTool: async () => ({ content: [{ type: "text", text: "ok" }] }) }
const converted: Record<string, AITool> = Object.fromEntries(
Object.entries(tools).map(([key, def]) => [
key,
McpCatalog.convertTool(
{
name: key,
description: def.description,
inputSchema: { type: "object", properties: {} },
} as any,
client as any,
),
]),
)
return Layer.mock(MCP.Service, {
tools: () => Effect.succeed(converted),
defs: () => Effect.succeed({}),
clients: () => Effect.succeed({ github: { getServerCapabilities: () => undefined } } as any),
})
}
const mcpToolsLayer = fakeMcpLayer({
github_create_issue: { description: "Create an issue" },
github_list_issues: { description: "List issues" },
})
const root = LayerNode.group([ToolRegistry.node, Agent.node, MCP.node, RuntimeFlags.node])
const withCodeMode = testEffect(
LayerNode.compile(root, [
[Config.node, configLayer],
[RuntimeFlags.node, RuntimeFlags.layer({ experimentalCodeMode: true })],
[MCP.node, mcpToolsLayer],
]),
)
const withFlagOff = testEffect(
LayerNode.compile(root, [
[Config.node, configLayer],
[RuntimeFlags.node, RuntimeFlags.layer()],
[MCP.node, mcpToolsLayer],
]),
)
// Resolve the session tool record with the smallest honest stand-ins for the pieces
// resolve reads: a fabricated model (only providerID/api.id are consulted by the
// schema transform for these fixtures), a pass-through Truncate, an always-allow
// Permission, and an optionally-observing Plugin.trigger. Registry, MCP, and flags
// come from the compiled instance context.
function resolveTools(trigger?: Plugin.Interface["trigger"]) {
return Effect.gen(function* () {
const agents = yield* Agent.Service
const agent = yield* agents.defaultInfo()
return yield* SessionTools.resolve({
agent,
model: { providerID: "opencode", api: { id: "test" } } as any,
session: { id: SessionID.make("ses_session-tools"), permission: [] } as any,
processor: {
message: { id: MessageID.make("msg_session-tools") } as any,
updateToolCall: () => Effect.succeed(undefined),
completeToolCall: () => Effect.void,
},
bypassAgentCheck: false,
messages: [],
promptOps: {} as any,
})
}).pipe(
Effect.provide(
Layer.mergeAll(
Layer.mock(Permission.Service, { ask: () => Effect.void }),
Layer.mock(Plugin.Service, {
trigger:
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 }),
}),
),
),
)
}
afterEach(async () => {
await disposeAllInstances()
})
describe("session.tools resolve", () => {
withCodeMode.instance("code mode suppresses raw per-MCP registration behind the execute tool", () =>
Effect.gen(function* () {
const tools = yield* resolveTools()
const ids = Object.keys(tools)
expect(ids).toContain("execute")
expect(ids).not.toContain("github_create_issue")
expect(ids).not.toContain("github_list_issues")
}),
)
withFlagOff.instance("without code mode the raw MCP tools register and execute is absent", () =>
Effect.gen(function* () {
const tools = yield* resolveTools()
const ids = Object.keys(tools)
expect(ids).not.toContain("execute")
expect(ids).toContain("github_create_issue")
expect(ids).toContain("github_list_issues")
}),
)
withFlagOff.instance("legacy raw MCP execution fires plugin hooks keyed by the ai-sdk toolCallId", () =>
Effect.gen(function* () {
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 tools = yield* resolveTools(trigger)
const raw = tools["github_create_issue"]!
const result = yield* Effect.promise(() =>
Promise.resolve(
raw.execute!(
{ title: "x" },
{ toolCallId: "call_legacy", abortSignal: new AbortController().signal, messages: [] },
),
),
)
expect((result as any).output).toBe("ok")
expect(events.map((e) => [e.name, e.input.tool, e.input.callID])).toEqual([
["tool.execute.before", "github_create_issue", "call_legacy"],
["tool.execute.after", "github_create_issue", "call_legacy"],
])
expect(events[0]!.output).toEqual({ args: { title: "x" } })
// The after hook receives the raw MCP result, before model-facing shaping.
expect(events[1]!.output).toMatchObject({ content: [{ type: "text", text: "ok" }] })
}),
)
})
@@ -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<number, { resolve: (value: any) => 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<any> {
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<string, unknown> }, _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<string, unknown>) {
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<ReturnType<typeof buildTool>>["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<string, unknown>),
)
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<string, AITool> = {}
const mcpDefs: Record<string, MCPToolDef> = {}
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<unknown>")
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.<namespace>.<tool>(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<string, unknown> }[] }> = []
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 } }],
})
})
})
@@ -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<string, unknown>) => unknown,
inputSchema: Record<string, unknown> = { type: "object", properties: {} },
): AITool {
const client = {
callTool: async (params: { arguments?: Record<string, unknown> }) => 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<string, AITool>
defs?: Record<string, MCPToolDef>
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<string, AITool>, servers?: string[]) {
return servers ?? [...new Set(Object.keys(mcpTools).map((key) => key.split("_")[0]!))]
}
function build(
mcpTools: Record<string, AITool>,
defs: Record<string, MCPToolDef> = {},
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<string, AITool>,
defs: Record<string, MCPToolDef> = {},
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<string, MCPToolDef> = {
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<unknown>",
)
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<unknown>")
// 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<string, MCPToolDef> = {
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<string, AITool> = {}
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<unknown>")
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: "<name>" })`.')
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<string, unknown>[] = []
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<string, unknown> }[] }> = []
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<string, unknown> }[] }> = []
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")
})
})
@@ -17,6 +17,8 @@ import { InstanceState } from "@/effect/instance-state"
import { ToolJsonSchema } from "@/tool/json-schema"
import { MessageID, SessionID } from "@/session/schema"
import { RuntimeFlags } from "@/effect/runtime-flags"
import { MCP } from "@/mcp"
import { McpCatalog } from "@/mcp/catalog"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { ModelV2 } from "@opencode-ai/core/model"
@@ -57,6 +59,45 @@ const replacements = [
const it = testEffect(LayerNode.compile(root, replacements))
const withBrokenPlugin = testEffect(LayerNode.compile(root, [...replacements, [Plugin.node, brokenPluginLayer]]))
// Fake MCP.Service serving two tools on one server, built through the same
// `convertTool` path the real service uses so the code-mode catalog renders
// genuine signatures.
function fakeMcpLayer(tools: Record<string, { description: string }>) {
const client = { callTool: async () => ({ content: [] }) }
const converted = Object.fromEntries(
Object.entries(tools).map(([key, def]) => [
key,
McpCatalog.convertTool(
{
name: key,
description: def.description,
inputSchema: { type: "object", properties: {} },
} as any,
client as any,
),
]),
)
return Layer.mock(MCP.Service, {
tools: () => Effect.succeed(converted),
defs: () => Effect.succeed({}),
clients: () => Effect.succeed(Object.keys(tools).length > 0 ? ({ github: {} } as any) : {}),
})
}
const mcpToolsLayer = fakeMcpLayer({
github_create_issue: { description: "Create an issue" },
github_list_issues: { description: "List issues" },
})
const codeModeFlags = [RuntimeFlags.node, RuntimeFlags.layer({ experimentalCodeMode: true })] as const
const withCodeMode = testEffect(
LayerNode.compile(root, [[Config.node, configLayer], codeModeFlags, [MCP.node, mcpToolsLayer]]),
)
const withCodeModeNoMcp = testEffect(
LayerNode.compile(root, [[Config.node, configLayer], codeModeFlags, [MCP.node, fakeMcpLayer({})]]),
)
const withFlagOff = testEffect(LayerNode.compile(root, [...replacements, [MCP.node, mcpToolsLayer]]))
afterEach(async () => {
await disposeAllInstances()
})
@@ -491,3 +532,82 @@ describe("tool.registry", () => {
}),
)
})
describe("tool.registry code mode", () => {
const model = { providerID: ProviderV2.ID.opencode, modelID: ModelV2.ID.make("test") }
withCodeMode.instance("registers the code-mode execute tool when the flag is on and MCP tools exist", () =>
Effect.gen(function* () {
const registry = yield* ToolRegistry.Service
const agents = yield* Agent.Service
const tools = yield* registry.tools({ ...model, agent: yield* agents.defaultInfo() })
const execute = tools.find((tool) => tool.id === "execute")
if (!execute) throw new Error("code-mode execute tool was not registered")
// The registry appends the grouped catalog to the static base description.
expect(execute.description).toContain("confined runtime")
expect(execute.description).toContain("## Available tools")
expect(execute.description).toContain("tools.github.create_issue(")
expect(execute.description).toContain("tools.github.list_issues(")
}),
)
withCodeModeNoMcp.instance("does not register code mode when no MCP tools are connected", () =>
Effect.gen(function* () {
const registry = yield* ToolRegistry.Service
const agents = yield* Agent.Service
const tools = yield* registry.tools({ ...model, agent: yield* agents.defaultInfo() })
expect(tools.map((tool) => tool.id)).not.toContain("execute")
}),
)
withFlagOff.instance("does not register code mode when the flag is off, even with MCP tools", () =>
Effect.gen(function* () {
const registry = yield* ToolRegistry.Service
const agents = yield* Agent.Service
const tools = yield* registry.tools({ ...model, agent: yield* agents.defaultInfo() })
expect(tools.map((tool) => tool.id)).not.toContain("execute")
}),
)
withCodeMode.instance("hard-denied MCP tools are dropped from the catalog; ask-level ones stay", () =>
Effect.gen(function* () {
const registry = yield* ToolRegistry.Service
const agents = yield* Agent.Service
const base = yield* agents.defaultInfo()
const agent = {
...base,
permission: [
...base.permission,
{ permission: "github_create_issue", pattern: "*" as const, action: "deny" as const },
{ permission: "github_list_issues", pattern: "*" as const, action: "ask" as const },
],
}
const tools = yield* registry.tools({ ...model, agent })
const execute = tools.find((tool) => tool.id === "execute")
if (!execute) throw new Error("code-mode execute tool was not registered")
expect(execute.description).not.toContain("create_issue")
expect(execute.description).toContain("tools.github.list_issues(")
expect(execute.description).toContain("- github (1 tool)")
}),
)
withCodeMode.instance("session-level hard-denied MCP tools are dropped from the code-mode catalog", () =>
Effect.gen(function* () {
const registry = yield* ToolRegistry.Service
const agents = yield* Agent.Service
const tools = yield* registry.tools({
...model,
agent: yield* agents.defaultInfo(),
permission: [
{ permission: "github_create_issue", pattern: "*" as const, action: "deny" as const },
{ permission: "github_list_issues", pattern: "*" as const, action: "ask" as const },
],
})
const execute = tools.find((tool) => tool.id === "execute")
if (!execute) throw new Error("code-mode execute tool was not registered")
expect(execute.description).not.toContain("create_issue")
expect(execute.description).toContain("tools.github.list_issues(")
expect(execute.description).toContain("- github (1 tool)")
}),
)
})