refactor(opencode): trim code-mode comments

This commit is contained in:
Aiden Cline 2026-07-03 01:23:44 -05:00
commit d357fa57fb
5 changed files with 3 additions and 176 deletions

View file

@ -159,11 +159,6 @@ 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 }>>
@ -209,10 +204,6 @@ const layer = Layer.effect(
type Transport = StdioClientTransport | StreamableHTTPClientTransport | SSEClientTransport
/**
* Connect a client via the given transport with resource safety:
* on failure the transport is closed; on success the caller owns it.
*/
const connectTransport = Effect.fn("MCP.connectTransport")(function* (transport: Transport, timeout: number) {
const directory = yield* InstanceState.directory
return yield* Effect.acquireUseRelease(

View file

@ -213,13 +213,6 @@ 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)))

View file

@ -19,18 +19,6 @@ 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.",
@ -46,8 +34,6 @@ export const Parameters = Schema.Struct({
}),
})
/** 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 = {
@ -55,17 +41,8 @@ type Metadata = {
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
@ -77,24 +54,14 @@ export type CatalogEntry = {
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[],
@ -121,8 +88,6 @@ export function groupByServer(
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>,
@ -131,13 +96,6 @@ export function buildCatalog(
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>,
@ -167,20 +125,11 @@ const lastSegment = (uri: string) => {
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 }
@ -239,19 +188,12 @@ export function toSandboxResult(raw: unknown, collect: (attachment: Attachment)
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"
@ -264,8 +206,6 @@ export function formatValue(value: unknown): string {
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) {
@ -280,10 +220,6 @@ function toolTree(catalog: readonly CatalogEntry[], run: (entry: CatalogEntry) =
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) => {
@ -340,8 +276,6 @@ export const CodeModeTool = Tool.define(
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,
@ -349,11 +283,6 @@ export const CodeModeTool = Tool.define(
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 ?? [])
@ -362,22 +291,10 @@ export const CodeModeTool = Tool.define(
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: 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. This keeps plugins aware of child calls. 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(
@ -412,11 +329,7 @@ export const CodeModeTool = Tool.define(
}),
})
// 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.
// Bridge ai-sdk AbortSignal cancellation into the Effect fiber.
const cancelled = Effect.callback<ExecuteResult>((resume) => {
const onAbort = () =>
resume(
@ -443,8 +356,6 @@ export const CodeModeTool = Tool.define(
...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,

View file

@ -20,7 +20,6 @@ import {
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=="
@ -37,16 +36,7 @@ const ctx: Tool.Context = {
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.
*/
// Avoid the SDK Client here; other MCP tests mock it process-globally.
class RawJsonRpcClient {
private nextId = 1
private pending = new Map<number, { resolve: (value: any) => void; reject: (error: Error) => void }>()
@ -56,7 +46,7 @@ class RawJsonRpcClient {
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
if (msg.id === undefined) return
const entry = this.pending.get(msg.id)
if (!entry) return
this.pending.delete(msg.id)
@ -83,16 +73,11 @@ class RawJsonRpcClient {
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",
@ -158,9 +143,6 @@ async function buildTool() {
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) =>
@ -179,7 +161,6 @@ async function buildTool() {
)
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]),
}
}
@ -201,9 +182,7 @@ describe("code mode integration (real MCP server)", () => {
)
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")
@ -252,7 +231,6 @@ describe("code mode integration (real MCP server)", () => {
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)
})

View file

@ -35,8 +35,6 @@ const ctx: Tool.Context = {
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,
@ -48,12 +46,6 @@ function mcpTool(
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>
@ -82,8 +74,6 @@ function harness(input: {
)
}
// 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]!))]
}
@ -104,8 +94,6 @@ function build(
)
}
// 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> = {},
@ -184,13 +172,9 @@ describe("code mode execute", () => {
)
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')
@ -237,37 +221,24 @@ describe("code mode execute", () => {
})
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")
@ -414,7 +385,6 @@ describe("code mode execute", () => {
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" }])
})
@ -442,7 +412,6 @@ describe("code mode execute", () => {
)
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"],
@ -453,7 +422,6 @@ describe("code mode execute", () => {
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" }] })
})
@ -489,10 +457,8 @@ describe("code mode execute", () => {
),
)
// 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"])
})
@ -508,7 +474,6 @@ describe("code mode execute", () => {
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" } }],
})
@ -546,7 +511,6 @@ describe("code mode execute", () => {
})
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")
@ -579,9 +543,6 @@ describe("code mode execute", () => {
})
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", () => {
@ -613,8 +574,6 @@ describe("code mode execute", () => {
})
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()
@ -689,7 +648,6 @@ describe("code mode permission visibility", () => {
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),
)
@ -718,7 +676,6 @@ describe("code mode permission visibility", () => {
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"])
@ -850,12 +807,9 @@ describe("formatting helpers", () => {
})
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")
})
})