Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ac384f3d27 |
@@ -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"),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
@@ -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 "."
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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">[] = []
|
||||
|
||||
@@ -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
|
||||
}),
|
||||
)
|
||||
@@ -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)")
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user