diff --git a/packages/opencode/src/mcp/index.ts b/packages/opencode/src/mcp/index.ts index d336da48e3..3b93e19a19 100644 --- a/packages/opencode/src/mcp/index.ts +++ b/packages/opencode/src/mcp/index.ts @@ -159,11 +159,6 @@ export interface Interface { readonly clients: () => Effect.Effect> readonly instructions: () => Effect.Effect readonly tools: () => Effect.Effect> - /** - * Raw MCP tool definitions keyed identically to {@link tools} (`toolName(client, name)`). - * Unlike {@link tools}, these retain the original `inputSchema`/`outputSchema`, which code - * mode uses to render tool signatures (including return types) to the model. - */ readonly defs: () => Effect.Effect> readonly prompts: () => Effect.Effect> readonly resources: (clientName?: string) => Effect.Effect> @@ -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( diff --git a/packages/opencode/src/permission/index.ts b/packages/opencode/src/permission/index.ts index 44afb77843..2e27ff2424 100644 --- a/packages/opencode/src/permission/index.ts +++ b/packages/opencode/src/permission/index.ts @@ -213,13 +213,6 @@ export function disabled(tools: string[], ruleset: PermissionV1.Ruleset): Set(tools: Record, ruleset: PermissionV1.Ruleset): Record { const hidden = disabled(Object.keys(tools), ruleset) return Object.fromEntries(Object.entries(tools).filter(([name]) => !hidden.has(name))) diff --git a/packages/opencode/src/tool/code-mode.ts b/packages/opencode/src/tool/code-mode.ts index 6c78d627eb..6d4937e081 100644 --- a/packages/opencode/src/tool/code-mode.ts +++ b/packages/opencode/src/tool/code-mode.ts @@ -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 } 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[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, 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, mcpDefs: Record, @@ -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, mcpDefs: Record, @@ -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 { 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 -/** Build the `tools..` tree CodeMode executes against, one - * `Tool.make` definition per MCP tool with its render-only JSON Schemas. */ function toolTree(catalog: readonly CatalogEntry[], run: (entry: CatalogEntry) => Run) { const tree: Record> = {} for (const entry of catalog) { @@ -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 = (effect: Effect.Effect) => 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 } - // 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((resume) => { const onAbort = () => resume( @@ -443,8 +356,6 @@ export const CodeModeTool = Tool.define( ...attached, } satisfies Tool.ExecuteResult } - // Diagnostics may carry suggestions (e.g. pointing an unknown tool at - // discovery); append the ones the message doesn't already contain. const hints = (result.error.suggestions ?? []).filter((hint) => !result.error.message.includes(hint)) return { title: CODE_MODE_TOOL, diff --git a/packages/opencode/test/tool/code-mode-integration.test.ts b/packages/opencode/test/tool/code-mode-integration.test.ts index c2cec6c668..e58addfbb0 100644 --- a/packages/opencode/test/tool/code-mode-integration.test.ts +++ b/packages/opencode/test/tool/code-mode-integration.test.ts @@ -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 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 }, _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") expect(description).toContain("// Add two numbers and return the structured sum") - // Small catalog: everything is inline, so no discovery tool is advertised. expect(description).not.toContain("$codemode") - // The workflow section is present with placeholder-only call forms. expect(description).toContain("## Workflow") expect(description).toContain("`const res = await tools..(input)`") expect(description).not.toContain("total_count") @@ -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) }) diff --git a/packages/opencode/test/tool/code-mode.test.ts b/packages/opencode/test/tool/code-mode.test.ts index bf1ee0d802..e1e3c8dcaf 100644 --- a/packages/opencode/test/tool/code-mode.test.ts +++ b/packages/opencode/test/tool/code-mode.test.ts @@ -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) => 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 defs?: Record @@ -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, 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, defs: Record = {}, @@ -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") - // 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") expect(description).toContain("tools.$codemode.search(") - // PARTIAL catalogs put search first in the workflow and advertise namespace browsing. expect(description).toContain("1. Find a tool (skip when it is already listed below)") expect(description).toContain('- Browse one namespace: `await tools.$codemode.search({ query: "", namespace: "" })`.') expect(description).not.toContain("total_count") - // All op lines cost the same estimated tokens (chars/4 rounds away the 1- vs 3-digit - // name difference), so the path tiebreak decides: the lexicographically-first ops made - // the cut and the lexicographic tail (op_99 is maximal) did not. expect(description).toContain("tools.alpha.op_0(") expect(description).not.toContain("tools.alpha.op_99(") - // The runtime search tool works in-program and returns complete signatures. const tool = await build(tools, {}, ["alpha", "zeta"]) const out = await Effect.runPromise( tool.execute({ code: "return await tools.$codemode.search({ query: 'only tool', limit: 3 })" }, ctx), ) const result = JSON.parse(out.output) - // Search-result paths carry the `tools.` prefix so each is directly usable as a call site. expect(result.items.map((i: any) => i.path)).toContain("tools.zeta.only_tool") expect(result.items[0].signature).toContain("tools.") - // Search results render the pretty multiline signature: MCP input-property - // descriptions ride along as JSDoc field comments. The inline catalog stays compact. const signature = result.items.find((i: any) => i.path === "tools.zeta.only_tool").signature expect(signature).toContain("tools.zeta.only_tool(input: {\n") expect(signature).toContain(" /** Subject to look up */\n topic: string") @@ -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") }) })