From 000b42d2043763d6432ad24f4cae58fa4abfefc7 Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Tue, 1 Sep 2026 00:01:31 -0500 Subject: [PATCH] refactor(core): nest code mode catalog (#46541) --- packages/core/src/codemode/catalog.ts | 44 ++++++++++++++--- packages/core/src/codemode/tool.ts | 49 +++++++++++++++---- packages/core/test/codemode.test.ts | 17 +++++-- packages/core/test/codemode/catalog.test.ts | 19 ++++--- .../core/test/codemode/instructions.test.ts | 6 ++- packages/core/test/lib/tool.ts | 7 ++- packages/core/test/mcp.test.ts | 12 ++--- packages/core/test/plugin/promise.test.ts | 3 +- packages/core/test/session-generate.test.ts | 3 +- packages/core/test/tool-registry.test.ts | 49 +++++++++++++------ 10 files changed, 152 insertions(+), 57 deletions(-) diff --git a/packages/core/src/codemode/catalog.ts b/packages/core/src/codemode/catalog.ts index f8df413f90c..3c8f2b9f19c 100644 --- a/packages/core/src/codemode/catalog.ts +++ b/packages/core/src/codemode/catalog.ts @@ -1,19 +1,25 @@ export * as CodeModeCatalog from "./catalog.js" -import type { Namespace } from "@opencode-ai/schema/tool" import { Schema } from "effect" export const Tool = Schema.Struct({ - path: Schema.String, + type: Schema.Literal("tool"), + name: Schema.String, description: Schema.String, signature: Schema.String, pinned: Schema.optionalKey(Schema.Boolean), }) export type Tool = typeof Tool.Type +export type Namespace = { + readonly type: "namespace" + readonly name: string + readonly description?: string + readonly tools: ReadonlyArray +} + export type Inventory = { - readonly tools: ReadonlyArray - readonly namespaces?: ReadonlyMap + readonly tools: ReadonlyArray } const Listing = Schema.Struct({ @@ -47,14 +53,15 @@ const INLINE_BUDGET = 2_000 // considering shorter listings first until the inline budget is exhausted. export function summarize(inventory: Inventory, options: Options = {}): Summary { const budget = options.budget ?? INLINE_BUDGET - const namespaces = [...Map.groupBy(inventory.tools, (tool) => tool.path.split(".", 1)[0] ?? tool.path)] + const flattened = flatten(inventory.tools) + const namespaces = [...Map.groupBy(flattened.tools, (tool) => tool.path.split(".", 1)[0] ?? tool.path)] .sort(([left], [right]) => { if (left < right) return -1 if (left > right) return 1 return 0 }) .map(([name, namespaceEntries]) => { - const description = inventory.namespaces?.get(name)?.description + const description = flattened.namespaces.get(name)?.description const listings = namespaceEntries .map((entry) => { const firstLine = entry.description.split("\n", 1)[0]?.trim() ?? "" @@ -126,12 +133,35 @@ export function summarize(inventory: Inventory, options: Options = {}): Summary entries: namespace.listings.filter((listing) => namespace.selectedListings.has(listing)), })) return { - total: inventory.tools.length, + total: flattened.tools.length, shown: namespaceSummaries.reduce((total, namespace) => total + namespace.entries.length, 0), namespaces: namespaceSummaries, } } +function flatten(entries: ReadonlyArray, path: ReadonlyArray = []) { + const tools: Array & { readonly path: string }> = [] + const namespaces = new Map() + for (const entry of entries) { + if (entry.type === "tool") { + tools.push({ + type: "tool", + path: [...path, entry.name].join("."), + description: entry.description, + signature: entry.signature, + ...(entry.pinned === undefined ? {} : { pinned: entry.pinned }), + }) + continue + } + const next = [...path, entry.name] + namespaces.set(next.join("."), entry) + const nested = flatten(entry.tools, next) + tools.push(...nested.tools) + for (const [name, namespace] of nested.namespaces) namespaces.set(name, namespace) + } + return { tools, namespaces } +} + export function namespaceLine(namespace: typeof NamespaceSummary.Type) { const count = namespace.count === 1 ? "1 tool" : `${namespace.count} tools` const label = diff --git a/packages/core/src/codemode/tool.ts b/packages/core/src/codemode/tool.ts index f025a7d2e5f..68f36c7e4d3 100644 --- a/packages/core/src/codemode/tool.ts +++ b/packages/core/src/codemode/tool.ts @@ -40,12 +40,14 @@ type CollectedFiles = { readonly files: Array } -type ToolNode = { - tool?: Tool.Tool +type Node = { + tool?: T namespace?: ToolNamespace - readonly children: Map + readonly children: Map> } +type ToolNode = Node> + type Tools = { [name: string]: Tool.Tool | Namespace.Namespace | Tools } @@ -162,14 +164,43 @@ export const catalog = (inventory: Inventory) => { .filter((registration) => registration.options?.pinned === true) .map(qualifiedName), ) + const root: CatalogNode = { children: new Map() } + for (const namespace of inventory.namespaces?.values() ?? []) getNode(root, namespace.name).namespace = namespace + for (const tool of runtime(inventory, () => Effect.fail(toolError("Execute context is unavailable"))).catalog()) + getNode(root, tool.path).tool = { + type: "tool", + name: tool.path.split(".").at(-1) ?? tool.path, + description: tool.description, + signature: tool.signature, + pinned: pinned.has(tool.path), + } return { - tools: runtime(inventory, () => Effect.fail(toolError("Execute context is unavailable"))) - .catalog() - .map((tool) => ({ ...tool, pinned: pinned.has(tool.path) })), - ...(inventory.namespaces === undefined ? {} : { namespaces: inventory.namespaces }), + tools: renderCatalog(root), } satisfies CodeModeCatalog.Inventory } +type CatalogNode = Node + +function renderCatalog(root: CatalogNode): ReadonlyArray { + return Array.from(root.children) + .toSorted(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0)) + .flatMap(([name, node]) => { + const tools = renderCatalog(node) + const namespace = + node.namespace === undefined && tools.length === 0 + ? undefined + : { + type: "namespace" as const, + name, + ...(node.namespace?.description === undefined ? {} : { description: node.namespace.description }), + tools, + } + if (node.tool === undefined) return namespace === undefined ? [] : [namespace] + if (namespace === undefined) return [node.tool] + return [node.tool, namespace] + }) +} + function runtime( inventory: Inventory, executeTool: (name: string, tool: Info, input: unknown) => Effect.Effect, @@ -191,9 +222,9 @@ function runtime( return CodeMode.make({ tools, ...hooks }) } -function getNode(root: ToolNode, path: string) { +function getNode(root: Node, path: string) { return path.split(".").reduce((parent, name) => { - const child: ToolNode = parent.children.get(name) ?? { children: new Map() } + const child: Node = parent.children.get(name) ?? { children: new Map() } parent.children.set(name, child) return child }, root) diff --git a/packages/core/test/codemode.test.ts b/packages/core/test/codemode.test.ts index cbaa4e33d3f..5ff6afbadc3 100644 --- a/packages/core/test/codemode.test.ts +++ b/packages/core/test/codemode.test.ts @@ -10,7 +10,8 @@ describe("CodeMode", () => { it.effect("owns registrations, execute, and catalog materialization", () => Effect.gen(function* () { const tools = yield* Tool.Service - yield* tools.transform((draft) => + yield* tools.transform((draft) => { + draft.namespace({ name: "empty", description: "No tools registered yet" }) draft.add({ name: "echo", description: "Echo text", @@ -18,21 +19,27 @@ describe("CodeMode", () => { output: Schema.String, options: { pinned: true }, execute: ({ text }) => Effect.succeed({ output: text }), - }), - ) + }) + }) const snapshot = yield* tools.snapshot() expect(snapshot.definitions.some((tool) => tool.name === "execute")).toBe(true) expect(snapshot.codeModeCatalog).toStrictEqual({ tools: [ { - path: "echo", + type: "tool", + name: "echo", description: "Echo text", signature: "tools.echo(input: {\n text: string,\n}): Promise", pinned: true, }, + { + type: "namespace", + name: "empty", + description: "No tools registered yet", + tools: [], + }, ], - namespaces: new Map(), }) }).pipe( Effect.scoped, diff --git a/packages/core/test/codemode/catalog.test.ts b/packages/core/test/codemode/catalog.test.ts index 301b7f52543..e60a713d405 100644 --- a/packages/core/test/codemode/catalog.test.ts +++ b/packages/core/test/codemode/catalog.test.ts @@ -3,7 +3,8 @@ import { CodeModeCatalog } from "@opencode-ai/core/codemode/catalog" import { CodeModeInstructions } from "@opencode-ai/core/codemode/instructions" const entry = (path: string, description: string, signature?: string, pinned = false): CodeModeCatalog.Tool => ({ - path, + type: "tool", + name: path, description, signature: signature ?? `tools.${path}(input: {\n q: string,\n}): Promise`, pinned, @@ -15,12 +16,12 @@ const lookup = entry( "tools.orders.lookup(input: {\n id: string,\n}): Promise<{\n id: string,\n status: string,\n}>", ) -const render = (tools: ReadonlyArray, budget?: number) => +const render = (tools: CodeModeCatalog.Inventory["tools"], budget?: number) => CodeModeInstructions.render(CodeModeCatalog.summarize({ tools }, budget === undefined ? {} : { budget })) const update = ( - previous: ReadonlyArray, - current: ReadonlyArray, + previous: CodeModeCatalog.Inventory["tools"], + current: CodeModeCatalog.Inventory["tools"], budget?: number, ) => CodeModeInstructions.update( @@ -103,10 +104,10 @@ describe("CodeModeCatalog.summarize", () => { const listingCost = Math.round(` - ${tool.signature} // One`.length / 4) const namespaceCost = Math.round(CodeModeCatalog.namespaceLine({ name: "alpha", count: 1, entries: [] }).length / 4) const description = "A namespace description that stays visible beyond the available tool budget" - const namespaces = new Map([["alpha", { name: "alpha", description }]]) + const namespace = { type: "namespace" as const, name: "alpha", description, tools: [tool] } expect(CodeModeCatalog.summarize({ tools: [tool] }, { budget: namespaceCost + listingCost }).shown).toBe(1) - const catalog = CodeModeCatalog.summarize({ tools: [tool], namespaces }, { budget: namespaceCost + listingCost }) + const catalog = CodeModeCatalog.summarize({ tools: [namespace] }, { budget: namespaceCost + listingCost }) expect(catalog.shown).toBe(0) expect(catalog.namespaces[0]?.description).toBe(description) expect(CodeModeInstructions.render(catalog)).toContain(`- alpha (1 tool, none shown) // ${description}`) @@ -209,12 +210,10 @@ describe("CodeModeInstructions.update", () => { test("restates namespace descriptions when they change", () => { const previous = CodeModeCatalog.summarize({ - tools: [echo], - namespaces: new Map([["notes", { name: "notes", description: "Old description" }]]), + tools: [{ type: "namespace", name: "notes", description: "Old description", tools: [echo] }], }) const current = CodeModeCatalog.summarize({ - tools: [echo], - namespaces: new Map([["notes", { name: "notes", description: "New description" }]]), + tools: [{ type: "namespace", name: "notes", description: "New description", tools: [echo] }], }) const text = CodeModeInstructions.update(previous, current) expect(text).toContain("This catalog supersedes the previous Code Mode tool catalog.") diff --git a/packages/core/test/codemode/instructions.test.ts b/packages/core/test/codemode/instructions.test.ts index 7defdb69ace..6362d7d9484 100644 --- a/packages/core/test/codemode/instructions.test.ts +++ b/packages/core/test/codemode/instructions.test.ts @@ -10,13 +10,15 @@ import { it } from "../lib/effect" import { readInitial, readUpdate } from "../lib/instructions" const echo: CodeModeCatalog.Tool = { - path: "notes.echo", + type: "tool", + name: "notes.echo", description: "Echo text", signature: "tools.notes.echo(input: {\n text: string,\n}): Promise", } const lookup: CodeModeCatalog.Tool = { - path: "orders.lookup", + type: "tool", + name: "orders.lookup", description: "Look up an order", signature: "tools.orders.lookup(input: {\n id: string,\n}): Promise", } diff --git a/packages/core/test/lib/tool.ts b/packages/core/test/lib/tool.ts index 21f69fd9022..ce02d51de3c 100644 --- a/packages/core/test/lib/tool.ts +++ b/packages/core/test/lib/tool.ts @@ -1,4 +1,5 @@ import { Agent } from "@opencode-ai/core/agent" +import { CodeModeCatalog } from "@opencode-ai/core/codemode/catalog" import type { Permission } from "@opencode-ai/core/permission" import { SessionMessage } from "@opencode-ai/core/session/message" import { toSessionError } from "@opencode-ai/core/session/to-session-error" @@ -16,6 +17,9 @@ export const toolIdentity = { export const toolDefinitions = (registry: Tool.Interface, permissions?: Permission.Ruleset) => registry.snapshot(permissions).pipe(Effect.map((toolSet) => toolSet.definitions)) +export const codeModeListings = (catalog: CodeModeCatalog.Inventory) => + CodeModeCatalog.summarize(catalog, { budget: Infinity }).namespaces.flatMap((namespace) => namespace.entries) + export function waitForTool(registry: Tool.Interface, name: string, remaining = 1000): Effect.Effect { return Effect.gen(function* () { if ((yield* toolDefinitions(registry)).some((tool) => tool.name === name)) return @@ -35,7 +39,8 @@ export function waitForCodeModeTool( ): Effect.Effect { return Effect.gen(function* () { const toolSet = yield* registry.snapshot() - if (toolSet.codeModeCatalog?.tools.some((tool) => tool.path === path)) return toolSet + if (toolSet.codeModeCatalog && codeModeListings(toolSet.codeModeCatalog).some((tool) => tool.path === path)) + return toolSet if (remaining === 0) { return yield* Effect.fail(new Error(`Timed out waiting for Code Mode tool: ${path}`)) } diff --git a/packages/core/test/mcp.test.ts b/packages/core/test/mcp.test.ts index 441e7a14347..9d8335f235a 100644 --- a/packages/core/test/mcp.test.ts +++ b/packages/core/test/mcp.test.ts @@ -46,7 +46,7 @@ import { imagePassthrough } from "./lib/image" import { location } from "./fixture/location" import { tmpdirScoped } from "./fixture/tmpdir" import { hostEnvironmentLayer, recordingEnvironmentLayer } from "./fixture/environment" -import { executeTool, toolDefinitions, toolIdentity, waitForTool } from "./lib/tool" +import { codeModeListings, executeTool, toolDefinitions, toolIdentity, waitForTool } from "./lib/tool" let assertion: Deferred.Deferred | undefined let decision: Effect.Effect = Effect.void @@ -1764,7 +1764,7 @@ it.effect("advertises MCP output schemas to Code Mode", () => "direct_media", "execute", ]) - expect(toolSet.codeModeCatalog?.tools.find((tool) => tool.path === "demo.search")?.signature).toContain( + expect(codeModeListings(toolSet.codeModeCatalog!).find((tool) => tool.path === "demo.search")?.line).toContain( "ok: boolean", ) expect(execute?.description).not.toContain("tools.demo.search") @@ -1784,7 +1784,7 @@ it.effect("forwards the invoking session through direct and Code Mode MCP tools" expect(toolSet.definitions.find((tool) => tool.name === "direct_lookup")?.inputSchema).not.toHaveProperty( "properties.sessionID", ) - expect(toolSet.codeModeCatalog?.tools.find((tool) => tool.path === "demo.search")?.signature).not.toContain( + expect(codeModeListings(toolSet.codeModeCatalog!).find((tool) => tool.path === "demo.search")?.line).not.toContain( "sessionID", ) @@ -1830,7 +1830,7 @@ it.effect("returns content-only MCP results through Code Mode", () => yield* registration.flush const toolSet = yield* registry.snapshot() - expect(toolSet.codeModeCatalog?.tools.some((tool) => tool.path === "demo.status")).toBe(true) + expect(codeModeListings(toolSet.codeModeCatalog!).some((tool) => tool.path === "demo.status")).toBe(true) const execution = yield* toolSet.execute({ sessionID: Session.ID.make("ses_mcp_content_only"), @@ -1916,7 +1916,7 @@ it.effect("waits for permission before calling an MCP tool", () => const registration = yield* McpTool.Service yield* registration.flush const toolSet = yield* registry.snapshot() - expect(toolSet.codeModeCatalog?.tools.some((tool) => tool.path === "demo.search")).toBe(true) + expect(codeModeListings(toolSet.codeModeCatalog!).some((tool) => tool.path === "demo.search")).toBe(true) const fiber = yield* toolSet .execute({ @@ -1960,7 +1960,7 @@ it.effect("does not call MCP when permission is blocked", () => const registration = yield* McpTool.Service yield* registration.flush const toolSet = yield* registry.snapshot() - expect(toolSet.codeModeCatalog?.tools.some((tool) => tool.path === "demo.search")).toBe(true) + expect(codeModeListings(toolSet.codeModeCatalog!).some((tool) => tool.path === "demo.search")).toBe(true) const execution = yield* toolSet.execute({ sessionID: Session.ID.make("ses_mcp_blocked"), diff --git a/packages/core/test/plugin/promise.test.ts b/packages/core/test/plugin/promise.test.ts index ad4616331c8..8a88508fac7 100644 --- a/packages/core/test/plugin/promise.test.ts +++ b/packages/core/test/plugin/promise.test.ts @@ -18,6 +18,7 @@ import { Tool } from "@opencode-ai/core/tool" import { Provider } from "@opencode-ai/core/provider" import { Project } from "@opencode-ai/core/project" import { Workspace } from "@opencode-ai/core/workspace" +import { codeModeListings } from "../lib/tool" import { AbsolutePath } from "@opencode-ai/core/schema" import { define } from "@opencode-ai/plugin/promise/plugin" import type { Info } from "@opencode-ai/plugin/promise/tool" @@ -981,7 +982,7 @@ describe("fromPromise", () => { const snapshot = yield* registry.snapshot() expect(snapshot.definitions.map((tool) => tool.name)).toEqual(["execute"]) - expect(snapshot.codeModeCatalog?.tools.map((tool) => tool.path)).toEqual(["acme.hello"]) + expect(codeModeListings(snapshot.codeModeCatalog!).map((tool) => tool.path)).toEqual(["acme.hello"]) expect(original.definitions.map((tool) => tool.name)).toEqual(["acme_hello", "execute"]) expect( yield* snapshot.execute({ diff --git a/packages/core/test/session-generate.test.ts b/packages/core/test/session-generate.test.ts index daa10c2875d..e6fc2889d93 100644 --- a/packages/core/test/session-generate.test.ts +++ b/packages/core/test/session-generate.test.ts @@ -118,7 +118,8 @@ const tools = Layer.mock(Tool.Service, { codeModeCatalog: { tools: [ { - path: "captured.lookup", + type: "tool", + name: "captured.lookup", description: "Captured Code Mode catalog", signature: "tools.captured.lookup(input: {}): Promise", }, diff --git a/packages/core/test/tool-registry.test.ts b/packages/core/test/tool-registry.test.ts index dbcf052832e..7b655c850b1 100644 --- a/packages/core/test/tool-registry.test.ts +++ b/packages/core/test/tool-registry.test.ts @@ -14,7 +14,7 @@ import { State } from "@opencode-ai/core/state" import { Tool } from "@opencode-ai/core/tool" import type { Info } from "@opencode-ai/schema/tool" import { LayerNode } from "@opencode-ai/util/effect/layer-node" -import { executeTool, toolDefinitions } from "./lib/tool" +import { codeModeListings, executeTool, toolDefinitions } from "./lib/tool" import { Deferred, Effect, Exit, Fiber, Layer, Logger, Schema, SchemaGetter, SchemaIssue, Scope } from "effect" import { TestClock } from "effect/testing" import { z } from "zod" @@ -247,7 +247,9 @@ describe("Tool", () => { yield* update.dispose expect((yield* executeTool(service, call("acme_echo"))).output).toEqual({ text: "refreshed" }) yield* Scope.close(scope, Exit.void) - expect((yield* service.snapshot()).codeModeCatalog?.tools.map((tool) => tool.path)).toEqual(["hidden"]) + expect(codeModeListings((yield* service.snapshot()).codeModeCatalog!).map((tool) => tool.path)).toEqual([ + "hidden", + ]) yield* service.transform((draft) => draft.update("acme_echo", (tool) => { @@ -502,7 +504,7 @@ describe("Tool", () => { const snapshot = yield* service.snapshot() expect(snapshot.definitions.map((tool) => tool.name)).toEqual(["execute"]) - expect(snapshot.codeModeCatalog?.tools.map((tool) => tool.path)).toEqual([ + expect(codeModeListings(snapshot.codeModeCatalog!).map((tool) => tool.path)).toEqual([ "-lookup", "123", "123._private.-tools.2d_get_scene", @@ -542,7 +544,9 @@ describe("Tool", () => { const snapshot = yield* service.snapshot() expect(snapshot.definitions.map((tool) => tool.name)).toEqual(["first", "execute"]) - expect(snapshot.codeModeCatalog?.tools.map((tool) => tool.path)).toEqual(["invalid__namespace.second"]) + expect(codeModeListings(snapshot.codeModeCatalog!).map((tool) => tool.path)).toEqual([ + "invalid__namespace.second", + ]) }), ) @@ -560,17 +564,32 @@ describe("Tool", () => { const snapshot = yield* service.snapshot() expect(snapshot.definitions.map((tool) => tool.name)).toEqual(["registry_direct", "execute"]) - expect(snapshot.codeModeCatalog?.tools.map((tool) => tool.path)).toEqual([ + expect(codeModeListings(snapshot.codeModeCatalog!).map((tool) => tool.path)).toEqual([ "legacy.plain", "registry.search", "registry.search.sales", ]) - expect(snapshot.codeModeCatalog?.namespaces).toEqual( - new Map([ - ["registry", { name: "registry", description: "Package publishing and discovery" }], - ["registry.search", { name: "registry.search", description: "Pricing operations" }], - ]), - ) + expect(snapshot.codeModeCatalog?.tools).toEqual([ + { + type: "namespace", + name: "legacy", + tools: [expect.objectContaining({ type: "tool", name: "plain" })], + }, + { + type: "namespace", + name: "registry", + description: "Package publishing and discovery", + tools: [ + expect.objectContaining({ type: "tool", name: "search" }), + { + type: "namespace", + name: "search", + description: "Pricing operations", + tools: [expect.objectContaining({ type: "tool", name: "sales" })], + }, + ], + }, + ]) const result = yield* snapshot.execute({ ...call("execute"), call: { @@ -615,7 +634,7 @@ describe("Tool", () => { }) const snapshot = yield* service.snapshot() - expect(snapshot.codeModeCatalog?.tools.map((tool) => tool.path)).toEqual(["pricing", "pricing.sales"]) + expect(codeModeListings(snapshot.codeModeCatalog!).map((tool) => tool.path)).toEqual(["pricing", "pricing.sales"]) const result = yield* snapshot.execute({ ...call("execute"), call: { @@ -664,7 +683,7 @@ describe("Tool", () => { ]) const snapshot = yield* service.snapshot() expect(snapshot.definitions.map((tool) => tool.name)).toEqual(["healthy", "execute"]) - expect(snapshot.codeModeCatalog?.tools.map((tool) => tool.path)).toEqual(["codemode"]) + expect(codeModeListings(snapshot.codeModeCatalog!).map((tool) => tool.path)).toEqual(["codemode"]) expect((yield* snapshot.execute(call("phone_type")).pipe(Effect.flip)).message).toBe("Unknown tool: phone_type") }).pipe(Effect.provide(Logger.layer([logger]))) }) @@ -729,7 +748,7 @@ describe("Tool", () => { const snapshot = yield* service.snapshot() expect(snapshot.definitions.map((tool) => tool.name)).toEqual(["execute"]) - expect(snapshot.codeModeCatalog?.tools[0]?.signature).toContain("tools.echo") + expect(codeModeListings(snapshot.codeModeCatalog!)[0]?.line).toContain("tools.echo") }), ) @@ -1192,7 +1211,7 @@ describe("Tool", () => { }).pipe(Scope.provide(scope)) const toolSet = yield* service.snapshot() const execute = toolSet.definitions.find((tool) => tool.name === "execute") - expect(toolSet.codeModeCatalog?.tools[0]?.signature).toContain("tools.echo") + expect(codeModeListings(toolSet.codeModeCatalog!)[0]?.line).toContain("tools.echo") expect(execute?.description).toContain("confined Code Mode runtime") expect(execute?.description).not.toContain("Echo text") yield* Scope.close(scope, Exit.void)