refactor(core): unify v2 tool architecture (#31168)

This commit is contained in:
Kit Langton 2026-06-06 20:49:12 -04:00 committed by GitHub
commit 660a00d317
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
46 changed files with 2297 additions and 2041 deletions

View file

@ -3,8 +3,12 @@ import { Tool } from "@opencode-ai/core/public"
import { ApplicationTools } from "@opencode-ai/core/tool/application-tools"
import { PermissionV2 } from "@opencode-ai/core/permission"
import { SessionV2 } from "@opencode-ai/core/session"
import { SessionMessage } from "@opencode-ai/core/session/message"
import { AgentV2 } from "@opencode-ai/core/agent"
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
import { executeTool, settleTool, toolDefinitions } from "./lib/tool"
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
import { Tools } from "@opencode-ai/core/tool/tools"
import { Effect, Exit, Layer, Schema, Scope } from "effect"
import { testEffect } from "./lib/effect"
@ -20,11 +24,13 @@ const registry = ToolRegistry.layer.pipe(
const it = testEffect(Layer.mergeAll(applications, registry))
const sessionID = SessionV2.ID.make("ses_application_tool")
const agent = AgentV2.ID.make("build")
const assistantMessageID = SessionMessage.ID.make("msg_application_tool")
const contextual = (contexts: Tool.Context[]) =>
Tool.make({
description: "Read application context",
parameters: Schema.Struct({ query: Schema.String }),
success: Schema.Struct({ answer: Schema.String }),
input: Schema.Struct({ query: Schema.String }),
output: Schema.Struct({ answer: Schema.String }),
execute: ({ query }, context) =>
Effect.sync(() => {
contexts.push(context)
@ -37,23 +43,69 @@ const contextual = (contexts: Tool.Context[]) =>
})
describe("ApplicationTools", () => {
it.effect("keeps the Core carrier opaque and executes its single handler", () =>
Effect.gen(function* () {
const applications = yield* ApplicationTools.Service
const registry = yield* ToolRegistry.Service
const contexts: Tool.Context[] = []
const tool = contextual(contexts)
expect(Object.keys(tool)).toEqual([])
yield* applications.register({ opaque: tool })
expect(
yield* executeTool(registry, {
sessionID,
agent,
assistantMessageID,
call: { type: "tool-call", id: "call-opaque", name: "opaque", input: { query: "once" } },
}),
).toEqual({
type: "content",
value: [
{ type: "text", text: "ONCE" },
{ type: "media", mediaType: "image/png", data: "aGVsbG8=", filename: "result.png" },
],
})
expect(contexts).toEqual([{ sessionID, agent, assistantMessageID, toolCallID: "call-opaque" }])
}),
)
it.effect("exposes narrow scoped Location registration and validates names", () =>
Effect.gen(function* () {
const tools: Tools.Interface = yield* Tools.Service
const registry = yield* ToolRegistry.Service
const scope = yield* Scope.make()
yield* tools.register({ location_tool: contextual([]) }).pipe(Scope.provide(scope))
expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["location_tool"])
expect(yield* Effect.flip(tools.register({ "invalid name": contextual([]) }))).toBeInstanceOf(
Tool.RegistrationError,
)
yield* Scope.close(scope, Exit.void)
expect(yield* toolDefinitions(registry)).toEqual([])
}),
)
it.effect("filters an application tool by its name without adding execution authorization", () =>
Effect.gen(function* () {
const applications = yield* ApplicationTools.Service
const registry = yield* ToolRegistry.Service
const contexts: Tool.Context[] = []
yield* applications.attach({ application_context: contextual(contexts) })
yield* applications.register({ application_context: contextual(contexts) })
expect(yield* registry.definitions([{ action: "application_context", resource: "*", effect: "deny" }])).toEqual(
[],
)
expect(
yield* registry.settle({
yield* toolDefinitions(registry, [{ action: "application_context", resource: "*", effect: "deny" }]),
).toEqual([])
expect(
yield* settleTool(registry, {
sessionID,
agent,
assistantMessageID,
call: { type: "tool-call", id: "call-denied", name: "application_context", input: { query: "hello" } },
}),
).toMatchObject({ result: { type: "content" } })
expect(contexts).toEqual([{ sessionID, id: "call-denied", name: "application_context" }])
expect(contexts).toEqual([{ sessionID, agent, assistantMessageID, toolCallID: "call-denied" }])
}),
)
@ -63,14 +115,16 @@ describe("ApplicationTools", () => {
const registry = yield* ToolRegistry.Service
const contexts: Tool.Context[] = []
yield* applications.attach({ application_context: contextual(contexts) })
yield* applications.register({ application_context: contextual(contexts) })
expect(yield* registry.definitions()).toMatchObject([
expect(yield* toolDefinitions(registry)).toMatchObject([
{ name: "application_context", description: "Read application context" },
])
expect(
yield* registry.settle({
yield* settleTool(registry, {
sessionID,
agent,
assistantMessageID,
call: { type: "tool-call", id: "call-context", name: "application_context", input: { query: "hello" } },
}),
).toEqual({
@ -82,14 +136,14 @@ describe("ApplicationTools", () => {
],
},
output: {
structured: { answer: "HELLO" },
structured: {},
content: [
{ type: "text", text: "HELLO" },
{ type: "file", source: { type: "data", data: "aGVsbG8=" }, mime: "image/png", name: "result.png" },
],
},
})
expect(contexts).toEqual([{ sessionID, id: "call-context", name: "application_context" }])
expect(contexts).toEqual([{ sessionID, agent, assistantMessageID, toolCallID: "call-context" }])
}),
)
@ -99,11 +153,11 @@ describe("ApplicationTools", () => {
const registry = yield* ToolRegistry.Service
const scope = yield* Scope.make()
yield* applications.attach({ temporary: contextual([]) }).pipe(Scope.provide(scope))
expect((yield* registry.definitions()).map((tool) => tool.name)).toEqual(["temporary"])
yield* applications.register({ temporary: contextual([]) }).pipe(Scope.provide(scope))
expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["temporary"])
yield* Scope.close(scope, Exit.void)
expect(yield* registry.definitions()).toEqual([])
expect(yield* toolDefinitions(registry)).toEqual([])
}),
)
@ -112,13 +166,15 @@ describe("ApplicationTools", () => {
const applications = yield* ApplicationTools.Service
const registry = yield* ToolRegistry.Service
const attachmentScope = yield* Scope.make()
yield* applications.attach({ contextual: contextual([]) }).pipe(Scope.provide(attachmentScope))
expect((yield* registry.definitions()).map((tool) => tool.name)).toEqual(["contextual"])
yield* applications.register({ contextual: contextual([]) }).pipe(Scope.provide(attachmentScope))
expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["contextual"])
yield* Scope.close(attachmentScope, Exit.void)
expect(
yield* registry.settle({
yield* settleTool(registry, {
sessionID,
agent,
assistantMessageID,
call: { type: "tool-call", id: "call-removed", name: "contextual", input: { query: "hello" } },
}),
).toEqual({ result: { type: "error", value: "Unknown tool: contextual" } })
@ -132,9 +188,9 @@ describe("ApplicationTools", () => {
const scope = yield* Scope.make()
yield* Scope.close(scope, Exit.void)
yield* applications.attach({ closed: contextual([]) }).pipe(Scope.provide(scope))
yield* applications.register({ closed: contextual([]) }).pipe(Scope.provide(scope))
expect(yield* registry.definitions()).toEqual([])
expect(yield* toolDefinitions(registry)).toEqual([])
}),
)
@ -143,12 +199,12 @@ describe("ApplicationTools", () => {
const applications = yield* ApplicationTools.Service
const registry = yield* ToolRegistry.Service
const attached = { stable: contextual([]) }
yield* applications.attach(attached)
yield* applications.register(attached)
Object.assign(attached, { late: contextual([]) })
yield* Effect.scoped(applications.attach({ temporary: contextual([]) }))
yield* Effect.scoped(applications.register({ temporary: contextual([]) }))
expect((yield* registry.definitions()).map((tool) => tool.name)).toEqual(["stable"])
expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["stable"])
}),
)
@ -159,22 +215,26 @@ describe("ApplicationTools", () => {
const firstContexts: Tool.Context[] = []
const secondContexts: Tool.Context[] = []
const scope = yield* Scope.make()
yield* applications.attach({ contextual: contextual(firstContexts) })
expect((yield* registry.definitions()).map((tool) => tool.name)).toEqual(["contextual"])
yield* applications.attach({ contextual: contextual(secondContexts) }).pipe(Scope.provide(scope))
yield* applications.register({ contextual: contextual(firstContexts) })
expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["contextual"])
yield* applications.register({ contextual: contextual(secondContexts) }).pipe(Scope.provide(scope))
yield* registry.settle({
yield* settleTool(registry, {
sessionID,
agent,
assistantMessageID,
call: { type: "tool-call", id: "call-second", name: "contextual", input: { query: "second" } },
})
yield* Scope.close(scope, Exit.void)
yield* registry.settle({
yield* settleTool(registry, {
sessionID,
agent,
assistantMessageID,
call: { type: "tool-call", id: "call-first", name: "contextual", input: { query: "first" } },
})
expect(secondContexts).toEqual([{ sessionID, id: "call-second", name: "contextual" }])
expect(firstContexts).toEqual([{ sessionID, id: "call-first", name: "contextual" }])
expect(secondContexts).toEqual([{ sessionID, agent, assistantMessageID, toolCallID: "call-second" }])
expect(firstContexts).toEqual([{ sessionID, agent, assistantMessageID, toolCallID: "call-first" }])
}),
)
@ -182,32 +242,26 @@ describe("ApplicationTools", () => {
Effect.gen(function* () {
const applications = yield* ApplicationTools.Service
const registry = yield* ToolRegistry.Service
const transform = yield* registry.transform()
const locationContexts: Tool.Context[] = []
const applicationContexts: Tool.Context[] = []
const location = contextual(locationContexts)
yield* transform((editor) =>
editor.set("shared", {
tool: location.definition,
permission: { action: "question", resource: "*" },
execute: ({ parameters, sessionID, call }) =>
location.execute(parameters, { sessionID, id: call.id, name: call.name }),
}),
)
yield* applications.attach({ shared: contextual(applicationContexts) })
yield* registry.register({ shared: location })
yield* applications.register({ shared: contextual(applicationContexts) })
expect(
(yield* registry.definitions([{ action: "question", resource: "*", effect: "deny" }])).map(
(yield* toolDefinitions(registry, [{ action: "shared", resource: "*", effect: "deny" }])).map(
(definition) => definition.name,
),
).toEqual([])
expect(
yield* registry.settle({
yield* settleTool(registry, {
sessionID,
agent,
assistantMessageID,
call: { type: "tool-call", id: "call-shared", name: "shared", input: { query: "location" } },
}),
).toMatchObject({ result: { type: "content" } })
expect(locationContexts).toEqual([{ sessionID, id: "call-shared", name: "shared" }])
expect(locationContexts).toEqual([{ sessionID, agent, assistantMessageID, toolCallID: "call-shared" }])
expect(applicationContexts).toEqual([])
}),
)

View file

@ -0,0 +1,20 @@
import { AgentV2 } from "@opencode-ai/core/agent"
import { SessionMessage } from "@opencode-ai/core/session/message"
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
import { Effect } from "effect"
export const toolIdentity = {
agent: AgentV2.ID.make("build"),
assistantMessageID: SessionMessage.ID.make("msg_tool_test"),
}
export const toolDefinitions = (
registry: ToolRegistry.Interface,
permissions?: Parameters<typeof registry.materialize>[0],
) => registry.materialize(permissions).pipe(Effect.map((materialized) => materialized.definitions))
export const settleTool = (registry: ToolRegistry.Interface, input: ToolRegistry.ExecuteInput) =>
registry.materialize().pipe(Effect.flatMap((materialized) => materialized.settle(input)))
export const executeTool = (registry: ToolRegistry.Interface, input: ToolRegistry.ExecuteInput) =>
settleTool(registry, input).pipe(Effect.map((settlement) => settlement.result))

View file

@ -10,6 +10,7 @@ import { ProviderV2 } from "@opencode-ai/core/provider"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { tmpdir } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect"
import { toolDefinitions } from "./lib/tool"
import { FSUtil } from "../src/fs-util"
import { Auth } from "../src/auth"
import { EventV2 } from "../src/event"
@ -50,11 +51,11 @@ describe("LocationServiceMap", () => {
).pipe(
Effect.flatMap(([blocked, allowed]) =>
Effect.gen(function* () {
yield* (yield* ApplicationTools.Service).attach({
yield* (yield* ApplicationTools.Service).register({
application_context: Tool.make({
description: "Read application context",
parameters: Schema.Struct({}),
success: Schema.Struct({ ok: Schema.Boolean }),
input: Schema.Struct({}),
output: Schema.Struct({ ok: Schema.Boolean }),
execute: () => Effect.succeed({ ok: true }),
}),
})
@ -77,7 +78,7 @@ describe("LocationServiceMap", () => {
yield* transform((editor) => editor.provider.update(ProviderV2.ID.make("test"), () => {}))
return {
providers: yield* catalog.provider.all(),
tools: yield* (yield* ToolRegistry.Service).definitions(),
tools: yield* toolDefinitions(yield* ToolRegistry.Service),
}
}).pipe(Effect.scoped, Effect.provide(LocationServiceMap.get({ directory: AbsolutePath.make(directory) })))

View file

@ -30,11 +30,11 @@ describe("public native OpenCode API", () => {
expect(Session.ID.create()).toStartWith("ses_")
expect(Session.MessageID.create()).toStartWith("msg_")
expect(yield* opencode.sessions.list()).toBeArray()
yield* opencode.tools.attach({
yield* opencode.tools.register({
public_tool: Tool.make({
description: "Public tool",
parameters: Schema.Struct({}),
success: Schema.Struct({ ok: Schema.Boolean }),
input: Schema.Struct({}),
output: Schema.Struct({ ok: Schema.Boolean }),
execute: () => Effect.succeed({ ok: true }),
}),
})

View file

@ -1,65 +1,69 @@
import { describe, expect } from "bun:test"
import { Tool, ToolFailure } from "@opencode-ai/llm"
import { PermissionV2 } from "@opencode-ai/core/permission"
import { SessionV2 } from "@opencode-ai/core/session"
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
import { Tool } from "@opencode-ai/core/tool/tool"
import { AgentV2 } from "@opencode-ai/core/agent"
import { ApplicationTools } from "@opencode-ai/core/tool/application-tools"
import { Effect, Exit, Layer, Schema, Scope } from "effect"
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, Scope } from "effect"
import { testEffect } from "./lib/effect"
const assertions: PermissionV2.AssertInput[] = []
let denyAction: string | undefined
const permission = Layer.succeed(
PermissionV2.Service,
PermissionV2.Service.of({
assert: (input) =>
Effect.sync(() => assertions.push(input)).pipe(
Effect.andThen(
input.action === denyAction ? Effect.fail(new PermissionV2.DeniedError({ rules: [] })) : Effect.void,
),
),
ask: () => Effect.die("unused"),
reply: () => Effect.die("unused"),
get: () => Effect.die("unused"),
forSession: () => Effect.die("unused"),
list: () => Effect.die("unused"),
}),
)
const bounds: ToolOutputStore.BoundInput[] = []
const retentionFailure = new ToolOutputStore.StorageError({ operation: "write", cause: new Error("disk full") })
const outputStore = Layer.mock(ToolOutputStore.Service, {
bound: (input) => Effect.sync(() => bounds.push(input)).pipe(Effect.as({ output: input.output, outputPaths: [] })),
bound: (input) => {
if (input.toolCallID === "call-retention-failure") return Effect.fail(retentionFailure)
return Effect.sync(() => bounds.push(input)).pipe(
Effect.as(
input.toolCallID === "call-bounded"
? {
output: { structured: {}, content: [{ type: "text" as const, text: "bounded reference" }] },
outputPaths: ["/managed/generic"],
}
: { output: input.output, outputPaths: [] },
),
)
},
})
const registry = ToolRegistry.layer.pipe(Layer.provide(ApplicationTools.layer), Layer.provide(outputStore))
const it = testEffect(registry)
const identity = {
agent: AgentV2.ID.make("build"),
assistantMessageID: 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 registry = ToolRegistry.layer.pipe(
Layer.provide(permission),
Layer.provide(ApplicationTools.layer),
Layer.provide(outputStore),
)
const it = testEffect(Layer.mergeAll(permission, registry))
const echo = Tool.make({
description: "Echo text",
parameters: Schema.Struct({ text: Schema.String }),
success: Schema.Struct({ text: Schema.String }),
execute: ({ text }) => Effect.succeed({ text }),
})
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
}
describe("ToolRegistry", () => {
it.effect("matches V1 whole-tool filtering, edit aliases, and ordered wildcard precedence", () =>
it.effect("filters disabled tools with edit aliases and ordered wildcard precedence", () =>
Effect.gen(function* () {
const registry = yield* ToolRegistry.Service
const transform = yield* registry.transform()
const sessionID = SessionV2.ID.make("ses_registry_filter")
yield* transform((editor) => {
editor.set("question", { tool: echo })
editor.set("bash", { tool: echo })
editor.set("edit", { tool: echo })
editor.set("write", { tool: echo })
editor.set("apply_patch", { tool: echo })
const service = yield* ToolRegistry.Service
yield* service.register({
question: make(),
bash: make(),
edit: make("edit"),
write: make("edit"),
apply_patch: make("edit"),
})
const names = (rules: PermissionV2.Ruleset) =>
registry.definitions(rules).pipe(Effect.map((definitions) => definitions.map((tool) => tool.name)))
const names = (rules: Parameters<ToolRegistry.Interface["materialize"]>[0]) =>
toolDefinitions(service, rules).pipe(Effect.map((definitions) => definitions.map((tool) => tool.name)))
expect(yield* names([{ action: "question", resource: "*", effect: "deny" }])).toEqual([
"bash",
@ -67,253 +71,254 @@ describe("ToolRegistry", () => {
"write",
"apply_patch",
])
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: "question", resource: "*", effect: "ask" }])).toContain("question")
expect(yield* names([{ action: "edit", resource: "*", effect: "deny" }])).toEqual(["question", "bash"])
expect(
yield* names([
{ action: "edit", resource: "*", effect: "deny" },
{ action: "edit", resource: "*.md", effect: "ask" },
]),
).toEqual(["question", "bash", "edit", "write", "apply_patch"])
expect(
yield* names([
{ action: "edit", resource: "*.md", effect: "allow" },
{ action: "edit", resource: "*", effect: "deny" },
]),
).toEqual(["question", "bash"])
}),
)
it.effect("settles only through concrete leaf authorization, not catalog visibility", () =>
it.effect("keeps permission decoration isolated between registrations", () =>
Effect.gen(function* () {
const registry = yield* ToolRegistry.Service
const transform = yield* registry.transform()
const sessionID = SessionV2.ID.make("ses_registry_stale")
let executed = false
yield* transform((editor) =>
editor.set("question", {
tool: echo,
permission: { action: "question", resource: "*" },
authorize: ({ assertPermission }) =>
assertPermission({ action: "question", resources: ["actual"] }).pipe(
Effect.mapError(() => new ToolFailure({ message: "Denied" })),
),
execute: () =>
Effect.sync(() => {
executed = true
return { text: "unexpected" }
}),
}),
)
const service = yield* ToolRegistry.Service
const shared = make()
yield* service.register({ first: shared })
yield* service.register({ second: Tool.withPermission(shared, "edit") })
Tool.withPermission(shared, "question")
expect(
(yield* registry.definitions([{ action: "question", resource: "*", effect: "deny" }])).map((tool) => tool.name),
).toEqual([])
expect(
yield* registry.settle({
sessionID,
call: { type: "tool-call", id: "call-stale", name: "question", input: { text: "hello" } },
}),
).toMatchObject({ result: { type: "json", value: { text: "unexpected" } } })
expect(assertions.at(-1)).toMatchObject({ action: "question", resources: ["actual"] })
expect(executed).toBe(true)
(yield* toolDefinitions(service, [{ action: "edit", resource: "*", effect: "deny" }])).map(
(definition) => definition.name,
),
).toEqual(["first"])
}),
)
it.effect("rebuilds advertised definitions when a scoped transform closes", () =>
it.effect("reuses model definitions across provider turns", () =>
Effect.gen(function* () {
const registry = yield* ToolRegistry.Service
const service = yield* ToolRegistry.Service
yield* service.register({ echo: make() })
const first = yield* toolDefinitions(service)
const second = yield* toolDefinitions(service)
expect(second[0]).toBe(first[0])
}),
)
it.effect("removes a scoped registration", () =>
Effect.gen(function* () {
const service = yield* ToolRegistry.Service
const scope = yield* Scope.make()
const transform = yield* registry.transform().pipe(Scope.provide(scope))
yield* transform((editor) => editor.set("echo", { tool: echo, authorize: () => Effect.void }))
expect(yield* registry.definitions()).toMatchObject([{ name: "echo", description: "Echo text" }])
yield* service.register({ echo: make() }).pipe(Scope.provide(scope))
expect((yield* toolDefinitions(service)).map((tool) => tool.name)).toEqual(["echo"])
yield* Scope.close(scope, Exit.void)
expect(yield* registry.definitions()).toEqual([])
expect(yield* toolDefinitions(service)).toEqual([])
}),
)
it.effect("returns an error result for an unknown tool", () =>
it.effect("returns model errors without swallowing interruption or defects", () =>
Effect.gen(function* () {
const registry = yield* ToolRegistry.Service
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" })),
}),
})
expect(
yield* registry.execute({
sessionID: SessionV2.ID.make("ses_registry_test"),
call: { type: "tool-call", id: "call-missing", name: "missing", input: {} },
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" })
}),
)
it.effect("does not execute a tool when authorization fails", () =>
Effect.gen(function* () {
const registry = yield* ToolRegistry.Service
let executed = false
const transform = yield* registry.transform()
yield* transform((editor) =>
editor.set("denied", {
authorize: () => Effect.fail(new ToolFailure({ message: "Denied" })),
tool: Tool.make({
description: "Denied tool",
parameters: Schema.Struct({}),
success: Schema.Struct({ ok: Schema.Boolean }),
execute: () =>
Effect.sync(() => {
executed = true
return { ok: true }
}),
}),
yield* service.register({
defect: Tool.make({
description: "Defect",
input: Schema.Struct({}),
output: Schema.Struct({}),
execute: () => Effect.die("unexpected executor defect"),
}),
)
})
expect(
yield* registry.execute({
sessionID: SessionV2.ID.make("ses_registry_test"),
call: { type: "tool-call", id: "call-denied", name: "denied", input: {} },
}),
).toEqual({ type: "error", value: "Denied" })
expect(executed).toBe(false)
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("binds invocation identity while preserving leaf-owned permission inputs", () =>
it.effect("propagates retention failures through settlement", () =>
Effect.gen(function* () {
assertions.length = 0
denyAction = undefined
const registry = yield* ToolRegistry.Service
const transform = yield* registry.transform()
const sessionID = SessionV2.ID.make("ses_registry_context")
const service = yield* ToolRegistry.Service
yield* service.register({ echo: make() })
const materialized = yield* service.materialize()
const exit = yield* materialized.settle(call("echo", "call-retention-failure")).pipe(Effect.exit)
yield* transform((editor) =>
editor.set("context", {
tool: Tool.make({
description: "Context tool",
parameters: Schema.Struct({}),
success: Schema.Struct({ ok: Schema.Boolean }),
}),
execute: ({ assertPermission, call, source }) =>
assertPermission({
action: "inspect",
resources: [call.id],
save: ["*"],
metadata: { tool: call.name },
}).pipe(
Effect.as({ ok: source === undefined }),
Effect.catch(() => Effect.fail(new ToolFailure({ message: "Denied" }))),
),
}),
)
expect(
yield* registry.execute({
sessionID,
call: { type: "tool-call", id: "call-context", name: "context", input: {} },
}),
).toEqual({ type: "json", value: { ok: true } })
expect(assertions).toEqual([
{
sessionID,
action: "inspect",
resources: ["call-context"],
save: ["*"],
metadata: { tool: "context" },
},
])
expect(assertions[0]).not.toHaveProperty("source")
expect(Exit.isFailure(exit)).toBe(true)
if (Exit.isFailure(exit)) expect(Option.getOrUndefined(Cause.findErrorOption(exit.cause))).toBe(retentionFailure)
}),
)
it.effect("keeps ordered multi-assert policy flow in the leaf and stops on denial", () =>
it.effect("exposes settlement only through materialization", () =>
Effect.gen(function* () {
assertions.length = 0
denyAction = "execute"
let executed = false
const registry = yield* ToolRegistry.Service
const transform = yield* registry.transform()
yield* transform((editor) =>
editor.set("ordered", {
tool: Tool.make({
description: "Ordered policy tool",
parameters: Schema.Struct({}),
success: Schema.Struct({ ok: Schema.Boolean }),
}),
execute: ({ assertPermission }) =>
Effect.gen(function* () {
yield* assertPermission({ action: "external_directory", resources: ["/outside/*"] })
yield* assertPermission({ action: "execute", resources: ["pwd"] })
executed = true
return { ok: true }
}).pipe(Effect.catch(() => Effect.fail(new ToolFailure({ message: "Denied" })))),
}),
)
expect(
yield* registry.execute({
sessionID: SessionV2.ID.make("ses_registry_context"),
call: { type: "tool-call", id: "call-ordered", name: "ordered", input: {} },
}),
).toEqual({ type: "error", value: "Denied" })
expect(assertions.map((input) => input.action)).toEqual(["external_directory", "execute"])
expect(executed).toBe(false)
denyAction = undefined
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("settles encoded structured output with canonical projected content", () =>
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 })),
}),
})
yield* executeTool(service, {
sessionID,
...identity,
call: { type: "tool-call", id: "call-context", name: "context", input: {} },
})
expect(contexts).toEqual([{ sessionID, ...identity, toolCallID: "call-context" }])
}),
)
it.effect("encodes output and applies generic settlement bounding", () =>
Effect.gen(function* () {
bounds.length = 0
const registry = yield* ToolRegistry.Service
const transform = yield* registry.transform()
yield* transform((editor) =>
editor.set("projected", {
tool: Tool.make({
description: "Projected tool",
parameters: Schema.Struct({ prefix: Schema.String }),
success: Schema.Struct({ count: Schema.NumberFromString }),
execute: () => Effect.succeed({ count: 2 }),
toModelOutput: ({ callID, parameters, output }) => [
{ type: "text", text: `${callID}:${parameters.prefix}:${output.count}` },
],
}),
}),
)
const service = yield* ToolRegistry.Service
yield* service.register({ bounded: make() })
expect(
yield* registry.settle({
sessionID: SessionV2.ID.make("ses_registry_test"),
call: { type: "tool-call", id: "call-projected", name: "projected", input: { prefix: "count" } },
yield* settleTool(service, {
sessionID,
...identity,
call: { type: "tool-call", id: "call-bounded", name: "bounded", input: { text: "complete" } },
}),
).toMatchObject({
result: { type: "text", value: "call-projected:count:2" },
output: { structured: { count: "2" }, content: [{ type: "text", text: "call-projected:count:2" }] },
).toEqual({
result: { type: "text", value: "bounded reference" },
output: { structured: {}, content: [{ type: "text", text: "bounded reference" }] },
outputPaths: ["/managed/generic"],
})
expect(bounds).toEqual([
{
sessionID: SessionV2.ID.make("ses_registry_test"),
toolCallID: "call-projected",
output: { structured: { count: "2" }, content: [{ type: "text", text: "call-projected:count:2" }] },
},
])
expect(bounds).toHaveLength(1)
}),
)
it.effect("executes the unchanged registration advertised for a provider turn", () =>
Effect.gen(function* () {
const service = yield* ToolRegistry.Service
yield* service.register({ echo: make() })
const materialized = yield* service.materialize()
expect((yield* materialized.settle(call("echo"))).result).toEqual({ type: "text", value: "echo" })
}),
)
it.effect("rejects a call when its advertised registration was removed", () =>
Effect.gen(function* () {
const service = yield* ToolRegistry.Service
const scope = yield* Scope.make()
yield* service.register({ echo: make() }).pipe(Scope.provide(scope))
const materialized = yield* service.materialize()
yield* Scope.close(scope, Exit.void)
expect((yield* materialized.settle(call("echo"))).result).toEqual({
type: "error",
value: "Stale tool call: echo",
})
}),
)
it.effect("rejects only the replaced name from a multi-tool provider turn", () =>
Effect.gen(function* () {
const service = yield* ToolRegistry.Service
yield* service.register({ first: make(), second: make() })
const materialized = yield* service.materialize()
yield* service.register({ first: make() })
expect((yield* materialized.settle(call("first"))).result).toEqual({
type: "error",
value: "Stale tool call: first",
})
expect((yield* materialized.settle(call("second"))).result).toEqual({ type: "text", value: "second" })
}),
)
it.effect("treats revealing a previous overlay as stale", () =>
Effect.gen(function* () {
const service = yield* ToolRegistry.Service
yield* service.register({ echo: make() })
const overlay = yield* Scope.make()
yield* service.register({ echo: make() }).pipe(Scope.provide(overlay))
const materialized = yield* service.materialize()
yield* Scope.close(overlay, Exit.void)
expect((yield* materialized.settle(call("echo"))).result).toEqual({
type: "error",
value: "Stale tool call: echo",
})
}),
)
it.effect("keeps captured execution running after registration mutation", () =>
Effect.gen(function* () {
const service = yield* ToolRegistry.Service
const started = yield* Deferred.make<void>()
const release = yield* Deferred.make<void>()
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 }) =>
Deferred.succeed(started, undefined).pipe(Effect.andThen(Deferred.await(release)), Effect.as({ text })),
toModelOutput: ({ output }) => [{ type: "text", text: output.text }],
}),
})
.pipe(Scope.provide(scope))
const materialized = yield* service.materialize()
const settlement = yield* materialized.settle(call("echo")).pipe(Effect.forkChild)
yield* Deferred.await(started)
yield* Scope.close(scope, Exit.void)
yield* service.register({ echo: make() })
yield* Deferred.succeed(release, undefined)
expect(yield* Fiber.join(settlement)).toMatchObject({ result: { type: "text", value: "echo" } })
}),
)
})

View file

@ -4,7 +4,6 @@ import {
LLMError,
LLMEvent,
Model,
Tool,
TransportReason,
InvalidRequestReason,
type LLMClientShape,
@ -38,7 +37,7 @@ import { ApplicationTools } from "@opencode-ai/core/tool/application-tools"
import { AgentV2 } from "@opencode-ai/core/agent"
import { Config } from "@opencode-ai/core/config"
import { ConfigCompaction } from "@opencode-ai/core/config/compaction"
import { NativeTool } from "@opencode-ai/core/tool/native"
import { Tool } from "@opencode-ai/core/tool/tool"
import {
SessionContextEpochTable,
SessionInputTable,
@ -110,7 +109,7 @@ const recoveryModel = Model.make({
provider: "fake",
route: OpenAIChat.route.with({ limits: { context: 20_000, output: 1_000 } }),
})
const authorizations: ToolRegistry.AuthorizeInput[] = []
const authorizations: Tool.Context[] = []
const executions: string[] = []
const permission = Layer.succeed(
PermissionV2.Service,
@ -132,38 +131,31 @@ const registry = ToolRegistry.layer.pipe(
const agents = AgentV2.layer
const echo = Layer.effectDiscard(
ToolRegistry.Service.use((registry) =>
registry.contribute((editor) => {
;(editor.set("echo", {
authorize: (input) =>
Effect.sync(() => {
authorizations.push(input)
}),
tool: Tool.make({
description: "Echo text",
parameters: Schema.Struct({ text: Schema.String }),
success: Schema.Struct({ text: Schema.String }),
toModelOutput: ({ output }) => [{ type: "text", text: output.text }],
execute: ({ text }) =>
Effect.gen(function* () {
executions.push(text)
activeToolExecutions++
maxActiveToolExecutions = Math.max(maxActiveToolExecutions, activeToolExecutions)
if (activeToolExecutions === toolExecutionsReady && toolExecutionsStarted) {
yield* Deferred.succeed(toolExecutionsStarted, undefined)
}
if (toolExecutionGate) yield* Deferred.await(toolExecutionGate)
return { text }
}).pipe(Effect.ensuring(Effect.sync(() => activeToolExecutions--))),
}),
registry.register({
echo: Tool.make({
description: "Echo text",
input: Schema.Struct({ text: Schema.String }),
output: Schema.Struct({ text: Schema.String }),
toModelOutput: ({ output }) => [{ type: "text", text: output.text }],
execute: ({ text }, context) =>
Effect.gen(function* () {
authorizations.push(context)
executions.push(text)
activeToolExecutions++
maxActiveToolExecutions = Math.max(maxActiveToolExecutions, activeToolExecutions)
if (activeToolExecutions === toolExecutionsReady && toolExecutionsStarted) {
yield* Deferred.succeed(toolExecutionsStarted, undefined)
}
if (toolExecutionGate) yield* Deferred.await(toolExecutionGate)
return { text }
}).pipe(Effect.ensuring(Effect.sync(() => activeToolExecutions--))),
}),
defect: Tool.make({
description: "Fail unexpectedly",
input: Schema.Struct({}),
output: Schema.Struct({}),
execute: () => Effect.die("unexpected tool defect"),
}),
editor.set("defect", {
tool: Tool.make({
description: "Fail unexpectedly",
parameters: Schema.Struct({}),
success: Schema.Struct({}),
execute: () => Effect.die("unexpected tool defect"),
}),
}))
}),
),
).pipe(Layer.provide(registry))
@ -567,12 +559,12 @@ describe("SessionRunnerLLM", () => {
yield* setup
const applicationTools = yield* ApplicationTools.Service
const session = yield* SessionV2.Service
const contexts: NativeTool.Context[] = []
yield* applicationTools.attach({
application_context: NativeTool.make({
const contexts: Tool.Context[] = []
yield* applicationTools.register({
application_context: Tool.make({
description: "Read application context",
parameters: Schema.Struct({ query: Schema.String }),
success: Schema.Struct({ answer: Schema.String }),
input: Schema.Struct({ query: Schema.String }),
output: Schema.Struct({ answer: Schema.String }),
execute: ({ query }, context) =>
Effect.sync(() => {
contexts.push(context)
@ -594,7 +586,14 @@ describe("SessionRunnerLLM", () => {
yield* session.resume(sessionID)
expect(requests[0]?.tools.map((tool) => tool.name)).toContain("application_context")
expect(contexts).toEqual([{ sessionID, id: "call-application", name: "application_context" }])
expect(contexts).toEqual([
{
sessionID,
agent: AgentV2.ID.make("build"),
assistantMessageID: expect.stringMatching(/^msg_/),
toolCallID: "call-application",
},
])
expect(yield* session.context(sessionID)).toMatchObject([
{ type: "user", text: "Use application context" },
{
@ -1783,7 +1782,7 @@ describe("SessionRunnerLLM", () => {
expect(requests).toHaveLength(2)
expect(requests[1]?.messages.map((message) => message.role)).toEqual(["user", "assistant", "tool"])
expect(authorizations).toMatchObject([{ sessionID, call: { id: "call-echo", name: "echo" } }])
expect(authorizations).toMatchObject([{ sessionID, toolCallID: "call-echo" }])
expect(executions).toEqual(["hello"])
expect(yield* session.context(sessionID)).toMatchObject([
{ type: "user", text: "Echo this" },
@ -2937,7 +2936,7 @@ describe("SessionRunnerLLM", () => {
}),
)
it.effect("durably settles unexpected local tool defects before continuing", () =>
it.effect("propagates unexpected local tool defects operationally", () =>
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
@ -2951,25 +2950,11 @@ describe("SessionRunnerLLM", () => {
LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }),
LLMEvent.finish({ reason: "tool-calls" }),
],
[],
]
yield* session.resume(sessionID)
expect(yield* session.resume(sessionID).pipe(Effect.catchDefect(Effect.succeed))).toBe("unexpected tool defect")
expect(requests).toHaveLength(2)
expect(yield* session.context(sessionID)).toMatchObject([
{ type: "user", text: "Call defect" },
{
type: "assistant",
content: [
{
type: "tool",
id: "call-defect",
state: { status: "error", error: { message: "unexpected tool defect" } },
},
],
},
])
expect(requests).toHaveLength(1)
}),
)
@ -2979,17 +2964,15 @@ describe("SessionRunnerLLM", () => {
const session = yield* SessionV2.Service
const registry = yield* ToolRegistry.Service
const questions = yield* QuestionV2.Service
const transform = yield* registry.transform()
yield* transform((editor) =>
editor.set("question", {
tool: Tool.make({
description: "Ask the user",
parameters: Schema.Struct({}),
success: Schema.Struct({}),
}),
execute: ({ sessionID }) => questions.ask({ sessionID, questions: [] }).pipe(Effect.as({}), Effect.orDie),
yield* registry.register({
question: Tool.make({
description: "Ask the user",
input: Schema.Struct({}),
output: Schema.Struct({}),
execute: (_, context) =>
questions.ask({ sessionID: context.sessionID, questions: [] }).pipe(Effect.as({}), Effect.orDie),
}),
)
})
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Ask then stop" }), resume: false })
requests.length = 0

View file

@ -1,7 +1,7 @@
import fs from "fs/promises"
import path from "path"
import { describe, expect } from "bun:test"
import { Deferred, Effect, Fiber, Layer } from "effect"
import { Deferred, Effect, Exit, Fiber, Layer } from "effect"
import { FileMutation } from "@opencode-ai/core/file-mutation"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { Location } from "@opencode-ai/core/location"
@ -14,6 +14,7 @@ import { ApplyPatchTool } from "@opencode-ai/core/tool/apply-patch"
import { location } from "./fixture/location"
import { tmpdir } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect"
import { toolIdentity, executeTool, settleTool, toolDefinitions } from "./lib/tool"
const sessionID = SessionV2.ID.make("ses_apply_patch_tool_test")
const assertions: PermissionV2.AssertInput[] = []
@ -92,6 +93,7 @@ const withTool = <A, E, R>(directory: string, body: (registry: ToolRegistry.Inte
const registry = ToolRegistry.defaultLayer.pipe(Layer.provide(permission))
const patch = ApplyPatchTool.layer.pipe(
Layer.provide(registry),
Layer.provide(permission),
Layer.provide(resolution),
Layer.provide(mutation),
Layer.provide(filesystem),
@ -103,6 +105,7 @@ const withTool = <A, E, R>(directory: string, body: (registry: ToolRegistry.Inte
const call = (patchText: string, id = "call-apply-patch") => ({
sessionID,
...toolIdentity,
call: { type: "tool-call" as const, id, name: "apply_patch", input: { patchText } },
})
@ -129,8 +132,9 @@ describe("ApplyPatchTool", () => {
Effect.andThen(
withTool(tmp.path, (registry) =>
Effect.gen(function* () {
expect((yield* registry.definitions()).map((tool) => tool.name)).toEqual(["apply_patch"])
const settled = yield* registry.settle(
expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["apply_patch"])
const settled = yield* settleTool(
registry,
call(
"*** Begin Patch\n*** Add File: nested/new.txt\n+created\n*** Update File: update.txt\n@@\n-before\n+after\n*** Delete File: remove.txt\n*** End Patch",
),
@ -146,7 +150,7 @@ describe("ApplyPatchTool", () => {
{ type: "delete", resource: "remove.txt" },
],
})
expect(assertions).toEqual([
expect(assertions).toMatchObject([
{ sessionID, action: "edit", resources: ["nested/new.txt", "update.txt", "remove.txt"], save: ["*"] },
])
expect(readsBeforeEditApproval).toBe(0)
@ -175,7 +179,8 @@ describe("ApplyPatchTool", () => {
withTool(tmp.path, (registry) =>
Effect.gen(function* () {
expect(
yield* registry.execute(
yield* executeTool(
registry,
call(
"*** Begin Patch\n*** Add File: created.txt\n+created\n*** Update File: old.txt\n*** Move to: moved.txt\n@@\n-before\n+after\n*** End Patch",
),
@ -203,7 +208,8 @@ describe("ApplyPatchTool", () => {
withTool(active.path, (registry) =>
Effect.gen(function* () {
expect(
yield* registry.execute(
yield* executeTool(
registry,
call(`*** Begin Patch\n*** Update File: ${target}\n@@\n-before\n+after\n*** End Patch`),
),
).toMatchObject({ type: "text" })
@ -236,7 +242,8 @@ describe("ApplyPatchTool", () => {
withTool(active.path, (registry) =>
Effect.gen(function* () {
expect(
yield* registry.execute(
yield* executeTool(
registry,
call(
`*** Begin Patch\n*** Update File: ${first}\n@@\n-before\n+after\n*** Update File: ${second}\n@@\n-before\n+after\n*** End Patch`,
),
@ -266,7 +273,8 @@ describe("ApplyPatchTool", () => {
return withTool(tmp.path, (registry) =>
Effect.gen(function* () {
expect(
yield* registry.execute(
yield* executeTool(
registry,
call(
"*** Begin Patch\n*** Add File: created.txt\n+created\n*** Update File: missing.txt\n@@\n-before\n+after\n*** End Patch",
),
@ -291,7 +299,8 @@ describe("ApplyPatchTool", () => {
withTool(tmp.path, (registry) =>
Effect.gen(function* () {
expect(
yield* registry.execute(
yield* executeTool(
registry,
call("*** Begin Patch\n*** Add File: existing.txt\n+replacement\n*** End Patch"),
),
).toEqual({ type: "error", value: "Unable to apply patch at existing.txt" })
@ -315,7 +324,10 @@ describe("ApplyPatchTool", () => {
return withTool(tmp.path, (registry) =>
Effect.gen(function* () {
expect(
yield* registry.execute(call("*** Begin Patch\n*** Add File: appeared.txt\n+replacement\n*** End Patch")),
yield* executeTool(
registry,
call("*** Begin Patch\n*** Add File: appeared.txt\n+replacement\n*** End Patch"),
),
).toEqual({ type: "error", value: "Unable to apply patch at appeared.txt" })
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("winner\n")
}),
@ -325,7 +337,7 @@ describe("ApplyPatchTool", () => {
),
)
it.live("reports earlier sequential applications when a later commit fails", () =>
it.live("preserves a later commit defect after earlier sequential applications", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) => {
@ -338,13 +350,13 @@ describe("ApplyPatchTool", () => {
withTool(tmp.path, (registry) =>
Effect.gen(function* () {
expect(
yield* registry.execute(
call("*** Begin Patch\n*** Delete File: first.txt\n*** Delete File: second.txt\n*** End Patch"),
Exit.isFailure(
yield* executeTool(
registry,
call("*** Begin Patch\n*** Delete File: first.txt\n*** Delete File: second.txt\n*** End Patch"),
).pipe(Effect.exit),
),
).toEqual({
type: "error",
value: "Patch partially applied before failing at second.txt. Applied: first.txt",
})
).toBe(true)
expect(yield* exists(first)).toBe(false)
expect(yield* exists(second)).toBe(true)
}),
@ -370,11 +382,10 @@ describe("ApplyPatchTool", () => {
yield* Effect.promise(() => Promise.all([fs.writeFile(first, "first"), fs.writeFile(second, "second")]))
yield* withTool(tmp.path, (registry) =>
Effect.gen(function* () {
const run = yield* registry
.execute(
call("*** Begin Patch\n*** Delete File: first.txt\n*** Delete File: second.txt\n*** End Patch"),
)
.pipe(Effect.forkChild)
const run = yield* executeTool(
registry,
call("*** Begin Patch\n*** Delete File: first.txt\n*** Delete File: second.txt\n*** End Patch"),
).pipe(Effect.forkChild)
yield* Deferred.await(removeStarted!)
const interrupt = yield* Fiber.interrupt(run).pipe(Effect.forkChild)
yield* Deferred.succeed(releaseRemove!, undefined)

View file

@ -13,11 +13,11 @@ import { AppProcess } from "@opencode-ai/core/process"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { SessionV2 } from "@opencode-ai/core/session"
import { BashTool } from "@opencode-ai/core/tool/bash"
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
import { location } from "./fixture/location"
import { tmpdir } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect"
import { toolIdentity, executeTool, settleTool, toolDefinitions } from "./lib/tool"
const sessionID = SessionV2.ID.make("ses_bash_tool_test")
const assertions: PermissionV2.AssertInput[] = []
@ -27,7 +27,6 @@ const runs: Array<{
readonly shell?: string | boolean
readonly options?: AppProcess.RunOptions
}> = []
const truncations: ToolOutputStore.TruncateInput[] = []
let denyAction: string | undefined
let result: AppProcess.RunResult = {
command: "mock",
@ -39,8 +38,6 @@ let result: AppProcess.RunResult = {
}
let runFailure: AppProcess.AppProcessError | undefined
let afterPermission = (_input: PermissionV2.AssertInput): Effect.Effect<void> => Effect.void
let truncate = (input: ToolOutputStore.TruncateInput): Effect.Effect<ToolOutputStore.TruncateResult> =>
Effect.succeed({ content: input.content, truncated: false })
const permission = Layer.succeed(
PermissionV2.Service,
@ -70,16 +67,6 @@ const appProcess = Layer.succeed(
}),
} as unknown as AppProcess.Interface),
)
const resources = Layer.succeed(
ToolOutputStore.Service,
ToolOutputStore.Service.of({
limits: () => Effect.die("unused"),
write: () => Effect.die("unused"),
truncate: (input) => Effect.sync(() => truncations.push(input)).pipe(Effect.andThen(truncate(input))),
bound: (input) => Effect.succeed({ output: input.output, outputPaths: [] }),
cleanup: () => Effect.die("unused"),
}),
)
const config = Layer.succeed(
Config.Service,
Config.Service.of({
@ -90,7 +77,6 @@ const config = Layer.succeed(
const reset = () => {
assertions.length = 0
runs.length = 0
truncations.length = 0
denyAction = undefined
runFailure = undefined
afterPermission = () => Effect.void
@ -102,7 +88,6 @@ const reset = () => {
stdoutTruncated: false,
stderrTruncated: false,
}
truncate = (input) => Effect.succeed({ content: input.content, truncated: false })
}
const withTool = <A, E, R>(
@ -123,7 +108,6 @@ const withTool = <A, E, R>(
Layer.provide(mutation),
Layer.provide(filesystem),
Layer.provide(processLayer),
Layer.provide(resources),
Layer.provide(config),
)
return Effect.gen(function* () {
@ -133,6 +117,7 @@ const withTool = <A, E, R>(
const call = (input: typeof BashTool.Parameters.Type, id = "call-bash") => ({
sessionID,
...toolIdentity,
call: { type: "tool-call" as const, id, name: "bash", input },
})
@ -146,11 +131,13 @@ describe("BashTool", () => {
reset()
return withTool(tmp.path, (registry) =>
Effect.gen(function* () {
const definitions = yield* registry.definitions()
const definitions = yield* toolDefinitions(registry)
expect(definitions.map((tool) => tool.name)).toEqual(["bash"])
expect(definitions[0]?.inputSchema).not.toHaveProperty("properties.background")
expect(yield* registry.definitions([{ action: "bash", resource: "*", effect: "deny" }])).toEqual([])
expect(yield* registry.settle(call({ command: "pwd", description: "Print working directory" }))).toEqual({
expect(yield* toolDefinitions(registry, [{ action: "bash", resource: "*", effect: "deny" }])).toEqual([])
expect(
yield* settleTool(registry, call({ command: "pwd", description: "Print working directory" })),
).toEqual({
result: { type: "text", value: "hello\n\n\nCommand exited with code 0." },
output: {
structured: {
@ -168,7 +155,7 @@ describe("BashTool", () => {
maxOutputBytes: BashTool.MAX_CAPTURE_BYTES,
maxErrorBytes: BashTool.MAX_CAPTURE_BYTES,
})
expect(assertions).toEqual([{ sessionID, action: "bash", resources: ["pwd"], save: ["pwd"] }])
expect(assertions).toMatchObject([{ sessionID, action: "bash", resources: ["pwd"], save: ["pwd"] }])
}),
)
},
@ -182,7 +169,9 @@ describe("BashTool", () => {
(tmp) => {
reset()
return Effect.promise(() => fs.mkdir(path.join(tmp.path, "src"))).pipe(
Effect.andThen(withTool(tmp.path, (registry) => registry.execute(call({ command: "pwd", workdir: "src" })))),
Effect.andThen(
withTool(tmp.path, (registry) => executeTool(registry, call({ command: "pwd", workdir: "src" }))),
),
Effect.andThen(
Effect.sync(() => expect(runs).toMatchObject([{ cwd: realpathSync(path.join(tmp.path, "src")) }])),
),
@ -206,7 +195,9 @@ describe("BashTool", () => {
}).pipe(Effect.orDie)
: Effect.void
return Effect.promise(() => fs.mkdir(workdir)).pipe(
Effect.andThen(withTool(tmp.path, (registry) => registry.execute(call({ command: "pwd", workdir: "src" })))),
Effect.andThen(
withTool(tmp.path, (registry) => executeTool(registry, call({ command: "pwd", workdir: "src" }))),
),
Effect.andThen(
Effect.sync(() => {
expect(runs).toEqual([])
@ -227,7 +218,7 @@ describe("BashTool", () => {
reset()
return withTool(
tmp.path,
(registry) => registry.settle(call({ command: "printf core-bash" })),
(registry) => settleTool(registry, call({ command: "printf core-bash" })),
AppProcess.defaultLayer,
).pipe(
Effect.andThen((settled) =>
@ -254,7 +245,7 @@ describe("BashTool", () => {
([active, outside]) => {
reset()
return withTool(active.path, (registry) =>
registry.execute(call({ command: "pwd", workdir: outside.path })),
executeTool(registry, call({ command: "pwd", workdir: outside.path })),
).pipe(
Effect.andThen(
Effect.sync(() => {
@ -281,13 +272,15 @@ describe("BashTool", () => {
Effect.gen(function* () {
reset()
denyAction = "external_directory"
yield* withTool(active.path, (registry) => registry.execute(call({ command: "pwd", workdir: outside.path })))
yield* withTool(active.path, (registry) =>
executeTool(registry, call({ command: "pwd", workdir: outside.path })),
)
expect(assertions.map((item) => item.action)).toEqual(["external_directory"])
expect(runs).toEqual([])
reset()
denyAction = "bash"
yield* withTool(active.path, (registry) => registry.execute(call({ command: "pwd" })))
yield* withTool(active.path, (registry) => executeTool(registry, call({ command: "pwd" })))
expect(assertions.map((item) => item.action)).toEqual(["bash"])
expect(runs).toEqual([])
}),
@ -305,7 +298,7 @@ describe("BashTool", () => {
reset()
denyAction = "external_directory"
const target = path.join(outside.path, "secret.txt")
return withTool(active.path, (registry) => registry.settle(call({ command: `cat ${target}` }))).pipe(
return withTool(active.path, (registry) => settleTool(registry, call({ command: `cat ${target}` }))).pipe(
Effect.andThen((settled) =>
Effect.sync(() => {
expect(assertions.map((item) => item.action)).toEqual(["bash"])
@ -327,19 +320,13 @@ describe("BashTool", () => {
),
)
it.live("keeps non-zero exits useful and exposes managed overflow by path", () =>
it.live("keeps non-zero exits useful", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) => {
reset()
result = { ...result, exitCode: 7, stdout: Buffer.from("HEAD full output TAIL") }
truncate = (input) =>
Effect.succeed({
content: "HEAD\n\n... output truncated; full content saved to /tmp/tool-output/tool_opaque ...\n\nTAIL",
truncated: true,
outputPath: "/tmp/tool-output/tool_opaque",
})
return withTool(tmp.path, (registry) => registry.settle(call({ command: "false" }, "call-overflow"))).pipe(
return withTool(tmp.path, (registry) => settleTool(registry, call({ command: "false" }, "call-overflow"))).pipe(
Effect.andThen((settled) =>
Effect.sync(() => {
expect(settled.result).toMatchObject({
@ -350,13 +337,9 @@ describe("BashTool", () => {
command: "false",
cwd: realpathSync(tmp.path),
exitCode: 7,
truncated: true,
outputPath: "/tmp/tool-output/tool_opaque",
output: "HEAD full output TAIL",
truncated: false,
})
expect(settled.outputPaths).toEqual(["/tmp/tool-output/tool_opaque"])
expect(truncations).toMatchObject([
{ sessionID, toolCallID: "call-overflow", content: "HEAD full output TAIL" },
])
}),
),
)
@ -371,7 +354,7 @@ describe("BashTool", () => {
(tmp) => {
reset()
result = { ...result, stdoutTruncated: true }
return withTool(tmp.path, (registry) => registry.settle(call({ command: "verbose" }))).pipe(
return withTool(tmp.path, (registry) => settleTool(registry, call({ command: "verbose" }))).pipe(
Effect.andThen((settled) =>
Effect.sync(() => {
expect(settled.output?.structured).toMatchObject({ truncated: true, stdoutTruncated: true })
@ -394,7 +377,7 @@ describe("BashTool", () => {
(tmp) => {
reset()
runFailure = new AppProcess.AppProcessError({ command: "sleep", cause: new Error("Timed out") })
return withTool(tmp.path, (registry) => registry.settle(call({ command: "sleep 60", timeout: 10 }))).pipe(
return withTool(tmp.path, (registry) => settleTool(registry, call({ command: "sleep 60", timeout: 10 }))).pipe(
Effect.andThen((settled) =>
Effect.sync(() => {
expect(settled.result).toMatchObject({

View file

@ -15,6 +15,7 @@ import { EditTool } from "@opencode-ai/core/tool/edit"
import { location } from "./fixture/location"
import { tmpdir } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect"
import { toolIdentity, executeTool, settleTool, toolDefinitions } from "./lib/tool"
const sessionID = SessionV2.ID.make("ses_edit_tool_test")
const assertions: PermissionV2.AssertInput[] = []
@ -82,6 +83,7 @@ const withTool = <A, E, R>(directory: string, body: (registry: ToolRegistry.Inte
const registry = ToolRegistry.defaultLayer.pipe(Layer.provide(permission))
const edit = EditTool.layer.pipe(
Layer.provide(registry),
Layer.provide(permission),
Layer.provide(resolution),
Layer.provide(mutation),
Layer.provide(filesystem),
@ -93,6 +95,7 @@ const withTool = <A, E, R>(directory: string, body: (registry: ToolRegistry.Inte
const call = (input: typeof EditTool.Parameters.Type, id = "call-edit") => ({
sessionID,
...toolIdentity,
call: { type: "tool-call" as const, id, name: "edit", input },
})
@ -109,9 +112,12 @@ describe("EditTool", () => {
Effect.andThen(
withTool(tmp.path, (registry) =>
Effect.gen(function* () {
expect((yield* registry.definitions()).map((tool) => tool.name)).toEqual(["edit"])
expect(yield* registry.definitions([{ action: "edit", resource: "*", effect: "deny" }])).toEqual([])
const settled = yield* registry.settle(
expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["edit"])
expect(yield* toolDefinitions(registry, [{ action: "edit", resource: "*", effect: "deny" }])).toEqual(
[],
)
const settled = yield* settleTool(
registry,
call({ path: "hello.txt", oldString: "before", newString: "after" }),
)
expect(settled.result).toEqual({
@ -126,7 +132,7 @@ describe("EditTool", () => {
replacements: 1,
})
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("after\nrest\n")
expect(assertions).toEqual([{ sessionID, action: "edit", resources: ["hello.txt"], save: ["*"] }])
expect(assertions).toMatchObject([{ sessionID, action: "edit", resources: ["hello.txt"], save: ["*"] }])
expect(writes).toEqual([yield* Effect.promise(() => fs.realpath(target))])
}),
),
@ -146,7 +152,7 @@ describe("EditTool", () => {
return Effect.promise(() => fs.writeFile(target, "before")).pipe(
Effect.andThen(
withTool(tmp.path, (registry) =>
registry.execute(call({ path: target, oldString: "before", newString: "after" })),
executeTool(registry, call({ path: target, oldString: "before", newString: "after" })),
),
),
Effect.andThen((result) =>
@ -171,7 +177,7 @@ describe("EditTool", () => {
return Effect.promise(() => fs.writeFile(target, "before")).pipe(
Effect.andThen(
withTool(active.path, (registry) =>
registry.execute(call({ path: target, oldString: "before", newString: "after" })),
executeTool(registry, call({ path: target, oldString: "before", newString: "after" })),
),
),
Effect.andThen((result) =>
@ -202,7 +208,7 @@ describe("EditTool", () => {
denyAction = "external_directory"
expect(
yield* withTool(active.path, (registry) =>
registry.execute(call({ path: external, oldString: "before", newString: "after" })),
executeTool(registry, call({ path: external, oldString: "before", newString: "after" })),
),
).toEqual({
type: "error",
@ -216,7 +222,7 @@ describe("EditTool", () => {
denyAction = "edit"
expect(
yield* withTool(active.path, (registry) =>
registry.execute(call({ path: external, oldString: "before", newString: "after" })),
executeTool(registry, call({ path: external, oldString: "before", newString: "after" })),
),
).toEqual({
type: "error",
@ -245,10 +251,12 @@ describe("EditTool", () => {
Effect.andThen(
withTool(tmp.path, (registry) =>
Effect.gen(function* () {
const matching = yield* registry.execute(
const matching = yield* executeTool(
registry,
call({ path: "secret.txt", oldString: "secret content", newString: "replacement" }),
)
const missing = yield* registry.execute(
const missing = yield* executeTool(
registry,
call({ path: "secret.txt", oldString: "not present", newString: "replacement" }),
)
@ -277,26 +285,26 @@ describe("EditTool", () => {
withTool(tmp.path, (registry) =>
Effect.gen(function* () {
expect(
yield* registry.execute(call({ path: "matches.txt", oldString: "same", newString: "same" })),
yield* executeTool(registry, call({ path: "matches.txt", oldString: "same", newString: "same" })),
).toEqual({
type: "error",
value: "No changes to apply: oldString and newString are identical.",
})
expect(
yield* registry.execute(call({ path: "matches.txt", oldString: "", newString: "after" })),
yield* executeTool(registry, call({ path: "matches.txt", oldString: "", newString: "after" })),
).toEqual({
type: "error",
value: "oldString must not be empty. Use write to create or overwrite a file.",
})
expect(
yield* registry.execute(call({ path: "matches.txt", oldString: "missing", newString: "after" })),
yield* executeTool(registry, call({ path: "matches.txt", oldString: "missing", newString: "after" })),
).toEqual({
type: "error",
value:
"Could not find oldString in the file. It must match exactly, including whitespace and indentation.",
})
expect(
yield* registry.execute(call({ path: "matches.txt", oldString: "same", newString: "after" })),
yield* executeTool(registry, call({ path: "matches.txt", oldString: "same", newString: "after" })),
).toEqual({
type: "error",
value:
@ -321,7 +329,7 @@ describe("EditTool", () => {
return Effect.promise(() => fs.writeFile(target, "same same same")).pipe(
Effect.andThen(
withTool(tmp.path, (registry) =>
registry.settle(call({ path: "all.txt", oldString: "same", newString: "after", replaceAll: true })),
settleTool(registry, call({ path: "all.txt", oldString: "same", newString: "after", replaceAll: true })),
),
),
Effect.andThen((settled) =>
@ -346,7 +354,7 @@ describe("EditTool", () => {
return Effect.promise(() => fs.writeFile(target, "\uFEFFbefore\r\nrest\r\n")).pipe(
Effect.andThen(
withTool(tmp.path, (registry) =>
registry.execute(call({ path: "windows.txt", oldString: "before\nrest", newString: "after\nrest" })),
executeTool(registry, call({ path: "windows.txt", oldString: "before\nrest", newString: "after\nrest" })),
),
),
Effect.andThen(() => Effect.promise(() => fs.readFile(target, "utf8"))),
@ -367,7 +375,7 @@ describe("EditTool", () => {
return Effect.promise(() => fs.writeFile(target, "before\n")).pipe(
Effect.andThen(
withTool(tmp.path, (registry) =>
registry.execute(call({ path: "concurrent.txt", oldString: "before", newString: "after" })),
executeTool(registry, call({ path: "concurrent.txt", oldString: "before", newString: "after" })),
),
),
Effect.andThen((result) =>
@ -390,7 +398,7 @@ describe("EditTool", () => {
test("keeps the locked edit schema, semantics docstring, and deferred TODOs visible", async () => {
const source = (await fs.readFile(new URL("../src/tool/edit.ts", import.meta.url), "utf8")).replaceAll("\r\n", "\n")
const definition = await Effect.runPromise(
withTool(path.dirname(fileURLToPath(import.meta.url)), (registry) => registry.definitions()),
withTool(path.dirname(fileURLToPath(import.meta.url)), (registry) => toolDefinitions(registry)),
)
const schema = definition[0]?.inputSchema as { readonly properties?: Record<string, unknown> }

View file

@ -8,6 +8,7 @@ import { SessionV2 } from "@opencode-ai/core/session"
import { GlobTool } from "@opencode-ai/core/tool/glob"
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
import { testEffect } from "./lib/effect"
import { toolIdentity, executeTool, settleTool, toolDefinitions } from "./lib/tool"
const sessionID = SessionV2.ID.make("ses_glob_tool_test")
const assertions: PermissionV2.AssertInput[] = []
@ -92,6 +93,7 @@ const reset = () => {
const call = (input: typeof GlobTool.Parameters.Type, id = "call-glob") => ({
sessionID,
...toolIdentity,
call: { type: "tool-call" as const, id, name: "glob", input },
})
@ -99,7 +101,7 @@ describe("GlobTool", () => {
it.effect("registers the glob definition", () =>
Effect.gen(function* () {
reset()
expect((yield* (yield* ToolRegistry.Service).definitions()).map((tool) => tool.name)).toEqual(["glob"])
expect((yield* toolDefinitions(yield* ToolRegistry.Service)).map((tool) => tool.name)).toEqual(["glob"])
}),
)
@ -108,11 +110,13 @@ describe("GlobTool", () => {
reset()
const registry = yield* ToolRegistry.Service
expect(yield* registry.execute(call({ pattern: "**/*.ts", path: RelativePath.make("src"), limit: 12 }))).toEqual({
expect(
yield* executeTool(registry, call({ pattern: "**/*.ts", path: RelativePath.make("src"), limit: 12 })),
).toEqual({
type: "text",
value: "No files found",
})
expect(assertions).toEqual([
expect(assertions).toMatchObject([
{
sessionID,
action: "glob",
@ -131,7 +135,7 @@ describe("GlobTool", () => {
reset()
allow = false
expect(yield* (yield* ToolRegistry.Service).execute(call({ pattern: "*.secret" }))).toEqual({
expect(yield* executeTool(yield* ToolRegistry.Service, call({ pattern: "*.secret" }))).toEqual({
type: "error",
value: "Unable to find files matching *.secret",
})
@ -155,7 +159,7 @@ describe("GlobTool", () => {
partial: false,
})
expect(yield* (yield* ToolRegistry.Service).settle(call({ pattern: "*.ts" }))).toEqual({
expect(yield* settleTool(yield* ToolRegistry.Service, call({ pattern: "*.ts" }))).toEqual({
result: { type: "text", value: "src/index.ts" },
output: {
structured: result,
@ -181,11 +185,11 @@ describe("GlobTool", () => {
partial: false,
})
expect(yield* (yield* ToolRegistry.Service).execute(call({ pattern: "*.md", reference: "docs" }))).toEqual({
expect(yield* executeTool(yield* ToolRegistry.Service, call({ pattern: "*.md", reference: "docs" }))).toEqual({
type: "text",
value: "docs:guide.md",
})
expect(assertions).toEqual([
expect(assertions).toMatchObject([
{
sessionID,
action: "glob",

View file

@ -1,7 +1,7 @@
import fs from "fs/promises"
import path from "path"
import { describe, expect } from "bun:test"
import { Effect, Layer } from "effect"
import { Effect, Exit, Layer } from "effect"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { Location } from "@opencode-ai/core/location"
import { FileSystem } from "@opencode-ai/core/filesystem"
@ -19,6 +19,7 @@ import { location } from "./fixture/location"
import { tmpdir } from "./fixture/tmpdir"
import { it as runtimeIt } from "./lib/effect"
import { testEffect } from "./lib/effect"
import { toolIdentity, executeTool, settleTool, toolDefinitions } from "./lib/tool"
const assertions: PermissionV2.AssertInput[] = []
const searches: LocationSearch.GrepInput[] = []
@ -90,12 +91,20 @@ const sessionID = SessionV2.ID.make("ses_grep_tool_test")
const execute = (input: Record<string, unknown>) =>
ToolRegistry.Service.use((registry) =>
registry.execute({ sessionID, call: { type: "tool-call", id: "call-grep", name: "grep", input } }),
executeTool(registry, {
sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-grep", name: "grep", input },
}),
)
const settle = (input: Record<string, unknown>) =>
ToolRegistry.Service.use((registry) =>
registry.settle({ sessionID, call: { type: "tool-call", id: "call-grep", name: "grep", input } }),
settleTool(registry, {
sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-grep", name: "grep", input },
}),
)
const reset = () => {
@ -145,7 +154,7 @@ describe("GrepTool", () => {
it.effect("registers the grep contribution", () =>
Effect.gen(function* () {
reset()
expect(yield* (yield* ToolRegistry.Service).definitions()).toMatchObject([{ name: "grep" }])
expect(yield* toolDefinitions(yield* ToolRegistry.Service)).toMatchObject([{ name: "grep" }])
}),
)
@ -155,7 +164,7 @@ describe("GrepTool", () => {
const input = { pattern: "needle", path: "src", include: "*.ts", limit: 2 }
expect(yield* execute(input)).toEqual({ type: "text", value: "No files found" })
expect(assertions).toEqual([
expect(assertions).toMatchObject([
{
sessionID,
action: "grep",
@ -226,7 +235,7 @@ describe("GrepTool", () => {
}),
)
it.effect("returns a useful tool error for an invalid regex", () =>
it.effect("preserves an unexpected search defect", () =>
Effect.gen(function* () {
reset()
searchFailure = new Ripgrep.InvalidPatternError({
@ -234,10 +243,7 @@ describe("GrepTool", () => {
message: "regex parse error: unclosed character class",
})
expect(yield* execute({ pattern: "[" })).toEqual({
type: "error",
value: 'Invalid grep pattern "[": regex parse error: unclosed character class',
})
expect(Exit.isFailure(yield* execute({ pattern: "[" }).pipe(Effect.exit))).toBe(true)
expect(searches).toEqual([{ pattern: "[" }])
}),
)

View file

@ -1,6 +1,6 @@
import { describe, expect } from "bun:test"
import path from "path"
import { Effect, Layer } from "effect"
import { Cause, Effect, Exit, Fiber, Layer, Option } from "effect"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { Global } from "@opencode-ai/core/global"
import { Config } from "@opencode-ai/core/config"
@ -43,35 +43,7 @@ const withStore = <A, E, R>(
const it = testEffect(Layer.empty)
describe("ToolOutputStore", () => {
it.live("returns under-limit text unchanged without writing a file", () =>
withStore(({ store }) =>
Effect.gen(function* () {
expect(yield* store.truncate({ sessionID, toolCallID: "call-short", content: "one\ntwo" })).toEqual({
content: "one\ntwo",
truncated: false,
})
}),
),
)
it.live("stores full output at an absolute managed path", () =>
withStore(({ root, store, fs }) =>
Effect.gen(function* () {
const content = "HEAD-" + "x".repeat(500) + "-TAIL"
const result = yield* store.truncate({ sessionID, toolCallID: "call-large", content, maxBytes: 300 })
expect(result.truncated).toBe(true)
if (!result.truncated) throw new Error("expected truncation")
expect(path.isAbsolute(result.outputPath)).toBe(true)
expect(result.outputPath).toStartWith(path.join(root, "tool-output", "tool_"))
expect(result.content).toContain(result.outputPath)
expect(result.content).toContain("HEAD-")
expect(result.content).toContain("-TAIL")
expect(yield* fs.readFileString(result.outputPath)).toBe(content)
}),
),
)
it.live("bounds aggregate text blocks with one managed file", () =>
it.live("bounds aggregate text and structured output with one managed file", () =>
withStore(({ store, fs }) =>
Effect.gen(function* () {
const first = "HEAD-" + "x".repeat(30_000)
@ -87,9 +59,15 @@ describe("ToolOutputStore", () => {
],
},
})
expect(result.output.structured).toEqual({ kind: "report" })
expect(result.output.structured).toEqual({})
expect(result.outputPaths).toHaveLength(1)
expect(yield* fs.readFileString(result.outputPaths[0]!)).toBe(`${first}\n\n${second}`)
expect(JSON.parse(yield* fs.readFileString(result.outputPaths[0]))).toEqual({
structured: { kind: "report" },
content: [
{ type: "text", text: first },
{ type: "text", text: second },
],
})
if (result.output.content[0]?.type !== "text") throw new Error("expected text preview")
expect(Buffer.byteLength(result.output.content[0].text)).toBeLessThanOrEqual(ToolOutputStore.MAX_BYTES)
}),
@ -101,37 +79,172 @@ describe("ToolOutputStore", () => {
Effect.gen(function* () {
const structured = { text: "x".repeat(ToolOutputStore.MAX_BYTES) }
const result = yield* store.bound({ sessionID, toolCallID: "call-json", output: { structured, content: [] } })
expect(result.output.structured).toBe(structured)
expect(result.output.structured).toEqual({})
expect(result.outputPaths).toHaveLength(1)
expect(yield* fs.readFileString(result.outputPaths[0]!)).toBe(JSON.stringify(structured))
expect(JSON.parse(yield* fs.readFileString(result.outputPaths[0]))).toEqual({ structured, content: [] })
expect(result.output.content).toHaveLength(1)
}),
),
)
it.live("degrades to lossy bounded output when writing fails", () =>
it.live("preserves oversized inline media without duplicating its data", () =>
withStore(({ store }) =>
Effect.gen(function* () {
const data = "a".repeat(ToolOutputStore.MAX_BYTES)
const result = yield* store.bound({
sessionID,
toolCallID: "call-file",
output: {
structured: { caption: "pixel" },
content: [{ type: "file", source: { type: "data", data }, mime: "image/png", name: "pixel.png" }],
},
})
expect(result.outputPaths).toEqual([])
expect(result.output.structured).toEqual({})
expect(result.output.content).toHaveLength(1)
expect(result.output.content[0]).toEqual({
type: "file",
source: { type: "data", data },
mime: "image/png",
name: "pixel.png",
})
}),
),
)
it.live("rejects inline media beyond the settlement media limit", () =>
withStore(({ store }) =>
Effect.gen(function* () {
const exit = yield* store
.bound({
sessionID,
toolCallID: "call-file-too-large",
output: {
structured: {},
content: [
{
type: "file",
source: { type: "data", data: "a".repeat(ToolOutputStore.MAX_INLINE_MEDIA_BYTES + 1) },
mime: "image/png",
},
],
},
})
.pipe(Effect.exit)
expect(Exit.isFailure(exit)).toBe(true)
if (Exit.isFailure(exit))
expect(Option.getOrUndefined(Cause.findErrorOption(exit.cause))?._tag).toBe("ToolOutputStore.MediaLimitError")
}),
),
)
it.live("rejects inline media whose aggregate size exceeds the settlement limit", () =>
withStore(({ store }) =>
Effect.gen(function* () {
const exit = yield* store
.bound({
sessionID,
toolCallID: "call-files-too-large",
output: {
structured: {},
content: [
{ type: "file", source: { type: "data", data: "a".repeat(3 * 1024 * 1024) }, mime: "image/png" },
{ type: "file", source: { type: "data", data: "b".repeat(3 * 1024 * 1024) }, mime: "image/png" },
],
},
})
.pipe(Effect.exit)
expect(Exit.isFailure(exit)).toBe(true)
if (Exit.isFailure(exit))
expect(Option.getOrUndefined(Cause.findErrorOption(exit.cause))?._tag).toBe("ToolOutputStore.MediaLimitError")
}),
),
)
it.live("fails oversized settlement when complete retention cannot be written", () =>
withStore(({ root, store, fs }) =>
Effect.gen(function* () {
yield* fs.writeFileString(path.join(root, "tool-output"), "not a directory")
const result = yield* store.bound({
sessionID,
toolCallID: "call-lossy",
output: { structured: {}, content: [{ type: "text", text: "x".repeat(ToolOutputStore.MAX_BYTES + 1) }] },
})
expect(result.outputPaths).toEqual([])
if (result.output.content[0]?.type !== "text") throw new Error("expected text preview")
expect(result.output.content[0].text).toContain("could not be retained")
const exit = yield* store
.bound({
sessionID,
toolCallID: "call-lossy",
output: { structured: {}, content: [{ type: "text", text: "x".repeat(ToolOutputStore.MAX_BYTES + 1) }] },
})
.pipe(Effect.exit)
expect(Exit.isFailure(exit)).toBe(true)
if (Exit.isFailure(exit))
expect(Option.getOrUndefined(Cause.findErrorOption(exit.cause))?._tag).toBe("ToolOutputStore.StorageError")
}),
),
)
it.live("fails operationally when output cannot be encoded for bounding", () =>
withStore(({ store }) =>
Effect.gen(function* () {
const exit = yield* store
.bound({
sessionID,
toolCallID: "call-unencodable",
output: {
structured: { value: 1n },
content: [{ type: "text", text: "readable text" }],
},
})
.pipe(Effect.exit)
expect(Exit.isFailure(exit)).toBe(true)
if (Exit.isFailure(exit))
expect(Option.getOrUndefined(Cause.findErrorOption(exit.cause))?._tag).toBe("ToolOutputStore.StorageError")
}),
),
)
it.live("preserves interruption while retaining complete output", () =>
Effect.gen(function* () {
const root = yield* Effect.promise(() => tmpdir())
const blockedFilesystem = Layer.effect(
FSUtil.Service,
Effect.gen(function* () {
const fs = yield* FSUtil.Service
return FSUtil.Service.of({
...fs,
ensureDir: () => Effect.void,
writeFileString: () => Effect.never,
})
}),
).pipe(Layer.provide(FSUtil.defaultLayer))
const store = ToolOutputStore.layer.pipe(
Layer.provide(blockedFilesystem),
Layer.provide(Global.layerWith({ data: root.path })),
)
const exit = yield* Effect.gen(function* () {
const service = yield* ToolOutputStore.Service
const fiber = yield* service
.bound({
sessionID,
toolCallID: "call-interrupted",
output: { structured: {}, content: [{ type: "text", text: "x".repeat(ToolOutputStore.MAX_BYTES + 1) }] },
})
.pipe(Effect.forkChild)
yield* Fiber.interrupt(fiber)
return yield* Fiber.await(fiber)
}).pipe(Effect.provide(store))
expect(Exit.isFailure(exit) && Cause.hasInterrupts(exit.cause)).toBe(true)
yield* Effect.promise(() => root[Symbol.asyncDispose]())
}),
)
it.live("honors configured limits", () =>
withStore(
({ store }) =>
Effect.gen(function* () {
expect(yield* store.limits()).toEqual({ maxLines: 2, maxBytes: 1_000 })
expect(
(yield* store.truncate({ sessionID, toolCallID: "call-config", content: "one\ntwo\nthree" })).truncated,
).toBe(true)
const result = yield* store.bound({
sessionID,
toolCallID: "call-config",
output: { structured: {}, content: [{ type: "text", text: "one\ntwo\nthree" }] },
})
expect(result.outputPaths).toHaveLength(1)
}),
new Config.Info({ tool_output: new ConfigToolOutput.Info({ max_lines: 2, max_bytes: 1_000 }) }),
),
@ -140,9 +253,12 @@ describe("ToolOutputStore", () => {
it.live("cleans expired managed files and preserves unrelated files", () =>
withStore(({ root, store, fs }) =>
Effect.gen(function* () {
const old = yield* store.write({ sessionID, toolCallID: "old", content: "old" })
const recent = yield* store.write({ sessionID, toolCallID: "recent", content: "recent" })
const old = path.join(root, "tool-output", "tool_old")
const recent = path.join(root, "tool-output", "tool_recent")
const unrelated = path.join(root, "tool-output", "keep.txt")
yield* fs.ensureDir(path.join(root, "tool-output"))
yield* fs.writeFileString(old, "old")
yield* fs.writeFileString(recent, "recent")
yield* fs.writeFileString(unrelated, "keep")
const expired = new Date(Date.now() - 8 * 24 * 60 * 60 * 1_000)
yield* fs.utimes(old, expired, expired)

View file

@ -6,6 +6,7 @@ import { SessionV2 } from "@opencode-ai/core/session"
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
import { QuestionTool } from "@opencode-ai/core/tool/question"
import { testEffect } from "./lib/effect"
import { toolIdentity, executeTool, settleTool, toolDefinitions } from "./lib/tool"
const sessionID = SessionV2.ID.make("ses_question_tool_test")
const assertions: PermissionV2.AssertInput[] = []
@ -40,7 +41,7 @@ const question = Layer.succeed(
list: () => Effect.die("unused"),
}),
)
const tool = QuestionTool.layer.pipe(Layer.provide(registry), Layer.provide(question))
const tool = QuestionTool.layer.pipe(Layer.provide(registry), Layer.provide(permission), Layer.provide(question))
const it = testEffect(Layer.mergeAll(permission, registry, question, tool))
describe("QuestionTool", () => {
@ -50,10 +51,11 @@ describe("QuestionTool", () => {
deny = true
const registry = yield* ToolRegistry.Service
expect(yield* registry.definitions([{ action: "question", resource: "*", effect: "deny" }])).toEqual([])
expect(yield* toolDefinitions(registry, [{ action: "question", resource: "*", effect: "deny" }])).toEqual([])
expect(
yield* registry.settle({
yield* settleTool(registry, {
sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-question-denied", name: "question", input: { questions: [] } },
}),
).toEqual({ result: { type: "error", value: "Permission denied: question" } })
@ -82,10 +84,11 @@ describe("QuestionTool", () => {
},
]
expect((yield* registry.definitions()).map((definition) => definition.name)).toEqual(["question"])
expect((yield* toolDefinitions(registry)).map((definition) => definition.name)).toEqual(["question"])
expect(
yield* registry.settle({
yield* settleTool(registry, {
sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-question", name: "question", input: { questions } },
}),
).toEqual({
@ -104,8 +107,12 @@ describe("QuestionTool", () => {
],
},
})
expect(assertions).toEqual([{ sessionID, action: "question", resources: ["*"] }])
expect(capturedInput()).toEqual({ sessionID, questions, tool: undefined })
expect(assertions).toMatchObject([{ sessionID, action: "question", resources: ["*"] }])
expect(capturedInput()).toEqual({
sessionID,
questions,
tool: { messageID: toolIdentity.assistantMessageID, callID: "call-question" },
})
}),
)
@ -116,11 +123,16 @@ describe("QuestionTool", () => {
deny = false
const registryService = yield* ToolRegistry.Service
yield* registryService.execute({
yield* executeTool(registryService, {
sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-question", name: "question", input: { questions: [] } },
})
expect(capturedInput()).toEqual({ sessionID, questions: [], tool: undefined })
expect(capturedInput()).toEqual({
sessionID,
questions: [],
tool: { messageID: toolIdentity.assistantMessageID, callID: "call-question" },
})
}),
)
@ -130,12 +142,11 @@ describe("QuestionTool", () => {
reject = true
deny = false
const registryService = yield* ToolRegistry.Service
const fiber = yield* registryService
.execute({
sessionID,
call: { type: "tool-call", id: "call-question", name: "question", input: { questions: [] } },
})
.pipe(Effect.forkScoped)
const fiber = yield* executeTool(registryService, {
sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-question", name: "question", input: { questions: [] } },
}).pipe(Effect.forkScoped)
const exit = yield* Fiber.await(fiber)
expect(Exit.isFailure(exit)).toBe(true)

View file

@ -1,5 +1,5 @@
import { beforeEach, describe, expect } from "bun:test"
import { Effect, Layer } from "effect"
import { Effect, Exit, Layer } from "effect"
import { Config } from "@opencode-ai/core/config"
import { ConfigAttachments } from "@opencode-ai/core/config/attachments"
import { FileSystem } from "@opencode-ai/core/filesystem"
@ -8,6 +8,7 @@ import { SessionV2 } from "@opencode-ai/core/session"
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
import { ReadTool } from "@opencode-ai/core/tool/read"
import { testEffect } from "./lib/effect"
import { toolIdentity, executeTool, settleTool, toolDefinitions } from "./lib/tool"
const assertions: PermissionV2.AssertInput[] = []
const readCalls: {
@ -100,11 +101,12 @@ describe("ReadTool", () => {
Effect.gen(function* () {
const registry = yield* ToolRegistry.Service
expect(yield* registry.definitions()).toMatchObject([{ name: "read" }])
expect(yield* registry.definitions([{ action: "read", resource: "*", effect: "deny" }])).toEqual([])
expect(yield* toolDefinitions(registry)).toMatchObject([{ name: "read" }])
expect(yield* toolDefinitions(registry, [{ action: "read", resource: "*", effect: "deny" }])).toEqual([])
expect(
yield* registry.execute({
yield* executeTool(registry, {
sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-read", name: "read", input: { path: "README.md" } },
}),
).toEqual({ type: "json", value: { type: "text", content: "hello", mime: "text/plain" } })
@ -125,8 +127,9 @@ describe("ReadTool", () => {
const registry = yield* ToolRegistry.Service
expect(
yield* registry.execute({
yield* executeTool(registry, {
sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-image", name: "read", input: { path: "pixel.png" } },
}),
).toEqual({
@ -138,12 +141,12 @@ describe("ReadTool", () => {
})
expect(readCalls).toEqual([{ input: { path: "pixel.png" }, page: {} }])
const settled = yield* registry.settle({
const settled = yield* settleTool(registry, {
sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-image-settle", name: "read", input: { path: "pixel.png" } },
})
expect(settled.output?.structured).toEqual({ type: "media", mime: "image/png" })
expect(JSON.stringify(settled.output?.structured)).not.toContain(png)
expect(settled.output?.structured).toEqual({})
expect(settled.output?.content).toMatchObject([
{ type: "text", text: "Image read successfully" },
{ type: "file", mime: "image/png", source: { type: "data", data: png } },
@ -151,6 +154,40 @@ describe("ReadTool", () => {
}),
)
it.effect("preserves a PNG above the generic text limit as native media", () =>
Effect.gen(function* () {
const photon = yield* Effect.promise(() => import("@silvia-odwyer/photon-node"))
const pixels = Uint8Array.from({ length: 256 * 256 * 4 }, (_, index) => (index * 73 + (index >> 3)) % 256)
const source = new photon.PhotonImage(pixels, 256, 256)
const png = Buffer.from(source.get_bytes()).toString("base64")
source.free()
expect(Buffer.byteLength(png)).toBeGreaterThan(50 * 1024)
readResult = new FileSystem.BinaryContent({
type: "binary",
content: png,
encoding: "base64",
mime: "image/png",
})
const registry = yield* ToolRegistry.Service
const settled = yield* settleTool(registry, {
sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-large-image", name: "read", input: { path: "large.png" } },
})
expect(settled.outputPaths).toBeUndefined()
expect(settled.output?.structured).toEqual({})
expect(settled.result).toEqual({
type: "content",
value: [
{ type: "text", text: "Image read successfully" },
{ type: "media", mediaType: "image/png", data: png, filename: "large.png" },
],
})
}),
)
it.effect("rejects invalid image data returned by the filesystem", () =>
Effect.gen(function* () {
readResult = new FileSystem.BinaryContent({
@ -162,8 +199,9 @@ describe("ReadTool", () => {
const registry = yield* ToolRegistry.Service
expect(
yield* registry.execute({
yield* executeTool(registry, {
sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-truncated-image", name: "read", input: { path: "truncated.png" } },
}),
).toEqual({ type: "error", value: "Image could not be decoded: truncated.png" })
@ -193,8 +231,9 @@ describe("ReadTool", () => {
}),
]
const registry = yield* ToolRegistry.Service
const result = yield* registry.execute({
const result = yield* executeTool(registry, {
sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-wide-image", name: "read", input: { path: "wide.png" } },
})
@ -224,8 +263,9 @@ describe("ReadTool", () => {
}),
]
const registry = yield* ToolRegistry.Service
const result = yield* registry.execute({
const result = yield* executeTool(registry, {
sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-resize-image", name: "read", input: { path: "wide.png" } },
})
@ -261,8 +301,9 @@ describe("ReadTool", () => {
}),
]
const registry = yield* ToolRegistry.Service
const result = yield* registry.execute({
const result = yield* executeTool(registry, {
sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-max-bytes", name: "read", input: { path: "pixel.png" } },
})
@ -283,8 +324,9 @@ describe("ReadTool", () => {
const registry = yield* ToolRegistry.Service
expect(
yield* registry.execute({
yield* executeTool(registry, {
sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-disguised-image", name: "read", input: { path: "pixel.bin" } },
}),
).toMatchObject({
@ -294,22 +336,25 @@ describe("ReadTool", () => {
}),
)
it.effect("preserves unsupported binary errors from the filesystem", () =>
it.effect("preserves unexpected filesystem defects", () =>
Effect.gen(function* () {
readFailure = new FileSystem.BinaryFileError("archive.dat")
const registry = yield* ToolRegistry.Service
expect(
yield* registry.execute({
sessionID,
call: {
type: "tool-call",
id: "call-binary",
name: "read",
input: { path: "archive.dat", offset: 2, limit: 1 },
},
}),
).toEqual({ type: "error", value: "Cannot read binary file: archive.dat" })
Exit.isFailure(
yield* executeTool(registry, {
sessionID,
...toolIdentity,
call: {
type: "tool-call",
id: "call-binary",
name: "read",
input: { path: "archive.dat", offset: 2, limit: 1 },
},
}).pipe(Effect.exit),
),
).toBe(true)
expect(readCalls).toEqual([
{ input: { path: "archive.dat", offset: 2, limit: 1 }, page: { offset: 2, limit: 1 } },
])
@ -322,8 +367,9 @@ describe("ReadTool", () => {
const registry = yield* ToolRegistry.Service
expect(
yield* registry.execute({
yield* executeTool(registry, {
sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-read", name: "read", input: { path: "README.md" } },
}),
).toEqual({ type: "error", value: "Unable to read README.md" })
@ -337,8 +383,9 @@ describe("ReadTool", () => {
const registry = yield* ToolRegistry.Service
expect(
yield* registry.execute({
yield* executeTool(registry, {
sessionID,
...toolIdentity,
call: {
type: "tool-call",
id: "call-read-directory",
@ -359,8 +406,9 @@ describe("ReadTool", () => {
const registry = yield* ToolRegistry.Service
expect(
yield* registry.execute({
yield* executeTool(registry, {
sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-read-directory-denied", name: "read", input: { path: "src" } },
}),
).toEqual({ type: "error", value: "Unable to read src" })
@ -372,8 +420,9 @@ describe("ReadTool", () => {
Effect.gen(function* () {
const registry = yield* ToolRegistry.Service
yield* registry.execute({
yield* executeTool(registry, {
sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-read", name: "read", input: { path: "README.md", reference: "docs" } },
})
@ -381,17 +430,20 @@ describe("ReadTool", () => {
}),
)
it.effect("settles missing files as typed tool errors", () =>
it.effect("preserves unexpected resolution defects", () =>
Effect.gen(function* () {
const registry = yield* ToolRegistry.Service
resolveFailure = new Error("missing")
expect(
yield* registry.execute({
sessionID,
call: { type: "tool-call", id: "call-missing", name: "read", input: { path: "missing.txt" } },
}),
).toEqual({ type: "error", value: "Unable to read missing.txt" })
Exit.isFailure(
yield* executeTool(registry, {
sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-missing", name: "read", input: { path: "missing.txt" } },
}).pipe(Effect.exit),
),
).toBe(true)
expect(readCalls).toEqual([])
}),
@ -410,8 +462,9 @@ describe("ReadTool", () => {
const registry = yield* ToolRegistry.Service
expect(
yield* registry.execute({
yield* executeTool(registry, {
sessionID,
...toolIdentity,
call: {
type: "tool-call",
id: "call-large",
@ -438,8 +491,9 @@ describe("ReadTool", () => {
const registry = yield* ToolRegistry.Service
expect(
yield* registry.execute({
yield* executeTool(registry, {
sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-direct-binary", name: "read", input: { path: "late-binary" } },
}),
).toEqual({ type: "error", value: "Cannot read binary file: late-binary" })

View file

@ -9,10 +9,10 @@ import { AbsolutePath } from "@opencode-ai/core/schema"
import { SessionV2 } from "@opencode-ai/core/session"
import { SkillV2 } from "@opencode-ai/core/skill"
import { SkillTool } from "@opencode-ai/core/tool/skill"
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
import { tmpdir } from "./fixture/tmpdir"
import { it } from "./lib/effect"
import { toolIdentity, executeTool, settleTool, toolDefinitions } from "./lib/tool"
const sessionID = SessionV2.ID.make("ses_skill_tool_test")
@ -41,9 +41,6 @@ describe("SkillTool", () => {
let current = [info]
const assertions: PermissionV2.AssertInput[] = []
let deny = false
const truncations: ToolOutputStore.TruncateInput[] = []
let truncate = (input: ToolOutputStore.TruncateInput): Effect.Effect<ToolOutputStore.TruncateResult> =>
Effect.succeed({ content: input.content, truncated: false })
let bootWaited = false
const boot = Layer.succeed(
PluginBoot.Service,
@ -77,75 +74,58 @@ describe("SkillTool", () => {
}),
)
const registry = ToolRegistry.defaultLayer.pipe(Layer.provide(permission))
const resources = Layer.succeed(
ToolOutputStore.Service,
ToolOutputStore.Service.of({
limits: () => Effect.die("unused"),
write: () => Effect.die("unused"),
truncate: (input) => Effect.sync(() => truncations.push(input)).pipe(Effect.andThen(truncate(input))),
bound: (input) => Effect.succeed({ output: input.output, outputPaths: [] }),
cleanup: () => Effect.die("unused"),
}),
)
const tool = SkillTool.layer.pipe(
Layer.provide(registry),
Layer.provide(permission),
Layer.provide(FSUtil.defaultLayer),
Layer.provide(boot),
Layer.provide(skills),
Layer.provide(resources),
)
const layer = Layer.mergeAll(permission, skills, registry, boot, resources, tool)
const layer = Layer.mergeAll(permission, skills, registry, boot, tool)
return yield* Effect.gen(function* () {
const registry = yield* ToolRegistry.Service
expect(bootWaited).toBe(true)
expect((yield* registry.definitions())[0]).toMatchObject({
expect((yield* toolDefinitions(registry))[0]).toMatchObject({
name: "skill",
description: SkillTool.description,
})
expect(
yield* registry.execute({
yield* executeTool(registry, {
sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-skill", name: "skill", input: { name: "effect" } },
}),
).toEqual({
type: "text",
value: SkillTool.toModelOutput(info, [reference]),
})
expect(truncations).toEqual([
{ sessionID, toolCallID: "call-skill", content: SkillTool.toModelOutput(info, [reference]) },
])
truncate = (input) =>
Effect.succeed({
content: "HEAD\n\n... output truncated; full content saved to /tmp/tool-output/tool_opaque ...\n\nTAIL",
truncated: true,
outputPath: "/tmp/tool-output/tool_opaque",
})
expect(
yield* registry.settle({
yield* settleTool(registry, {
sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-skill-overflow", name: "skill", input: { name: "effect" } },
}),
).toMatchObject({
result: { type: "text", value: expect.stringContaining("/tmp/tool-output/tool_opaque") },
output: {
structured: { truncated: true, outputPath: "/tmp/tool-output/tool_opaque" },
},
result: { type: "text", value: SkillTool.toModelOutput(info, [reference]) },
output: { structured: { name: "effect" } },
})
expect(assertions).toEqual([
expect(assertions).toMatchObject([
{ sessionID, action: "skill", resources: ["effect"], save: ["effect"] },
{ sessionID, action: "skill", resources: ["effect"], save: ["effect"] },
])
expect(
yield* registry.execute({
yield* executeTool(registry, {
sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-missing-skill", name: "skill", input: { name: "missing" } },
}),
).toEqual({ type: "error", value: "Unable to load skill missing" })
deny = true
expect(
yield* registry.execute({
yield* executeTool(registry, {
sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-denied-skill", name: "skill", input: { name: "effect" } },
}),
).toEqual({ type: "error", value: "Unable to load skill effect" })
@ -163,10 +143,10 @@ describe("SkillTool", () => {
]),
)
current = [flat]
truncate = (input) => Effect.succeed({ content: input.content, truncated: false })
expect(
yield* registry.execute({
yield* executeTool(registry, {
sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-flat-skill", name: "skill", input: { name: "public" } },
}),
).toEqual({ type: "text", value: SkillTool.toModelOutput(flat, []) })

View file

@ -12,6 +12,7 @@ import { SessionTodo } from "@opencode-ai/core/session/todo"
import { TodoWriteTool } from "@opencode-ai/core/tool/todowrite"
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
import { testEffect } from "./lib/effect"
import { toolIdentity, executeTool, settleTool, toolDefinitions } from "./lib/tool"
const sessionID = SessionV2.ID.make("ses_todowrite_tool_test")
const assertions: PermissionV2.AssertInput[] = []
@ -35,7 +36,7 @@ const database = Database.layerFromPath(":memory:")
const events = EventV2.layer.pipe(Layer.provide(database))
const todos = SessionTodo.layer.pipe(Layer.provide(database), Layer.provide(events))
const registry = ToolRegistry.defaultLayer.pipe(Layer.provide(permission))
const tool = TodoWriteTool.layer.pipe(Layer.provide(registry), Layer.provide(todos))
const tool = TodoWriteTool.layer.pipe(Layer.provide(registry), Layer.provide(permission), Layer.provide(todos))
const it = testEffect(Layer.mergeAll(database, events, todos, permission, registry, tool))
const setup = Effect.gen(function* () {
@ -63,6 +64,7 @@ const setup = Effect.gen(function* () {
const call = (todos: ReadonlyArray<SessionTodo.Info>, id = "call-todowrite") => ({
sessionID,
...toolIdentity,
call: { type: "tool-call" as const, id, name: TodoWriteTool.name, input: { todos } },
})
@ -74,15 +76,15 @@ describe("TodoWriteTool", () => {
const service = yield* SessionTodo.Service
const todoList = [{ content: "Implement slice", status: "in_progress", priority: "high" }]
expect((yield* registry.definitions()).map((tool) => tool.name)).toEqual([TodoWriteTool.name])
expect(yield* registry.settle(call(todoList))).toEqual({
expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual([TodoWriteTool.name])
expect(yield* settleTool(registry, call(todoList))).toEqual({
result: { type: "text", value: JSON.stringify(todoList, null, 2) },
output: {
structured: { todos: todoList },
content: [{ type: "text", text: JSON.stringify(todoList, null, 2) }],
},
})
expect(assertions).toEqual([{ sessionID, action: "todowrite", resources: ["*"], save: ["*"] }])
expect(assertions).toMatchObject([{ sessionID, action: "todowrite", resources: ["*"], save: ["*"] }])
expect(yield* service.get(sessionID)).toEqual(todoList)
}),
)
@ -95,12 +97,14 @@ describe("TodoWriteTool", () => {
yield* service.update({ sessionID, todos: [{ content: "keep", status: "pending", priority: "low" }] })
deny = true
expect(yield* registry.execute(call([{ content: "blocked", status: "completed", priority: "high" }]))).toEqual({
expect(
yield* executeTool(registry, call([{ content: "blocked", status: "completed", priority: "high" }])),
).toEqual({
type: "error",
value: "Unable to update todos",
})
expect(yield* service.get(sessionID)).toEqual([{ content: "keep", status: "pending", priority: "low" }])
expect(assertions).toEqual([{ sessionID, action: "todowrite", resources: ["*"], save: ["*"] }])
expect(assertions).toMatchObject([{ sessionID, action: "todowrite", resources: ["*"], save: ["*"] }])
}),
)
})

View file

@ -4,19 +4,16 @@ import * as TestClock from "effect/testing/TestClock"
import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { PermissionV2 } from "@opencode-ai/core/permission"
import { SessionV2 } from "@opencode-ai/core/session"
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
import { WebFetchTool } from "@opencode-ai/core/tool/webfetch"
import { testEffect } from "./lib/effect"
import { toolIdentity, executeTool, settleTool, toolDefinitions } from "./lib/tool"
const sessionID = SessionV2.ID.make("ses_webfetch_test")
const requests: Array<{ readonly url: string; readonly headers: Record<string, string> }> = []
const assertions: PermissionV2.AssertInput[] = []
const truncations: ToolOutputStore.TruncateInput[] = []
let respond = (_request: HttpClientRequest.HttpClientRequest) =>
Effect.succeed(new Response("hello", { headers: { "content-type": "text/plain" } }))
let truncate = (input: ToolOutputStore.TruncateInput): Effect.Effect<ToolOutputStore.TruncateResult> =>
Effect.succeed({ content: input.content, truncated: false })
const http = Layer.succeed(
HttpClient.HttpClient,
@ -38,36 +35,25 @@ const permission = Layer.succeed(
list: () => Effect.die("unused"),
}),
)
const resources = Layer.succeed(
ToolOutputStore.Service,
ToolOutputStore.Service.of({
limits: () => Effect.die("unused"),
write: () => Effect.die("unused"),
truncate: (input) => Effect.sync(() => truncations.push(input)).pipe(Effect.andThen(truncate(input))),
bound: (input) => Effect.succeed({ output: input.output, outputPaths: [] }),
cleanup: () => Effect.die("unused"),
}),
)
const registry = ToolRegistry.defaultLayer.pipe(Layer.provide(permission))
const webfetch = WebFetchTool.layer.pipe(Layer.provide(registry), Layer.provide(http), Layer.provide(resources))
const it = testEffect(Layer.mergeAll(registry, permission, http, resources, webfetch))
const webfetch = WebFetchTool.layer.pipe(Layer.provide(registry), Layer.provide(permission), Layer.provide(http))
const it = testEffect(Layer.mergeAll(registry, permission, http, webfetch))
const fetchWebfetch = WebFetchTool.layer.pipe(
Layer.provide(registry),
Layer.provide(permission),
Layer.provide(FetchHttpClient.layer),
Layer.provide(resources),
)
const live = testEffect(Layer.mergeAll(registry, permission, FetchHttpClient.layer, resources, fetchWebfetch))
const live = testEffect(Layer.mergeAll(registry, permission, FetchHttpClient.layer, fetchWebfetch))
const reset = () => {
requests.length = 0
assertions.length = 0
truncations.length = 0
respond = () => Effect.succeed(new Response("hello", { headers: { "content-type": "text/plain" } }))
truncate = (input) => Effect.succeed({ content: input.content, truncated: false })
}
const call = (input: typeof WebFetchTool.Parameters.Type, id = "call-webfetch") => ({
sessionID,
...toolIdentity,
call: { type: "tool-call" as const, id, name: "webfetch", input },
})
@ -93,15 +79,15 @@ describe("WebFetchTool contribution", () => {
const registry = yield* ToolRegistry.Service
const url = "http://example.com/public"
expect((yield* registry.definitions()).map((tool) => tool.name)).toEqual(["webfetch"])
expect(yield* registry.settle(call({ url, format: "text", timeout: 4 }))).toEqual({
expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["webfetch"])
expect(yield* settleTool(registry, call({ url, format: "text", timeout: 4 }))).toEqual({
result: { type: "text", value: "hello" },
output: {
structured: { url, contentType: "text/plain", format: "text", output: "hello", truncated: false },
structured: { url, contentType: "text/plain", format: "text", output: "hello" },
content: [{ type: "text", text: "hello" }],
},
})
expect(assertions).toEqual([
expect(assertions).toMatchObject([
{ sessionID, action: "webfetch", resources: [url], save: ["*"], metadata: { url, format: "text", timeout: 4 } },
])
expect(requests).toMatchObject([{ url, headers: { accept: expect.stringContaining("text/plain;q=1.0") } }])
@ -114,11 +100,11 @@ describe("WebFetchTool contribution", () => {
const registry = yield* ToolRegistry.Service
const url = "http://localhost/private"
expect(yield* registry.execute(call({ url, format: "text" }))).toEqual({
expect(yield* executeTool(registry, call({ url, format: "text" }))).toEqual({
type: "text",
value: "hello",
})
expect(assertions).toEqual([
expect(assertions).toMatchObject([
{ sessionID, action: "webfetch", resources: [url], save: ["*"], metadata: { url, format: "text" } },
])
expect(requests.map((request) => request.url)).toEqual([url])
@ -142,8 +128,11 @@ describe("WebFetchTool contribution", () => {
const registry = yield* ToolRegistry.Service
const url = new URL("/redirect", server.url).toString()
expect(yield* registry.execute(call({ url, format: "text" }))).toEqual({ type: "text", value: "redirected" })
expect(assertions).toEqual([
expect(yield* executeTool(registry, call({ url, format: "text" }))).toEqual({
type: "text",
value: "redirected",
})
expect(assertions).toMatchObject([
{ sessionID, action: "webfetch", resources: [url], save: ["*"], metadata: { url, format: "text" } },
])
}),
@ -156,7 +145,7 @@ describe("WebFetchTool contribution", () => {
reset()
const registry = yield* ToolRegistry.Service
expect(yield* registry.execute(call({ url: "file:///etc/passwd", format: "text" }))).toEqual({
expect(yield* executeTool(registry, call({ url: "file:///etc/passwd", format: "text" }))).toEqual({
type: "error",
value: "Unable to fetch file:///etc/passwd",
})
@ -176,41 +165,17 @@ describe("WebFetchTool contribution", () => {
)
const registry = yield* ToolRegistry.Service
expect(yield* registry.execute(call({ url: "https://1.1.1.1", format: "markdown" }))).toEqual({
expect(yield* executeTool(registry, call({ url: "https://1.1.1.1", format: "markdown" }))).toEqual({
type: "text",
value: "# Hello\n\nworld",
})
expect(yield* registry.execute(call({ url: "https://1.1.1.1", format: "text" }))).toEqual({
expect(yield* executeTool(registry, call({ url: "https://1.1.1.1", format: "text" }))).toEqual({
type: "text",
value: "Helloworld",
})
}),
)
it.effect("exposes managed overflow through a path", () =>
Effect.gen(function* () {
reset()
truncate = (input) =>
Effect.succeed({
content: "HEAD\n\n... output truncated; full content saved to /tmp/tool-output/tool_opaque ...\n\nTAIL",
truncated: true,
outputPath: "/tmp/tool-output/tool_opaque",
})
const registry = yield* ToolRegistry.Service
const settled = yield* registry.settle(call({ url: "https://1.1.1.1", format: "html" }, "call-overflow"))
expect(settled.result).toMatchObject({
type: "text",
value: expect.stringContaining("/tmp/tool-output/tool_opaque"),
})
expect(settled.output?.structured).toMatchObject({
truncated: true,
outputPath: "/tmp/tool-output/tool_opaque",
})
expect(truncations).toEqual([{ sessionID, toolCallID: "call-overflow", content: "hello", mime: "text/html" }])
}),
)
it.effect("rejects declared and streamed oversized bodies", () =>
Effect.gen(function* () {
reset()
@ -221,7 +186,7 @@ describe("WebFetchTool contribution", () => {
headers: { "content-type": "text/plain", "content-length": String(WebFetchTool.MAX_RESPONSE_BYTES + 1) },
}),
)
expect(yield* registry.execute(call({ url: "https://1.1.1.1/declared", format: "text" }))).toEqual({
expect(yield* executeTool(registry, call({ url: "https://1.1.1.1/declared", format: "text" }))).toEqual({
type: "error",
value: "Unable to fetch https://1.1.1.1/declared",
})
@ -230,7 +195,7 @@ describe("WebFetchTool contribution", () => {
Effect.succeed(
new Response("x".repeat(WebFetchTool.MAX_RESPONSE_BYTES + 1), { headers: { "content-type": "text/plain" } }),
)
expect(yield* registry.execute(call({ url: "https://1.1.1.1/streamed", format: "text" }))).toEqual({
expect(yield* executeTool(registry, call({ url: "https://1.1.1.1/streamed", format: "text" }))).toEqual({
type: "error",
value: "Unable to fetch https://1.1.1.1/streamed",
})
@ -242,17 +207,16 @@ describe("WebFetchTool contribution", () => {
reset()
const registry = yield* ToolRegistry.Service
respond = () => Effect.succeed(new Response("png", { headers: { "content-type": "image/png" } }))
expect(yield* registry.execute(call({ url: "https://1.1.1.1/image", format: "html" }))).toEqual({
expect(yield* executeTool(registry, call({ url: "https://1.1.1.1/image", format: "html" }))).toEqual({
type: "error",
value: "Unable to fetch https://1.1.1.1/image",
})
respond = () => Effect.succeed(new Response("pdf", { headers: { "content-type": "application/pdf" } }))
expect(yield* registry.execute(call({ url: "https://1.1.1.1/file", format: "html" }))).toEqual({
expect(yield* executeTool(registry, call({ url: "https://1.1.1.1/file", format: "html" }))).toEqual({
type: "error",
value: "Unable to fetch https://1.1.1.1/file",
})
expect(truncations).toEqual([])
}),
)
@ -268,7 +232,7 @@ describe("WebFetchTool contribution", () => {
)
const registry = yield* ToolRegistry.Service
expect(yield* registry.execute(call({ url: "https://1.1.1.1", format: "text" }))).toEqual({
expect(yield* executeTool(registry, call({ url: "https://1.1.1.1", format: "text" }))).toEqual({
type: "text",
value: "ok",
})
@ -283,9 +247,10 @@ describe("WebFetchTool contribution", () => {
reset()
respond = () => Effect.never
const registry = yield* ToolRegistry.Service
const fiber = yield* registry
.execute(call({ url: "https://1.1.1.1/slow", format: "text", timeout: 1 }))
.pipe(Effect.forkChild)
const fiber = yield* executeTool(
registry,
call({ url: "https://1.1.1.1/slow", format: "text", timeout: 1 }),
).pipe(Effect.forkChild)
yield* TestClock.adjust(Duration.seconds(1))
expect(yield* Fiber.join(fiber)).toEqual({ type: "error", value: "Unable to fetch https://1.1.1.1/slow" })

View file

@ -5,8 +5,8 @@ import { PermissionV2 } from "@opencode-ai/core/permission"
import { SessionV2 } from "@opencode-ai/core/session"
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
import { WebSearchTool } from "@opencode-ai/core/tool/websearch"
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
import { testEffect } from "./lib/effect"
import { toolIdentity, executeTool, settleTool, toolDefinitions } from "./lib/tool"
const sessionID = SessionV2.ID.make("ses_websearch_test")
const payload = (text: string) =>
@ -65,11 +65,8 @@ interface Request {
const requests: Request[] = []
const assertions: PermissionV2.AssertInput[] = []
const truncations: ToolOutputStore.TruncateInput[] = []
let responseBody = payload("search results")
let config: WebSearchTool.Config = { enableExa: false, enableParallel: false }
let truncate = (input: ToolOutputStore.TruncateInput): Effect.Effect<ToolOutputStore.TruncateResult> =>
Effect.succeed({ content: input.content, truncated: false })
const http = Layer.succeed(
HttpClient.HttpClient,
@ -117,40 +114,28 @@ const websearchConfig = Layer.succeed(
},
}),
)
const resources = Layer.succeed(
ToolOutputStore.Service,
ToolOutputStore.Service.of({
limits: () => Effect.die("unused"),
write: () => Effect.die("unused"),
truncate: (input) => Effect.sync(() => truncations.push(input)).pipe(Effect.andThen(truncate(input))),
bound: (input) => Effect.succeed({ output: input.output, outputPaths: [] }),
cleanup: () => Effect.die("unused"),
}),
)
const websearch = WebSearchTool.layer.pipe(
Layer.provide(registry),
Layer.provide(permission),
Layer.provide(http),
Layer.provide(websearchConfig),
Layer.provide(resources),
)
const it = testEffect(Layer.mergeAll(registry, permission, http, websearchConfig, resources, websearch))
const it = testEffect(Layer.mergeAll(registry, permission, http, websearchConfig, websearch))
describe("WebSearchTool contribution", () => {
it.effect("registers websearch, asserts query permission, and calls Exa", () =>
Effect.gen(function* () {
requests.length = 0
assertions.length = 0
truncations.length = 0
truncate = (input) => Effect.succeed({ content: input.content, truncated: false })
responseBody = payload("exa results")
config = { provider: "exa", enableExa: false, enableParallel: false }
const registry = yield* ToolRegistry.Service
expect((yield* registry.definitions()).map((tool) => tool.name)).toEqual(["websearch"])
expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["websearch"])
expect(
yield* registry.execute({
yield* executeTool(registry, {
sessionID,
...toolIdentity,
call: {
type: "tool-call",
id: "call-exa",
@ -165,7 +150,7 @@ describe("WebSearchTool contribution", () => {
},
}),
).toEqual({ type: "text", value: "exa results" })
expect(assertions).toEqual([
expect(assertions).toMatchObject([
{
sessionID,
action: "websearch",
@ -213,8 +198,9 @@ describe("WebSearchTool contribution", () => {
config = { provider: "parallel", enableExa: false, enableParallel: false, parallelApiKey: "parallel-secret" }
const registry = yield* ToolRegistry.Service
const settled = yield* registry.settle({
const settled = yield* settleTool(registry, {
sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-parallel", name: "websearch", input: { query: "effect layers" } },
})
@ -235,7 +221,7 @@ describe("WebSearchTool contribution", () => {
expect(settled).toEqual({
result: { type: "text", value: "parallel results" },
output: {
structured: { provider: "parallel", text: "parallel results", truncated: false },
structured: { provider: "parallel", text: "parallel results" },
content: [{ type: "text", text: "parallel results" }],
},
})
@ -251,8 +237,9 @@ describe("WebSearchTool contribution", () => {
config = { provider: "exa", enableExa: false, enableParallel: false, exaApiKey: "exa secret" }
const registry = yield* ToolRegistry.Service
const settled = yield* registry.settle({
const settled = yield* settleTool(registry, {
sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-exa-key", name: "websearch", input: { query: "effect schema" } },
})
@ -270,47 +257,15 @@ describe("WebSearchTool contribution", () => {
const registry = yield* ToolRegistry.Service
expect(
yield* registry.execute({
yield* executeTool(registry, {
sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-empty", name: "websearch", input: { query: "nothing" } },
}),
).toEqual({ type: "text", value: WebSearchTool.NO_RESULTS })
}),
)
it.effect("exposes managed overflow through typed structured output", () =>
Effect.gen(function* () {
requests.length = 0
assertions.length = 0
truncations.length = 0
responseBody = payload("full search results")
config = { provider: "exa", enableExa: false, enableParallel: false }
truncate = (input) =>
Effect.succeed({
content: "HEAD\n\n... output truncated; full content saved to /tmp/tool-output/tool_opaque ...\n\nTAIL",
truncated: true,
outputPath: "/tmp/tool-output/tool_opaque",
})
const registry = yield* ToolRegistry.Service
const settled = yield* registry.settle({
sessionID,
call: { type: "tool-call", id: "call-overflow", name: "websearch", input: { query: "verbose" } },
})
expect(settled.result).toMatchObject({
type: "text",
value: expect.stringContaining("/tmp/tool-output/tool_opaque"),
})
expect(settled.output?.structured).toMatchObject({
provider: "exa",
truncated: true,
outputPath: "/tmp/tool-output/tool_opaque",
})
expect(truncations).toEqual([{ sessionID, toolCallID: "call-overflow", content: "full search results" }])
}),
)
it.effect("rejects oversized MCP response bodies", () =>
Effect.gen(function* () {
requests.length = 0
@ -320,8 +275,9 @@ describe("WebSearchTool contribution", () => {
const registry = yield* ToolRegistry.Service
expect(
yield* registry.execute({
yield* executeTool(registry, {
sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-large-response", name: "websearch", input: { query: "too much" } },
}),
).toEqual({ type: "error", value: "Unable to search the web for too much" })

View file

@ -15,6 +15,7 @@ import { WriteTool } from "@opencode-ai/core/tool/write"
import { location } from "./fixture/location"
import { tmpdir } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect"
import { toolIdentity, executeTool, settleTool, toolDefinitions } from "./lib/tool"
const sessionID = SessionV2.ID.make("ses_write_tool_test")
const assertions: PermissionV2.AssertInput[] = []
@ -64,7 +65,12 @@ const withTool = <A, E, R>(directory: string, body: (registry: ToolRegistry.Inte
const resolution = LocationMutation.layer.pipe(Layer.provide(filesystem), Layer.provide(activeLocation))
const mutation = FileMutation.layer.pipe(Layer.provide(filesystem))
const registry = ToolRegistry.defaultLayer.pipe(Layer.provide(permission))
const write = WriteTool.layer.pipe(Layer.provide(registry), Layer.provide(resolution), Layer.provide(mutation))
const write = WriteTool.layer.pipe(
Layer.provide(registry),
Layer.provide(permission),
Layer.provide(resolution),
Layer.provide(mutation),
)
return Effect.gen(function* () {
return yield* body(yield* ToolRegistry.Service)
}).pipe(Effect.provide(Layer.mergeAll(registry, resolution, mutation, write)))
@ -72,6 +78,7 @@ const withTool = <A, E, R>(directory: string, body: (registry: ToolRegistry.Inte
const call = (input: typeof WriteTool.Parameters.Type, id = "call-write") => ({
sessionID,
...toolIdentity,
call: { type: "tool-call" as const, id, name: "write", input },
})
@ -85,8 +92,8 @@ describe("WriteTool", () => {
reset()
return withTool(tmp.path, (registry) =>
Effect.gen(function* () {
expect((yield* registry.definitions()).map((tool) => tool.name)).toEqual(["write"])
const settled = yield* registry.settle(call({ path: "src/new.txt", content: "created" }))
expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["write"])
const settled = yield* settleTool(registry, call({ path: "src/new.txt", content: "created" }))
expect(settled).toEqual({
result: { type: "text", value: "Created file successfully: src/new.txt" },
output: {
@ -102,7 +109,7 @@ describe("WriteTool", () => {
expect(yield* Effect.promise(() => fs.readFile(path.join(tmp.path, "src", "new.txt"), "utf8"))).toBe(
"created",
)
expect(assertions).toEqual([{ sessionID, action: "edit", resources: ["src/new.txt"], save: ["*"] }])
expect(assertions).toMatchObject([{ sessionID, action: "edit", resources: ["src/new.txt"], save: ["*"] }])
expect(writes).toEqual([path.join(yield* Effect.promise(() => fs.realpath(tmp.path)), "src", "new.txt")])
}),
)
@ -118,7 +125,7 @@ describe("WriteTool", () => {
reset()
return Effect.promise(() => fs.writeFile(path.join(tmp.path, "existing.txt"), "before")).pipe(
Effect.andThen(
withTool(tmp.path, (registry) => registry.settle(call({ path: "existing.txt", content: "after" }))),
withTool(tmp.path, (registry) => settleTool(registry, call({ path: "existing.txt", content: "after" }))),
),
Effect.andThen((settled) =>
Effect.gen(function* () {
@ -149,8 +156,11 @@ describe("WriteTool", () => {
Effect.andThen(
withTool(tmp.path, (registry) =>
Effect.gen(function* () {
yield* registry.settle(call({ path: "preserved.txt", content: "after" }, "call-preserved"))
yield* registry.settle(call({ path: "deduplicated.txt", content: "\uFEFFafter" }, "call-deduplicated"))
yield* settleTool(registry, call({ path: "preserved.txt", content: "after" }, "call-preserved"))
yield* settleTool(
registry,
call({ path: "deduplicated.txt", content: "\uFEFFafter" }, "call-deduplicated"),
)
expect(yield* Effect.promise(() => fs.readFile(preserved, "utf8"))).toBe("\uFEFFafter")
expect(yield* Effect.promise(() => fs.readFile(deduplicated, "utf8"))).toBe("\uFEFFafter")
@ -169,7 +179,7 @@ describe("WriteTool", () => {
(tmp) => {
reset()
const target = path.join(tmp.path, "absolute.txt")
return withTool(tmp.path, (registry) => registry.execute(call({ path: target, content: "inside" }))).pipe(
return withTool(tmp.path, (registry) => executeTool(registry, call({ path: target, content: "inside" }))).pipe(
Effect.andThen((result) =>
Effect.gen(function* () {
expect(result).toEqual({ type: "text", value: "Created file successfully: absolute.txt" })
@ -189,7 +199,9 @@ describe("WriteTool", () => {
([active, outside]) => {
reset()
const target = path.join(outside.path, "external.txt")
return withTool(active.path, (registry) => registry.settle(call({ path: target, content: "external" }))).pipe(
return withTool(active.path, (registry) =>
settleTool(registry, call({ path: target, content: "external" })),
).pipe(
Effect.andThen((settled) =>
Effect.gen(function* () {
const canonicalTarget = path.join(yield* Effect.promise(() => fs.realpath(outside.path)), "external.txt")
@ -227,7 +239,9 @@ describe("WriteTool", () => {
reset()
denyAction = "external_directory"
expect(
yield* withTool(active.path, (registry) => registry.execute(call({ path: external, content: "blocked" }))),
yield* withTool(active.path, (registry) =>
executeTool(registry, call({ path: external, content: "blocked" })),
),
).toEqual({
type: "error",
value: `Unable to write ${external}`,
@ -239,7 +253,7 @@ describe("WriteTool", () => {
denyAction = "edit"
expect(
yield* withTool(active.path, (registry) =>
registry.execute(call({ path: "denied.txt", content: "blocked" })),
executeTool(registry, call({ path: "denied.txt", content: "blocked" })),
),
).toEqual({
type: "error",
@ -259,7 +273,7 @@ describe("WriteTool", () => {
test("keeps the locked write schema, semantics docstring, and deferred UX TODOs visible", async () => {
const source = (await fs.readFile(new URL("../src/tool/write.ts", import.meta.url), "utf8")).replaceAll("\r\n", "\n")
const definition = await Effect.runPromise(
withTool(path.dirname(fileURLToPath(import.meta.url)), (registry) => registry.definitions()),
withTool(path.dirname(fileURLToPath(import.meta.url)), (registry) => toolDefinitions(registry)),
)
const schema = definition[0]?.inputSchema as { readonly properties?: Record<string, unknown> }