refactor(opencode): trim code-mode comments
This commit is contained in:
parent
7665d61947
commit
d357fa57fb
5 changed files with 3 additions and 176 deletions
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)))
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue