opencode/packages/core/test/session-runner-tool-registry.test.ts

508 lines
20 KiB
TypeScript

import { describe, expect } from "bun:test"
import { Tool } from "@opencode-ai/core/tool/tool"
import { AgentV2 } from "@opencode-ai/core/agent"
import type { PermissionV2 } from "@opencode-ai/core/permission"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { Image } from "@opencode-ai/core/image"
import { SessionV2 } from "@opencode-ai/core/session"
import { SessionMessage } from "@opencode-ai/core/session/message"
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
import { executeTool, settleTool, toolDefinitions } from "./lib/tool"
import { Cause, Deferred, Effect, Exit, Fiber, Layer, Option, Schema, SchemaGetter, SchemaIssue, Scope } from "effect"
import { testEffect } from "./lib/effect"
const bounds: ToolOutputStore.BoundInput[] = []
const retentionFailure = new ToolOutputStore.StorageError({ operation: "write", cause: new Error("disk full") })
const outputStore = Layer.mock(ToolOutputStore.Service, {
bound: (input) => {
if (input.callID === "call-retention-failure") return Effect.fail(retentionFailure)
return Effect.sync(() => bounds.push(input)).pipe(
Effect.as(
input.callID === "call-bounded"
? {
output: { structured: {}, content: [{ type: "text" as const, text: "bounded reference" }] },
outputPaths: ["/managed/generic"],
}
: { output: input.output, outputPaths: [] },
),
)
},
})
const imageStore = Layer.mock(Image.Service, {
normalize: (resource, content) => {
if (resource === "corrupt.png") return Effect.fail(new Image.DecodeError({ resource }))
if (resource === "too-large.png")
return Effect.fail(
new Image.SizeError({
resource,
width: 9_000,
height: 9_000,
bytes: content.content.length,
maxWidth: 2_000,
maxHeight: 2_000,
maxBytes: 5,
}),
)
return Effect.succeed({ ...content, content: "bm9ybWFsaXplZA==", mime: "image/jpeg" })
},
})
const registryLayer = AppNodeBuilder.build(ToolRegistry.node, [
[ToolOutputStore.node, outputStore],
[Image.node, imageStore],
])
const it = testEffect(registryLayer)
const identity = {
agent: AgentV2.ID.make("build"),
messageID: SessionMessage.ID.make("msg_registry"),
}
const sessionID = SessionV2.ID.make("ses_registry")
const call = (name: string, id = `call-${name}`): ToolRegistry.ExecuteInput => ({
sessionID,
...identity,
call: { type: "tool-call", id, name, input: { text: name } },
})
const make = (permission?: string) => {
const tool = Tool.make({
description: "Echo text",
input: Schema.Struct({ text: Schema.String }),
output: Schema.Struct({ text: Schema.String }),
execute: ({ text }) => Effect.succeed({ text }),
toModelOutput: ({ output }) => [{ type: "text", text: output.text }],
})
return permission ? Tool.withPermission(tool, permission) : tool
}
const constant = (text: string) =>
Tool.make({
description: "Return text",
input: Schema.Struct({ text: Schema.String }),
output: Schema.Struct({ text: Schema.String }),
execute: () => Effect.succeed({ text }),
toModelOutput: ({ output }) => [{ type: "text" as const, text: output.text }],
})
describe("ToolRegistry", () => {
it.effect("rejects invalid dotted namespaces", () =>
Effect.gen(function* () {
const service = yield* ToolRegistry.Service
const error = yield* service.register({ echo: make() }, { namespace: "slack..admin" }).pipe(Effect.flip)
expect(error).toBeInstanceOf(Tool.RegistrationError)
expect(error.message).toBe('Invalid tool namespace: "slack..admin"')
expect((yield* service.materialize()).definitions).toEqual([])
}),
)
it.effect("validates a registration batch before installing any tools", () =>
Effect.gen(function* () {
const service = yield* ToolRegistry.Service
const error = yield* service
.registerBatch([
{ tools: { first: make() }, options: { codemode: false } },
{ tools: { second: make() }, options: { namespace: "invalid..namespace", codemode: false } },
])
.pipe(Effect.flip)
expect(error).toBeInstanceOf(Tool.RegistrationError)
expect((yield* service.materialize()).definitions).toEqual([])
}),
)
it.effect("filters disabled tools with edit aliases and ordered wildcard precedence", () =>
Effect.gen(function* () {
const service = yield* ToolRegistry.Service
yield* service.register({
question: make(),
bash: make(),
edit: make("edit"),
write: make("edit"),
}, { codemode: false })
const names = (permissions: PermissionV2.Ruleset) =>
toolDefinitions(service, permissions).pipe(Effect.map((definitions) => definitions.map((tool) => tool.name)))
expect(yield* names([{ action: "question", resource: "*", effect: "deny" }])).toEqual(["bash", "edit", "write"])
expect(
yield* names([
{ action: "*", resource: "*", effect: "deny" },
{ action: "question", resource: "private", effect: "allow" },
]),
).toEqual(["question"])
expect(
yield* names([
{ action: "question", resource: "private", effect: "allow" },
{ action: "*", resource: "*", effect: "deny" },
]),
).toEqual([])
expect(yield* names([{ action: "edit", resource: "*", effect: "deny" }])).toEqual(["question", "bash"])
}),
)
it.effect("keeps permission decoration isolated between registrations", () =>
Effect.gen(function* () {
const service = yield* ToolRegistry.Service
const shared = make()
yield* service.register({ first: shared }, { codemode: false })
yield* service.register({ second: Tool.withPermission(shared, "edit") }, { codemode: false })
Tool.withPermission(shared, "question")
expect(
(yield* toolDefinitions(service, [{ action: "edit", resource: "*", effect: "deny" }])).map(
(definition) => definition.name,
),
).toEqual(["first"])
}),
)
it.effect("removes a scoped registration", () =>
Effect.gen(function* () {
const service = yield* ToolRegistry.Service
const scope = yield* Scope.make()
yield* service.register({ echo: make() }, { codemode: false }).pipe(Scope.provide(scope))
expect((yield* toolDefinitions(service)).map((tool) => tool.name)).toEqual(["echo"])
yield* Scope.close(scope, Exit.void)
expect(yield* toolDefinitions(service)).toEqual([])
}),
)
it.effect("preserves an interrupted registration until its scope closes", () =>
Effect.gen(function* () {
const service = yield* ToolRegistry.Service
const scope = yield* Scope.make()
const registered = yield* Deferred.make<void>()
const fiber = yield* service
.register({ echo: make() }, { codemode: false })
.pipe(
Effect.andThen(Deferred.succeed(registered, undefined)),
Effect.andThen(Effect.never),
Scope.provide(scope),
Effect.forkChild,
)
yield* Deferred.await(registered)
yield* Fiber.interrupt(fiber)
expect((yield* toolDefinitions(service)).map((tool) => tool.name)).toEqual(["echo"])
yield* Scope.close(scope, Exit.void)
expect(yield* toolDefinitions(service)).toEqual([])
}),
)
it.effect("returns model errors without swallowing interruption or defects", () =>
Effect.gen(function* () {
const service = yield* ToolRegistry.Service
yield* service.register({
failed: Tool.make({
description: "Failed",
input: Schema.Struct({}),
output: Schema.Struct({ ok: Schema.Boolean }),
execute: () => Effect.fail(new Tool.Failure({ message: "Denied" })),
}),
}, { codemode: false })
expect(
yield* executeTool(service, {
sessionID,
...identity,
call: { type: "tool-call", id: "failed", name: "failed", input: {} },
}),
).toEqual({ type: "error", value: "Denied" })
expect(
yield* executeTool(service, {
sessionID,
...identity,
call: { type: "tool-call", id: "missing", name: "missing", input: {} },
}),
).toEqual({ type: "error", value: "Unknown tool: missing" })
yield* service.register({
defect: Tool.make({
description: "Defect",
input: Schema.Struct({}),
output: Schema.Struct({}),
execute: () => Effect.die("unexpected executor defect"),
}),
}, { codemode: false })
expect(
yield* service.materialize().pipe(
Effect.flatMap((materialized) =>
materialized.settle({
sessionID,
...identity,
call: { type: "tool-call", id: "defect", name: "defect", input: {} },
}),
),
Effect.catchDefect(Effect.succeed),
),
).toBe("unexpected executor defect")
}),
)
it.effect("propagates retention failures through settlement", () =>
Effect.gen(function* () {
const service = yield* ToolRegistry.Service
yield* service.register({ echo: make() }, { codemode: false })
const materialized = yield* service.materialize()
const exit = yield* materialized.settle(call("echo", "call-retention-failure")).pipe(Effect.exit)
expect(Exit.isFailure(exit)).toBe(true)
if (Exit.isFailure(exit)) expect(Option.getOrUndefined(Cause.findErrorOption(exit.cause))).toBe(retentionFailure)
expect(retentionFailure.message).toBe("Failed to write tool output: disk full")
}),
)
it.effect("exposes settlement only through materialization", () =>
Effect.gen(function* () {
const service = yield* ToolRegistry.Service
expect("definitions" in service).toBe(false)
expect("execute" in service).toBe(false)
expect("settle" in service).toBe(false)
expect(typeof service.materialize).toBe("function")
}),
)
it.effect("passes complete invocation identity to the canonical handler", () =>
Effect.gen(function* () {
const service = yield* ToolRegistry.Service
const contexts: Tool.Context[] = []
yield* service.register({
context: Tool.make({
description: "Context",
input: Schema.Struct({}),
output: Schema.Struct({ ok: Schema.Boolean }),
execute: (_, context) => Effect.sync(() => contexts.push(context)).pipe(Effect.as({ ok: true })),
}),
}, { codemode: false })
yield* executeTool(service, {
sessionID,
...identity,
call: { type: "tool-call", id: "call-context", name: "context", input: {} },
})
expect(contexts).toEqual([
{ sessionID, ...identity, callID: "call-context", progress: expect.any(Function) },
])
}),
)
it.effect("encodes output and applies generic settlement bounding", () =>
Effect.gen(function* () {
bounds.length = 0
const service = yield* ToolRegistry.Service
yield* service.register({ bounded: make() }, { codemode: false })
expect(
yield* settleTool(service, {
sessionID,
...identity,
call: { type: "tool-call", id: "call-bounded", name: "bounded", input: { text: "complete" } },
}),
).toEqual({
result: { type: "text", value: "bounded reference" },
output: { structured: {}, content: [{ type: "text", text: "bounded reference" }] },
outputPaths: ["/managed/generic"],
})
expect(bounds).toHaveLength(1)
}),
)
it.effect("normalizes image tool output at settlement and drops unresizable images", () =>
Effect.gen(function* () {
const service = yield* ToolRegistry.Service
yield* service.register({
snapshot: Tool.make({
description: "Return images",
input: Schema.Struct({ text: Schema.String }),
output: Schema.Struct({ text: Schema.String }),
execute: ({ text }) => Effect.succeed({ text }),
toModelOutput: ({ output }) => [
{ type: "file", data: "aW1hZ2U=", mime: "image/png", name: "frame.png" },
{ type: "file", data: "aW1hZ2U=", mime: "image/png", name: "too-large.png" },
{ type: "file", data: "aW1hZ2U=", mime: "image/png", name: "corrupt.png" },
{ type: "text", text: output.text },
],
}),
}, { codemode: false })
const settlement = yield* settleTool(service, call("snapshot"))
expect(settlement.output?.content).toEqual([
{ type: "file", uri: "data:image/jpeg;base64,bm9ybWFsaXplZA==", mime: "image/jpeg", name: "frame.png" },
{ type: "text", text: "snapshot" },
{ type: "text", text: "[1 image omitted: could not be decoded.]" },
{ type: "text", text: "[1 image omitted: could not be resized below the image size limit.]" },
])
}),
)
it.effect("normalizes image progress content before it is published", () =>
Effect.gen(function* () {
const service = yield* ToolRegistry.Service
yield* service.register({
progressive: Tool.make({
description: "Emit image progress",
input: Schema.Struct({ text: Schema.String }),
output: Schema.Struct({ text: Schema.String }),
execute: ({ text }, context) =>
context
.progress({
structured: { stage: "capture" },
content: [
{ type: "file", data: "aW1hZ2U=", mime: "image/png", name: "frame.png" },
{ type: "file", data: "aW1hZ2U=", mime: "image/png", name: "too-large.png" },
],
})
.pipe(Effect.as({ text })),
}),
}, { codemode: false })
const updates: ToolRegistry.Progress[] = []
yield* settleTool(service, {
...call("progressive"),
progress: (update) =>
Effect.sync(() => {
updates.push(update)
}),
})
expect(updates).toEqual([
{
structured: { stage: "capture" },
content: [
{ type: "file", uri: "data:image/jpeg;base64,bm9ybWFsaXplZA==", mime: "image/jpeg", name: "frame.png" },
{ type: "text", text: "[1 image omitted: could not be resized below the image size limit.]" },
],
},
])
}),
)
it.effect("enforces transformed codecs at execution and projection boundaries", () =>
Effect.gen(function* () {
const service = yield* ToolRegistry.Service
const executed: string[] = []
const Transformed = Schema.Boolean.pipe(
Schema.decodeTo(Schema.String, {
decode: SchemaGetter.transform((value) => (value ? "yes" : "no")),
encode: SchemaGetter.transform((value) => value === "yes"),
}),
)
yield* service.register({
transformed: Tool.make({
description: "Transform values",
input: Schema.Struct({ value: Transformed }),
output: Schema.Struct({ value: Transformed }),
execute: ({ value }) => Effect.sync(() => executed.push(value)).pipe(Effect.as({ value })),
toModelOutput: ({ output }) => [{ type: "text", text: String(output.value) }],
}),
}, { codemode: false })
expect(
yield* executeTool(service, {
sessionID,
...identity,
call: { type: "tool-call", id: "transformed", name: "transformed", input: { value: true } },
}),
).toEqual({ type: "text", value: "true" })
expect(executed).toEqual(["yes"])
expect(
yield* executeTool(service, {
sessionID,
...identity,
call: { type: "tool-call", id: "invalid-input", name: "transformed", input: { value: "yes" } },
}),
).toMatchObject({ type: "error", value: expect.stringContaining("Invalid tool input") })
expect(executed).toEqual(["yes"])
yield* service.register({
invalid_output: Tool.make({
description: "Return invalid output",
input: Schema.Struct({}),
output: Schema.Struct({
value: Schema.Boolean.pipe(
Schema.decodeTo(Schema.String, {
decode: SchemaGetter.transform((value) => String(value)),
encode: SchemaGetter.transformOrFail((value) =>
value === "valid"
? Effect.succeed(true)
: Effect.fail(new SchemaIssue.InvalidValue(Option.some(value), { message: "invalid output" })),
),
}),
),
}),
execute: () => Effect.succeed({ value: "invalid" }),
}),
}, { codemode: false })
expect(
yield* executeTool(service, {
sessionID,
...identity,
call: { type: "tool-call", id: "invalid-output", name: "invalid_output", input: {} },
}),
).toMatchObject({ type: "error", value: expect.stringContaining("invalid value for its output schema") })
}),
)
it.effect("executes the tool advertised in a model request", () =>
Effect.gen(function* () {
const service = yield* ToolRegistry.Service
const scope = yield* Scope.make()
yield* service.register({ echo: constant("advertised") }, { codemode: false }).pipe(Scope.provide(scope))
const request = yield* service.materialize()
yield* Scope.close(scope, Exit.void)
yield* service.register({ echo: constant("replacement") }, { codemode: false })
expect((yield* request.settle(call("echo"))).result).toEqual({ type: "text", value: "advertised" })
expect(yield* executeTool(service, call("echo"))).toEqual({ type: "text", value: "replacement" })
}),
)
it.effect("reveals the previous registration after an overlay closes", () =>
Effect.gen(function* () {
const service = yield* ToolRegistry.Service
yield* service.register({ echo: constant("base") }, { codemode: false })
const overlay = yield* Scope.make()
yield* service.register({ echo: constant("overlay") }, { codemode: false }).pipe(Scope.provide(overlay))
expect(yield* executeTool(service, call("echo"))).toEqual({ type: "text", value: "overlay" })
yield* Scope.close(overlay, Exit.void)
expect(yield* executeTool(service, call("echo"))).toEqual({ type: "text", value: "base" })
}),
)
it.effect("executes codemode tools advertised in a model request", () =>
Effect.gen(function* () {
const service = yield* ToolRegistry.Service
const executed: string[] = []
const scope = yield* Scope.make()
yield* service
.register({
echo: Tool.make({
description: "Echo text",
input: Schema.Struct({ text: Schema.String }),
output: Schema.Struct({ text: Schema.String }),
execute: ({ text }) => Effect.sync(() => executed.push(`old:${text}`)).pipe(Effect.as({ text })),
}),
})
.pipe(Scope.provide(scope))
const materialized = yield* service.materialize()
yield* Scope.close(scope, Exit.void)
yield* service.register({
echo: Tool.make({
description: "Echo text",
input: Schema.Struct({ text: Schema.String }),
output: Schema.Struct({ text: Schema.String }),
execute: ({ text }) => Effect.sync(() => executed.push(`new:${text}`)).pipe(Effect.as({ text })),
}),
})
const settlement = yield* materialized.settle({
...call("execute"),
call: {
type: "tool-call",
id: "call-execute",
name: "execute",
input: { code: 'return await tools.echo({ text: "request" })' },
},
})
expect(settlement.result).toMatchObject({ type: "text" })
expect(executed).toEqual(["old:request"])
}),
)
})