test(core): migrate tool tests to canonical execution model

This commit is contained in:
Kit Langton
2026-07-22 14:27:13 -04:00
parent cc458a47a3
commit 3ecc29f3a2
25 changed files with 1042 additions and 878 deletions
@@ -39,8 +39,13 @@ export function toSessionError(cause: unknown): SessionError.Error {
}
if (cause instanceof PermissionV2.BlockedError) return { type: "permission.rejected", message: cause.message }
if (cause instanceof QuestionV2.RejectedError) return { type: "aborted", message: cause.message }
if (cause instanceof ToolFailure || cause instanceof Tool.Failure)
return cause.error === undefined ? { type: "tool.execution", message: cause.message } : toSessionError(cause.error)
if (cause instanceof ToolFailure || cause instanceof Tool.Failure) {
if (cause.error === undefined) return { type: "tool.execution", message: cause.message }
// The canonical error is the sole model-visible representation, so a cause
// with no message must not erase the tool's curated failure message.
const unwrapped = toSessionError(cause.error)
return unwrapped.message === "" ? { ...unwrapped, type: "tool.execution", message: cause.message } : unwrapped
}
if (cause instanceof StepFailedError) return cause.error
if (cause instanceof AgentNotFoundError) return { type: "unknown", message: cause.message }
if (cause instanceof UserInterruptedError) return { type: "aborted", message: cause.message }
+14 -15
View File
@@ -33,7 +33,7 @@ import { Image } from "@opencode-ai/core/image"
import { testEffect } from "./lib/effect"
import { imagePassthrough } from "./lib/image"
import { location } from "./fixture/location"
import { settleTool, toolDefinitions, toolIdentity, waitForTool } from "./lib/tool"
import { executeTool, toolDefinitions, toolIdentity, waitForTool } from "./lib/tool"
let assertion: Deferred.Deferred<PermissionV2.AssertInput> | undefined
let decision: Effect.Effect<void, PermissionV2.Error> = Effect.void
@@ -803,8 +803,8 @@ it.effect("advertises MCP output schemas to Code Mode", () =>
Effect.gen(function* () {
const registry = yield* ToolRegistry.Service
yield* waitForTool(registry, "execute")
const materialized = yield* registry.materialize()
const execute = materialized.definitions.find((tool) => tool.name === "execute")
const definitions = yield* toolDefinitions(registry)
const execute = definitions.find((tool) => tool.name === "execute")
expect(execute?.description).not.toContain("tools.demo.search")
}),
@@ -831,15 +831,14 @@ it.effect("fails the call when MCP reports isError", () =>
const registry = yield* ToolRegistry.Service
yield* waitForTool(registry, "direct_fail")
const settlement = yield* settleTool(registry, {
const execution = yield* executeTool(registry, {
sessionID: SessionV2.ID.make("ses_mcp_is_error"),
...toolIdentity,
call: { type: "tool-call", id: "call_mcp_is_error", name: "direct_fail", input: {} },
})
expect(settlement.result).toEqual({ type: "error", value: "search index unavailable" })
expect(settlement.error).toMatchObject({ message: "search index unavailable" })
expect(settlement.output).toBeUndefined()
expect(execution).toMatchObject({ status: "error", error: { message: "search index unavailable" } })
expect(execution.content).toBeUndefined()
}),
)
@@ -851,15 +850,14 @@ it.effect("preserves MCP text and media content for the model", () =>
const registry = yield* ToolRegistry.Service
yield* waitForTool(registry, "direct_media")
const settlement = yield* settleTool(registry, {
const execution = yield* executeTool(registry, {
sessionID: SessionV2.ID.make("ses_mcp_media"),
...toolIdentity,
call: { type: "tool-call", id: "call_mcp_media", name: "direct_media", input: {} },
})
expect(settlement.error).toBeUndefined()
expect(settlement.result.type).toBe("content")
expect(settlement.output?.content).toMatchObject([
expect(execution.status).toBe("completed")
expect(execution.content).toMatchObject([
{ type: "text", text: "rendered chart" },
{ type: "file", mime: "image/png" },
])
@@ -875,7 +873,7 @@ it.effect("waits for permission before calling an MCP tool", () =>
const registry = yield* ToolRegistry.Service
yield* waitForTool(registry, "execute")
const fiber = yield* settleTool(registry, {
const fiber = yield* executeTool(registry, {
sessionID: SessionV2.ID.make("ses_mcp_permission"),
...toolIdentity,
call: {
@@ -914,7 +912,7 @@ it.effect("does not call MCP when permission is blocked", () =>
const registry = yield* ToolRegistry.Service
yield* waitForTool(registry, "execute")
const settlement = yield* settleTool(registry, {
const execution = yield* executeTool(registry, {
sessionID: SessionV2.ID.make("ses_mcp_blocked"),
...toolIdentity,
call: {
@@ -924,8 +922,9 @@ it.effect("does not call MCP when permission is blocked", () =>
input: { code: "return await tools.demo.search({})" },
},
})
expect(settlement.result).toEqual({ type: "text", value: "Unable to execute demo_search" })
expect(settlement.output?.structured).toEqual({
expect(execution.status).toBe("completed")
expect(execution.content).toEqual([{ type: "text", text: "Unable to execute demo_search" }])
expect(execution.metadata).toEqual({
toolCalls: [{ tool: "demo.search", status: "error" }],
error: true,
})
+24 -14
View File
@@ -267,10 +267,10 @@ describe("PluginV2", () => {
})
yield* plugins.activate([versioned(plugin)])
expect((yield* registry.materialize()).definitions.map((tool) => tool.name)).toContain("plugin_tool")
expect((yield* registry.snapshot()).definitions.map((tool) => tool.name)).toContain("plugin_tool")
yield* plugins.activate([])
expect((yield* registry.materialize()).definitions.map((tool) => tool.name)).not.toContain("plugin_tool")
expect((yield* registry.snapshot()).definitions.map((tool) => tool.name)).not.toContain("plugin_tool")
}),
)
@@ -299,7 +299,7 @@ describe("PluginV2", () => {
yield* plugins.activate([versioned(plugin)])
expect((yield* registry.materialize()).definitions.map((tool) => tool.name)).toEqual([
expect((yield* registry.snapshot()).definitions.map((tool) => tool.name)).toEqual([
"plain",
"context7_look_up",
"execute",
@@ -307,14 +307,14 @@ describe("PluginV2", () => {
}),
)
it.effect("fires before/after tool hooks with mutable events around settlement", () =>
it.effect("fires before/after tool hooks with mutable events around execution", () =>
Effect.gen(function* () {
const plugins = yield* PluginV2.Service
const registry = yield* ToolRegistry.Service
const executed: unknown[] = []
const seen: {
before?: unknown
after?: { input: unknown; result: unknown; output: unknown }
after?: { input: unknown; status: string; content: unknown; metadata: unknown }
} = {}
const plugin = EffectPlugin.define({
@@ -348,9 +348,15 @@ describe("PluginV2", () => {
yield* ctx.tool
.hook("execute.after", (event) =>
Effect.sync(() => {
seen.after = { input: event.input, result: event.result, output: event.output }
event.result = { type: "text", value: "after-mutated" }
event.output = { structured: { rewritten: true }, content: [] }
seen.after = {
input: event.input,
status: event.status,
content: event.content,
metadata: event.metadata,
}
if (event.status !== "completed") return
event.content = [{ type: "text", text: "after-mutated" }]
event.metadata = { rewritten: true }
}),
)
.pipe(Effect.asVoid)
@@ -359,8 +365,8 @@ describe("PluginV2", () => {
yield* plugins.activate([versioned(plugin)])
const materialized = yield* registry.materialize()
const settlement = yield* materialized.settle({
const toolSet = yield* registry.snapshot()
const execution = yield* toolSet.execute({
sessionID: SessionV2.ID.make("ses_hooks"),
agent: AgentV2.ID.make("build"),
messageID: SessionMessage.ID.make("msg_hooks"),
@@ -371,11 +377,15 @@ describe("PluginV2", () => {
expect(executed).toEqual([{ text: "before-mutated" }])
expect(seen.after).toEqual({
input: { text: "before-mutated" },
result: { type: "json", value: { text: "before-mutated" } },
output: { structured: { text: "before-mutated" }, content: [] },
status: "completed",
content: [{ type: "text", text: '{"text":"before-mutated"}' }],
metadata: undefined,
})
expect(execution).toMatchObject({
status: "completed",
content: [{ type: "text", text: "after-mutated" }],
metadata: { rewritten: true },
})
expect(settlement.result).toEqual({ type: "text", value: "after-mutated" })
expect(settlement.output).toEqual({ structured: { rewritten: true }, content: [] })
}),
)
})
+10 -6
View File
@@ -287,7 +287,7 @@ describe("fromPromise", () => {
input: Schema.Struct({ name: Schema.String }),
output: Schema.String,
execute: async ({ name }, context) => {
await context.progress({ structured: { phase: "greeting" } })
await context.progress({ metadata: { phase: "greeting" } })
return `Hello, ${name}!`
},
})
@@ -297,18 +297,22 @@ describe("fromPromise", () => {
yield* PluginPromise.fromPromise(promisePlugin).effect(host)
const materialized = yield* registry.materialize()
expect(materialized.definitions).toContainEqual(expect.objectContaining({ name: "hello", description: "Hello" }))
const toolSet = yield* registry.snapshot()
expect(toolSet.definitions).toContainEqual(expect.objectContaining({ name: "hello", description: "Hello" }))
expect(
yield* materialized.settle({
yield* toolSet.execute({
sessionID: SessionV2.ID.make("ses_promise_tool"),
agent: AgentV2.ID.make("build"),
messageID: SessionMessage.ID.make("msg_promise_tool"),
progress: (update) => Effect.sync(() => progress.push(update)),
call: { type: "tool-call", id: "call_promise_tool", name: "hello", input: { name: "world" } },
}),
).toMatchObject({ result: { type: "text", value: "Hello, world!" } })
expect(progress).toEqual([{ structured: { phase: "greeting" }, content: [] }])
).toMatchObject({
status: "completed",
output: "Hello, world!",
content: [{ type: "text", text: "Hello, world!" }],
})
expect(progress).toEqual([{ metadata: { phase: "greeting" }, content: [] }])
}),
)
})
+2 -2
View File
@@ -95,10 +95,10 @@ const references = Layer.mock(ReferenceInstructions.Service, { load: () => Effec
const mcp = Layer.mock(McpInstructions.Service, { load: () => Effect.succeed(Instructions.empty) })
const plugins = Layer.mock(PluginSupervisor.Service, { flush: Effect.void })
const tools = Layer.mock(ToolRegistry.Service, {
materialize: () =>
snapshot: () =>
Effect.succeed({
definitions: [ToolDefinition.make({ name: "lookup", description: "Lookup", inputSchema: { type: "object" } })],
settle: () => Effect.die(new Error("unused")),
execute: () => Effect.die(new Error("unused")),
}),
register: () => Effect.die(new Error("unused")),
registerBatch: () => Effect.die(new Error("unused")),
@@ -34,7 +34,7 @@ import { ToolRegistry } from "@opencode-ai/core/tool/registry"
import { tempLocationLayer } from "./fixture/location"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { testEffect } from "./lib/effect"
import { registerToolPlugin, settleTool } from "./lib/tool"
import { executeTool, registerToolPlugin } from "./lib/tool"
const readToolNode = makeLocationNode({
name: "test/read-tool-plugin",
@@ -163,7 +163,7 @@ describe("SessionInstructions", () => {
// A read deep under sub/ discovers deep and sub AGENTS.md, walking up to but
// excluding the Location root (already supplied by core initial instructions).
yield* settleTool(registry, readCall(sessionID, "call-deep", "sub/deep/file.txt"))
yield* executeTool(registry, readCall(sessionID, "call-deep", "sub/deep/file.txt"))
const firstInjected = yield* synthetics(sessionID)
expect(firstInjected).toHaveLength(1)
@@ -179,7 +179,7 @@ describe("SessionInstructions", () => {
// A sibling read under sub/other discovers only the new AGENTS.md; sub is already
// injected for this session so it is not re-emitted, and the root is still excluded.
yield* settleTool(registry, readCall(sessionID, "call-other", "sub/other/file2.txt"))
yield* executeTool(registry, readCall(sessionID, "call-other", "sub/other/file2.txt"))
const secondInjected = yield* synthetics(sessionID)
expect(secondInjected).toHaveLength(2)
@@ -210,7 +210,7 @@ describe("SessionInstructions", () => {
yield* seedSynthetic(sessionID, [subPath])
expect(yield* synthetics(sessionID)).toHaveLength(1)
yield* settleTool(registry, readCall(sessionID, "call-sub", "sub/file.txt"))
yield* executeTool(registry, readCall(sessionID, "call-sub", "sub/file.txt"))
// The durable claim on the prior synthetic prevents re-injection; no new synthetic.
expect(yield* synthetics(sessionID)).toHaveLength(1)
@@ -236,7 +236,7 @@ describe("SessionInstructions", () => {
// Listing packages/foo/ discovers its own AGENTS.md, walking up to but excluding
// the Location root (already supplied by core initial instructions).
yield* settleTool(registry, readCall(sessionID, "call-list", "packages/foo"))
yield* executeTool(registry, readCall(sessionID, "call-list", "packages/foo"))
const firstInjected = yield* synthetics(sessionID)
expect(firstInjected).toHaveLength(1)
@@ -247,7 +247,7 @@ describe("SessionInstructions", () => {
// A subsequent file read under the listed directory is a dedup: pkg's AGENTS.md is
// already injected for this session, so nothing new is emitted.
yield* settleTool(registry, readCall(sessionID, "call-file", "packages/foo/file.txt"))
yield* executeTool(registry, readCall(sessionID, "call-file", "packages/foo/file.txt"))
expect(yield* synthetics(sessionID)).toHaveLength(1)
}),
@@ -269,7 +269,7 @@ describe("SessionInstructions", () => {
// The walk starts and stops at the Location root: the root AGENTS.md is searched but
// dropped by the dirname filter, and up() only walks upward so nested dirs are unseen.
yield* settleTool(registry, readCall(sessionID, "call-root-list", "."))
yield* executeTool(registry, readCall(sessionID, "call-root-list", "."))
expect(yield* synthetics(sessionID)).toHaveLength(0)
}),
@@ -368,7 +368,7 @@ Recent work
status: "running",
input: { path: "README.md" },
content: [],
structured: { type: "media", mime: "image/png" },
metadata: { type: "media", mime: "image/png" },
}),
time: { created },
}),
@@ -388,7 +388,6 @@ Recent work
name: "hello.png",
},
],
structured: {},
}),
time: { created, completed: created },
}),
@@ -403,7 +402,6 @@ Recent work
status: "completed",
input: { query: "Effect" },
content: [{ type: "text", text: "Found it" }],
structured: {},
}),
time: { created, completed: created },
}),
@@ -416,8 +414,6 @@ Recent work
state: SessionMessage.ToolStateError.make({
status: "error",
input: { path: "README.md" },
content: [],
structured: {},
error: { type: "unknown", message: "Denied" },
}),
time: { created, completed: created },
@@ -473,7 +469,7 @@ Recent work
providerMetadata: { provider: { continuation: "failed" } },
result: {
type: "error",
value: { error: { type: "unknown", message: "Denied" }, content: [], structured: {} },
value: { error: { type: "unknown", message: "Denied" }, content: [] },
},
},
])
@@ -575,9 +571,7 @@ Recent work
state: SessionMessage.ToolStateCompleted.make({
status: "completed",
input: { query: "Effect" },
content: [],
structured: {},
result: { type: "json", value: { found: true } },
content: [{ type: "text", text: '{"found":true}' }],
}),
time: { created, completed: created },
}),
@@ -592,8 +586,6 @@ Recent work
status: "error",
input: { query: "Effect" },
error: { type: "unknown", message: "Step interrupted" },
content: [],
structured: {},
}),
time: { created, completed: created },
}),
@@ -620,8 +612,10 @@ Recent work
type: "tool-result",
id: "hosted-completed",
name: "web_search",
result: { type: "json", value: { found: true } },
result: { type: "text", value: '{"found":true}' },
providerExecuted: true,
cache: undefined,
metadata: undefined,
providerMetadata: { provider: { itemId: "result_completed" } },
},
{
@@ -630,7 +624,7 @@ Recent work
name: "web_search",
input: { query: "Effect" },
providerExecuted: true,
providerMetadata: undefined,
providerMetadata: { provider: { itemId: "call_failed" } },
},
{
type: "tool-result",
@@ -641,13 +635,12 @@ Recent work
value: {
error: { type: "unknown", message: "Step interrupted" },
content: [],
structured: {},
},
},
providerExecuted: true,
cache: undefined,
metadata: undefined,
providerMetadata: undefined,
providerMetadata: { provider: { itemId: "result_failed" } },
},
])
})
@@ -676,9 +669,7 @@ Recent work
state: SessionMessage.ToolStateCompleted.make({
status: "completed",
input: { query: "Effect" },
content: [],
structured: {},
result: { type: "json", value: { status: "completed" } },
content: [{ type: "text", text: '{"status":"completed"}' }],
}),
time: { created, completed: created },
}),
@@ -692,8 +683,7 @@ Recent work
state: SessionMessage.ToolStateCompleted.make({
status: "completed",
input: { path: "README.md" },
content: [],
structured: { text: "Hello" },
content: [{ type: "text", text: "Hello" }],
}),
time: { created, completed: created },
}),
@@ -718,7 +708,7 @@ Recent work
type: "tool-result",
id: "hosted-old-model",
name: "web_search",
result: { type: "json", value: { status: "completed" } },
result: { type: "text", value: '{"status":"completed"}' },
providerExecuted: true,
cache: undefined,
metadata: undefined,
@@ -738,7 +728,7 @@ Recent work
type: "tool-result",
id: "local-old-model",
name: "read",
result: { type: "json", value: { text: "Hello" } },
result: { type: "text", value: "Hello" },
providerExecuted: false,
cache: undefined,
metadata: undefined,
@@ -50,7 +50,7 @@ const capture = (providerMetadataKey = "anthropic", options?: { readonly interru
}
const call = LLMEvent.toolCall({ id: "call-image", name: "read", input: { path: "pixel.png" } })
const result = LLMEvent.toolResult({
const hostedResult = LLMEvent.toolResult({
id: "call-image",
name: "read",
result: {
@@ -60,25 +60,28 @@ const result = LLMEvent.toolResult({
{ type: "file", uri: `data:image/png;base64,${base64}`, mime: "image/png", name: "pixel.png" },
],
},
output: {
structured: { type: "media", mime: "image/png" },
content: [
{ type: "text", text: "Image read successfully" },
{ type: "file", uri: `data:image/png;base64,${base64}`, mime: "image/png", name: "pixel.png" },
],
},
})
test("local tool success serializes media base64 once and reconstructs from structured content", async () => {
test("local tool success serializes media base64 once through canonical content", async () => {
const { published, publisher } = capture()
await Effect.runPromise(publisher.publish(call))
await Effect.runPromise(publisher.publish(result))
await Effect.runPromise(
publisher.toolExecution(call.id, call.name, {
status: "completed",
output: { type: "media", mime: "image/png" },
content: [
{ type: "text", text: "Image read successfully" },
{ type: "file", uri: `data:image/png;base64,${base64}`, mime: "image/png", name: "pixel.png" },
],
}),
)
const success = published.find((event) => event.type === "session.tool.success.1")
const success = published.find((event) => event.type === "session.tool.success.2")
expect(success).toBeDefined()
const serialized = JSON.stringify(success)
expect(serialized.split(base64)).toHaveLength(2)
expect(success?.data).not.toHaveProperty("result")
expect(success?.data).not.toHaveProperty("output")
expect(success?.data).toMatchObject({
content: [
@@ -88,12 +91,28 @@ test("local tool success serializes media base64 once and reconstructs from stru
})
})
test("provider-executed success retains its raw provider result", async () => {
test("provider-executed success derives content and retains provider result state", async () => {
const { published, publisher } = capture()
await Effect.runPromise(publisher.publish(LLMEvent.toolCall({ ...call, providerExecuted: true })))
await Effect.runPromise(publisher.publish(LLMEvent.toolResult({ ...result, providerExecuted: true })))
const success = published.find((event) => event.type === "session.tool.success.1")
expect(success?.data).toHaveProperty("result")
await Effect.runPromise(
publisher.publish(
LLMEvent.toolResult({
...hostedResult,
providerExecuted: true,
providerMetadata: { anthropic: { result: { type: "content", value: [] } } },
}),
),
)
const success = published.find((event) => event.type === "session.tool.success.2")
expect(success?.data).not.toHaveProperty("result")
expect(success?.data).toMatchObject({
executed: true,
content: [
{ type: "text", text: "Image read successfully" },
{ type: "file", uri: `data:image/png;base64,${base64}`, mime: "image/png" },
],
resultState: { result: { type: "content" } },
})
})
test("interrupted progress publication remains in the terminal failure snapshot", async () => {
@@ -101,14 +120,14 @@ test("interrupted progress publication remains in the terminal failure snapshot"
await Effect.runPromise(publisher.publish(call))
const exit = await Effect.runPromiseExit(
publisher.progress(call.id, {
structured: { phase: "visible" },
metadata: { phase: "visible" },
content: [{ type: "text", text: "visible" }],
}),
)
expect(Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause)).toBe(true)
await Effect.runPromise(publisher.failUnsettledTools({ type: "aborted", message: "interrupted" }))
expect(published.find((event) => event.type === "session.tool.failed.1")?.data).toMatchObject({
expect(published.find((event) => event.type === "session.tool.failed.2")?.data).toMatchObject({
metadata: { phase: "visible" },
content: [{ type: "text", text: "visible" }],
})
@@ -119,7 +138,7 @@ test("failure before progress omits partial output fields", async () => {
await Effect.runPromise(publisher.publish(call))
await Effect.runPromise(publisher.failUnsettledTools({ type: "aborted", message: "interrupted" }))
const failed = published.find((event) => event.type === "session.tool.failed.1")?.data
const failed = published.find((event) => event.type === "session.tool.failed.2")?.data
expect(failed).not.toHaveProperty("content")
expect(failed).not.toHaveProperty("metadata")
})
@@ -192,7 +211,7 @@ test("provider-executed tool metadata is flattened using the route key", async (
expect(published.find((event) => event.type === "session.tool.called.1")?.data).toMatchObject({
state: { itemId: "call" },
})
expect(published.find((event) => event.type === "session.tool.success.1")?.data).toMatchObject({
expect(published.find((event) => event.type === "session.tool.success.2")?.data).toMatchObject({
resultState: { itemId: "result" },
})
})
@@ -201,29 +220,30 @@ test("binary failure emits no success event", async () => {
const { published, publisher } = capture()
await Effect.runPromise(publisher.publish(call))
await Effect.runPromise(
publisher.publish(
LLMEvent.toolResult({
id: call.id,
name: call.name,
result: { type: "error", value: "Cannot read binary file" },
}),
),
publisher.toolExecution(call.id, call.name, {
status: "error",
error: { type: "tool.execution", message: "Cannot read binary file" },
}),
)
expect(published.some((event) => event.type === "session.tool.success.1")).toBe(false)
expect(published.some((event) => event.type === "session.tool.failed.1")).toBe(true)
expect(published.some((event) => event.type === "session.tool.success.2")).toBe(false)
expect(published.some((event) => event.type === "session.tool.failed.2")).toBe(true)
})
test("success event data can carry a provider-executed result", () => {
test("success event data can carry provider-executed result state", () => {
const decoded = Schema.decodeUnknownSync(SessionEvent.Tool.Success.data)({
sessionID,
assistantMessageID: SessionMessage.ID.create(),
callID: "call-old",
structured: { type: "media", mime: "image/png" },
content: [{ type: "file", uri: `data:image/png;base64,${base64}`, mime: "image/png" }],
result: { type: "content", value: [{ type: "file", uri: `data:image/png;base64,${base64}`, mime: "image/png" }] },
executed: true,
resultState: {
result: {
type: "content",
value: [{ type: "file", uri: `data:image/png;base64,${base64}`, mime: "image/png" }],
},
},
})
expect(decoded.result).toMatchObject({ type: "content" })
expect(decoded.resultState).toMatchObject({ result: { type: "content" } })
})
test("step finish records settlement without publishing step ended", async () => {
@@ -8,23 +8,24 @@ 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 { executeTool, toolDefinitions } from "./lib/tool"
import { Cause, Deferred, Effect, Exit, Fiber, Layer, Option, Schema, SchemaGetter, SchemaIssue, Scope } from "effect"
import { testEffect } from "./lib/effect"
const bounds: ToolOutputStore.BoundInput[] = []
const retentionFailure = new ToolOutputStore.StorageError({ operation: "write", cause: new Error("disk full") })
const outputStore = Layer.mock(ToolOutputStore.Service, {
limits: () => Effect.succeed({ maxLines: ToolOutputStore.MAX_LINES, maxBytes: ToolOutputStore.MAX_BYTES }),
bound: (input) => {
if (input.callID === "call-retention-failure") return Effect.fail(retentionFailure)
return Effect.sync(() => bounds.push(input)).pipe(
Effect.as(
input.callID === "call-bounded"
? {
output: { structured: {}, content: [{ type: "text" as const, text: "bounded reference" }] },
content: [{ type: "text" as const, text: "bounded reference" }],
outputPaths: ["/managed/generic"],
}
: { output: input.output, outputPaths: [] },
: { content: input.content, outputPaths: [] },
),
)
},
@@ -91,7 +92,7 @@ describe("ToolRegistry", () => {
expect(error).toBeInstanceOf(Tool.RegistrationError)
expect(error.message).toBe('Invalid tool namespace: "slack..admin"')
expect((yield* service.materialize()).definitions).toEqual([])
expect((yield* service.snapshot()).definitions).toEqual([])
}),
)
@@ -106,19 +107,22 @@ describe("ToolRegistry", () => {
.pipe(Effect.flip)
expect(error).toBeInstanceOf(Tool.RegistrationError)
expect((yield* service.materialize()).definitions).toEqual([])
expect((yield* service.snapshot()).definitions).toEqual([])
}),
)
it.effect("filters disabled tools with edit aliases and ordered wildcard precedence", () =>
Effect.gen(function* () {
const service = yield* ToolRegistry.Service
yield* service.register({
question: make(),
bash: make(),
edit: make("edit"),
write: make("edit"),
}, { codemode: false })
yield* service.register(
{
question: make(),
bash: make(),
edit: make("edit"),
write: make("edit"),
},
{ codemode: false },
)
const names = (permissions: PermissionV2.Ruleset) =>
toolDefinitions(service, permissions).pipe(Effect.map((definitions) => definitions.map((tool) => tool.name)))
@@ -191,41 +195,47 @@ describe("ToolRegistry", () => {
it.effect("returns model errors without swallowing interruption or defects", () =>
Effect.gen(function* () {
const service = yield* ToolRegistry.Service
yield* service.register({
failed: Tool.make({
description: "Failed",
input: Schema.Struct({}),
output: Schema.Struct({ ok: Schema.Boolean }),
execute: () => Effect.fail(new Tool.Failure({ message: "Denied" })),
}),
}, { codemode: false })
yield* service.register(
{
failed: Tool.make({
description: "Failed",
input: Schema.Struct({}),
output: Schema.Struct({ ok: Schema.Boolean }),
execute: () => Effect.fail(new Tool.Failure({ message: "Denied" })),
}),
},
{ codemode: false },
)
expect(
yield* executeTool(service, {
sessionID,
...identity,
call: { type: "tool-call", id: "failed", name: "failed", input: {} },
}),
).toEqual({ type: "error", value: "Denied" })
).toEqual({ status: "error", error: { type: "tool.execution", message: "Denied" } })
expect(
yield* executeTool(service, {
sessionID,
...identity,
call: { type: "tool-call", id: "missing", name: "missing", input: {} },
}),
).toEqual({ type: "error", value: "Unknown tool: missing" })
).toEqual({ status: "error", error: { type: "tool.unknown", message: "Unknown tool: missing" } })
yield* service.register({
defect: Tool.make({
description: "Defect",
input: Schema.Struct({}),
output: Schema.Struct({}),
execute: () => Effect.die("unexpected executor defect"),
}),
}, { codemode: false })
yield* service.register(
{
defect: Tool.make({
description: "Defect",
input: Schema.Struct({}),
output: Schema.Struct({}),
execute: () => Effect.die("unexpected executor defect"),
}),
},
{ codemode: false },
)
expect(
yield* service.materialize().pipe(
Effect.flatMap((materialized) =>
materialized.settle({
yield* service.snapshot().pipe(
Effect.flatMap((toolSet) =>
toolSet.execute({
sessionID,
...identity,
call: { type: "tool-call", id: "defect", name: "defect", input: {} },
@@ -237,12 +247,12 @@ describe("ToolRegistry", () => {
}),
)
it.effect("propagates retention failures through settlement", () =>
it.effect("propagates retention failures through execution", () =>
Effect.gen(function* () {
const service = yield* ToolRegistry.Service
yield* service.register({ echo: make() }, { codemode: false })
const materialized = yield* service.materialize()
const exit = yield* materialized.settle(call("echo", "call-retention-failure")).pipe(Effect.exit)
const toolSet = yield* service.snapshot()
const exit = yield* toolSet.execute(call("echo", "call-retention-failure")).pipe(Effect.exit)
expect(Exit.isFailure(exit)).toBe(true)
if (Exit.isFailure(exit)) expect(Option.getOrUndefined(Cause.findErrorOption(exit.cause))).toBe(retentionFailure)
@@ -250,13 +260,13 @@ describe("ToolRegistry", () => {
}),
)
it.effect("exposes settlement only through materialization", () =>
it.effect("exposes execution only through a snapshot", () =>
Effect.gen(function* () {
const service = yield* ToolRegistry.Service
expect("definitions" in service).toBe(false)
expect("execute" in service).toBe(false)
expect("settle" in service).toBe(false)
expect(typeof service.materialize).toBe("function")
expect(typeof service.snapshot).toBe("function")
}),
)
@@ -264,65 +274,70 @@ describe("ToolRegistry", () => {
Effect.gen(function* () {
const service = yield* ToolRegistry.Service
const contexts: Tool.Context[] = []
yield* service.register({
context: Tool.make({
description: "Context",
input: Schema.Struct({}),
output: Schema.Struct({ ok: Schema.Boolean }),
execute: (_, context) => Effect.sync(() => contexts.push(context)).pipe(Effect.as({ ok: true })),
}),
}, { codemode: false })
yield* service.register(
{
context: Tool.make({
description: "Context",
input: Schema.Struct({}),
output: Schema.Struct({ ok: Schema.Boolean }),
execute: (_, context) => Effect.sync(() => contexts.push(context)).pipe(Effect.as({ ok: true })),
}),
},
{ codemode: false },
)
yield* executeTool(service, {
sessionID,
...identity,
call: { type: "tool-call", id: "call-context", name: "context", input: {} },
})
expect(contexts).toEqual([
{ sessionID, ...identity, callID: "call-context", progress: expect.any(Function) },
])
expect(contexts).toEqual([{ sessionID, ...identity, callID: "call-context", progress: expect.any(Function) }])
}),
)
it.effect("encodes output and applies generic settlement bounding", () =>
it.effect("encodes output and applies generic execution bounding", () =>
Effect.gen(function* () {
bounds.length = 0
const service = yield* ToolRegistry.Service
yield* service.register({ bounded: make() }, { codemode: false })
expect(
yield* settleTool(service, {
yield* executeTool(service, {
sessionID,
...identity,
call: { type: "tool-call", id: "call-bounded", name: "bounded", input: { text: "complete" } },
}),
).toEqual({
result: { type: "text", value: "bounded reference" },
output: { structured: {}, content: [{ type: "text", text: "bounded reference" }] },
status: "completed",
output: { text: "complete" },
content: [{ type: "text", text: "bounded reference" }],
outputPaths: ["/managed/generic"],
})
expect(bounds).toHaveLength(1)
}),
)
it.effect("normalizes image tool output at settlement and drops unresizable images", () =>
it.effect("normalizes image tool output at execution and drops unresizable images", () =>
Effect.gen(function* () {
const service = yield* ToolRegistry.Service
yield* service.register({
snapshot: Tool.make({
description: "Return images",
input: Schema.Struct({ text: Schema.String }),
output: Schema.Struct({ text: Schema.String }),
execute: ({ text }) => Effect.succeed({ text }),
toModelOutput: ({ output }) => [
{ type: "file", data: "aW1hZ2U=", mime: "image/png", name: "frame.png" },
{ type: "file", data: "aW1hZ2U=", mime: "image/png", name: "too-large.png" },
{ type: "file", data: "aW1hZ2U=", mime: "image/png", name: "corrupt.png" },
{ type: "text", text: output.text },
],
}),
}, { codemode: false })
yield* service.register(
{
snapshot: Tool.make({
description: "Return images",
input: Schema.Struct({ text: Schema.String }),
output: Schema.Struct({ text: Schema.String }),
execute: ({ text }) => Effect.succeed({ text }),
toModelOutput: ({ output }) => [
{ type: "file", data: "aW1hZ2U=", mime: "image/png", name: "frame.png" },
{ type: "file", data: "aW1hZ2U=", mime: "image/png", name: "too-large.png" },
{ type: "file", data: "aW1hZ2U=", mime: "image/png", name: "corrupt.png" },
{ type: "text", text: output.text },
],
}),
},
{ codemode: false },
)
const settlement = yield* settleTool(service, call("snapshot"))
expect(settlement.output?.content).toEqual([
const execution = yield* executeTool(service, call("snapshot"))
expect(execution.content).toEqual([
{ type: "file", uri: "data:image/jpeg;base64,bm9ybWFsaXplZA==", mime: "image/jpeg", name: "frame.png" },
{ type: "text", text: "snapshot" },
{ type: "text", text: "[1 image omitted: could not be decoded.]" },
@@ -334,26 +349,29 @@ describe("ToolRegistry", () => {
it.effect("normalizes image progress content before it is published", () =>
Effect.gen(function* () {
const service = yield* ToolRegistry.Service
yield* service.register({
progressive: Tool.make({
description: "Emit image progress",
input: Schema.Struct({ text: Schema.String }),
output: Schema.Struct({ text: Schema.String }),
execute: ({ text }, context) =>
context
.progress({
structured: { stage: "capture" },
content: [
{ type: "file", data: "aW1hZ2U=", mime: "image/png", name: "frame.png" },
{ type: "file", data: "aW1hZ2U=", mime: "image/png", name: "too-large.png" },
],
})
.pipe(Effect.as({ text })),
}),
}, { codemode: false })
yield* service.register(
{
progressive: Tool.make({
description: "Emit image progress",
input: Schema.Struct({ text: Schema.String }),
output: Schema.Struct({ text: Schema.String }),
execute: ({ text }, context) =>
context
.progress({
metadata: { stage: "capture" },
content: [
{ type: "file", data: "aW1hZ2U=", mime: "image/png", name: "frame.png" },
{ type: "file", data: "aW1hZ2U=", mime: "image/png", name: "too-large.png" },
],
})
.pipe(Effect.as({ text })),
}),
},
{ codemode: false },
)
const updates: ToolRegistry.Progress[] = []
yield* settleTool(service, {
yield* executeTool(service, {
...call("progressive"),
progress: (update) =>
Effect.sync(() => {
@@ -362,7 +380,7 @@ describe("ToolRegistry", () => {
})
expect(updates).toEqual([
{
structured: { stage: "capture" },
metadata: { stage: "capture" },
content: [
{ type: "file", uri: "data:image/jpeg;base64,bm9ybWFsaXplZA==", mime: "image/jpeg", name: "frame.png" },
{ type: "text", text: "[1 image omitted: could not be resized below the image size limit.]" },
@@ -382,23 +400,31 @@ describe("ToolRegistry", () => {
encode: SchemaGetter.transform((value) => value === "yes"),
}),
)
yield* service.register({
transformed: Tool.make({
description: "Transform values",
input: Schema.Struct({ value: Transformed }),
output: Schema.Struct({ value: Transformed }),
execute: ({ value }) => Effect.sync(() => executed.push(value)).pipe(Effect.as({ value })),
toModelOutput: ({ output }) => [{ type: "text", text: String(output.value) }],
}),
}, { codemode: false })
yield* service.register(
{
transformed: Tool.make({
description: "Transform values",
input: Schema.Struct({ value: Transformed }),
output: Schema.Struct({ value: Transformed }),
execute: ({ value }) => Effect.sync(() => executed.push(value)).pipe(Effect.as({ value })),
toModelOutput: ({ output }) => [{ type: "text", text: String(output.value) }],
}),
},
{ codemode: false },
)
// toModelOutput observes the decoded domain value; Code Mode observes the encoded value.
expect(
yield* executeTool(service, {
sessionID,
...identity,
call: { type: "tool-call", id: "transformed", name: "transformed", input: { value: true } },
}),
).toEqual({ type: "text", value: "true" })
).toEqual({
status: "completed",
output: { value: true },
content: [{ type: "text", text: "yes" }],
})
expect(executed).toEqual(["yes"])
expect(
yield* executeTool(service, {
@@ -406,35 +432,44 @@ describe("ToolRegistry", () => {
...identity,
call: { type: "tool-call", id: "invalid-input", name: "transformed", input: { value: "yes" } },
}),
).toMatchObject({ type: "error", value: expect.stringContaining("Invalid tool input") })
).toMatchObject({
status: "error",
error: { type: "tool.execution", message: expect.stringContaining("Invalid tool input") },
})
expect(executed).toEqual(["yes"])
yield* service.register({
invalid_output: Tool.make({
description: "Return invalid output",
input: Schema.Struct({}),
output: Schema.Struct({
value: Schema.Boolean.pipe(
Schema.decodeTo(Schema.String, {
decode: SchemaGetter.transform((value) => String(value)),
encode: SchemaGetter.transformOrFail((value) =>
value === "valid"
? Effect.succeed(true)
: Effect.fail(new SchemaIssue.InvalidValue(Option.some(value), { message: "invalid output" })),
),
}),
),
yield* service.register(
{
invalid_output: Tool.make({
description: "Return invalid output",
input: Schema.Struct({}),
output: Schema.Struct({
value: Schema.Boolean.pipe(
Schema.decodeTo(Schema.String, {
decode: SchemaGetter.transform((value) => String(value)),
encode: SchemaGetter.transformOrFail((value) =>
value === "valid"
? Effect.succeed(true)
: Effect.fail(new SchemaIssue.InvalidValue(Option.some(value), { message: "invalid output" })),
),
}),
),
}),
execute: () => Effect.succeed({ value: "invalid" }),
}),
execute: () => Effect.succeed({ value: "invalid" }),
}),
}, { codemode: false })
},
{ codemode: false },
)
expect(
yield* executeTool(service, {
sessionID,
...identity,
call: { type: "tool-call", id: "invalid-output", name: "invalid_output", input: {} },
}),
).toMatchObject({ type: "error", value: expect.stringContaining("invalid value for its output schema") })
).toMatchObject({
status: "error",
error: { type: "tool.execution", message: expect.stringContaining("invalid value for its output schema") },
})
}),
)
@@ -443,12 +478,12 @@ describe("ToolRegistry", () => {
const service = yield* ToolRegistry.Service
const scope = yield* Scope.make()
yield* service.register({ echo: constant("advertised") }, { codemode: false }).pipe(Scope.provide(scope))
const request = yield* service.materialize()
const request = yield* service.snapshot()
yield* Scope.close(scope, Exit.void)
yield* service.register({ echo: constant("replacement") }, { codemode: false })
expect((yield* request.settle(call("echo"))).result).toEqual({ type: "text", value: "advertised" })
expect(yield* executeTool(service, call("echo"))).toEqual({ type: "text", value: "replacement" })
expect((yield* request.execute(call("echo"))).content).toEqual([{ type: "text", text: "advertised" }])
expect((yield* executeTool(service, call("echo"))).content).toEqual([{ type: "text", text: "replacement" }])
}),
)
@@ -459,9 +494,9 @@ describe("ToolRegistry", () => {
const overlay = yield* Scope.make()
yield* service.register({ echo: constant("overlay") }, { codemode: false }).pipe(Scope.provide(overlay))
expect(yield* executeTool(service, call("echo"))).toEqual({ type: "text", value: "overlay" })
expect((yield* executeTool(service, call("echo"))).content).toEqual([{ type: "text", text: "overlay" }])
yield* Scope.close(overlay, Exit.void)
expect(yield* executeTool(service, call("echo"))).toEqual({ type: "text", value: "base" })
expect((yield* executeTool(service, call("echo"))).content).toEqual([{ type: "text", text: "base" }])
}),
)
@@ -480,8 +515,8 @@ describe("ToolRegistry", () => {
}),
})
.pipe(Scope.provide(scope))
const materialized = yield* service.materialize()
const execute = materialized.definitions.find((tool) => tool.name === "execute")
const toolSet = yield* service.snapshot()
const execute = toolSet.definitions.find((tool) => tool.name === "execute")
expect(execute?.description).toContain("confined Code Mode runtime")
expect(execute?.description).not.toContain("Echo text")
yield* Scope.close(scope, Exit.void)
@@ -494,7 +529,7 @@ describe("ToolRegistry", () => {
}),
})
const settlement = yield* materialized.settle({
const execution = yield* toolSet.execute({
...call("execute"),
call: {
type: "tool-call",
@@ -504,7 +539,7 @@ describe("ToolRegistry", () => {
},
})
expect(settlement.result).toMatchObject({ type: "text" })
expect(execution).toMatchObject({ status: "completed", content: [{ type: "text" }] })
expect(executed).toEqual(["old:request"])
}),
)
+211 -164
View File
@@ -238,43 +238,46 @@ const permission = Layer.succeed(
)
const echo = Layer.effectDiscard(
ToolRegistry.Service.use((registry) =>
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: () =>
(toolExecutionGate ? Deferred.await(toolExecutionGate) : Effect.void).pipe(
Effect.andThen(Effect.die("unexpected tool defect")),
),
}),
// BigInt output with no model content forces ToolOutputStore.bound onto its
// JSON.stringify encode path, which fails with a typed StorageError.
storefail: Tool.make({
description: "Produce output that cannot be persisted",
input: Schema.Struct({}),
output: Schema.Any,
execute: () => Effect.succeed({ big: 1n }),
}),
}, { codemode: false }),
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: () =>
(toolExecutionGate ? Deferred.await(toolExecutionGate) : Effect.void).pipe(
Effect.andThen(Effect.die("unexpected tool defect")),
),
}),
// The wrapped ToolOutputStore below fails bound for this call ID with a
// typed StorageError, exercising the infrastructure failure channel.
storefail: Tool.make({
description: "Produce output that cannot be persisted",
input: Schema.Struct({}),
output: Schema.Struct({}),
execute: () => Effect.succeed({}),
}),
},
{ codemode: false },
),
),
)
const echoNode = makeLocationNode({ name: "test/session-runner-tools", layer: echo, deps: [ToolRegistry.node] })
@@ -379,6 +382,15 @@ const promptCatalog = Layer.mock(Catalog.Service, {
small: () => Effect.succeed(undefined),
},
})
// Pass-through bounding that fails "call-storefail" with a typed StorageError so
// runner tests can exercise the infrastructure failure channel deterministically.
const toolOutputStore = Layer.mock(ToolOutputStore.Service, {
limits: () => Effect.succeed({ maxLines: ToolOutputStore.MAX_LINES, maxBytes: ToolOutputStore.MAX_BYTES }),
bound: (input) =>
input.callID === "call-storefail"
? Effect.fail(new ToolOutputStore.StorageError({ operation: "write", cause: new Error("disk full") }))
: Effect.succeed({ content: input.content, outputPaths: [] }),
})
const runnerLayer = AppNodeBuilder.build(SessionRunnerLLM.node, [
[Snapshot.node, Snapshot.noopLayer],
[LayerNodePlatform.llmClient, client],
@@ -391,7 +403,7 @@ const runnerLayer = AppNodeBuilder.build(SessionRunnerLLM.node, [
[PermissionV2.node, permission],
[Config.node, config],
[McpInstructions.node, mcpInstructions],
[ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig],
[ToolOutputStore.node, toolOutputStore],
[PluginSupervisor.node, pluginSupervisor],
])
const execution = Layer.effect(
@@ -449,7 +461,7 @@ const it = testEffect(
[Snapshot.node, Snapshot.noopLayer],
[SessionExecution.node, execution],
[Config.node, config],
[ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig],
[ToolOutputStore.node, toolOutputStore],
[PluginSupervisor.node, pluginSupervisor],
],
),
@@ -586,8 +598,8 @@ const recordedStepSettlementEvents = (id: SessionV2.ID, assistantMessageID: Sess
const settlementTypes = new Set([
"session.step.started.1",
"session.tool.called.1",
"session.tool.success.1",
"session.tool.failed.1",
"session.tool.success.2",
"session.tool.failed.2",
"session.step.ended.1",
"session.step.failed.1",
])
@@ -827,12 +839,26 @@ describe("SessionRunnerLLM", () => {
yield* session.resume(sessionID)
expect(requests).toHaveLength(1)
// A hook-removed call fails independently and continues while step allowance remains.
expect(requests).toHaveLength(2)
expect(requests[0]?.system.map((part) => part.text)).toEqual(["Hooked system"])
expect(requests[0]?.messages).toEqual([Message.user("Hooked message")])
expect(requests[0]?.tools.map((tool) => tool.name)).not.toContain("echo")
expect(requests[0]?.tools.map((tool) => tool.name)).not.toContain("unregistered")
expect(executions).toEqual([])
expect(yield* session.context(sessionID)).toMatchObject([
{ type: "user", text: "Original message" },
{
type: "assistant",
content: [
{
type: "tool",
id: "call-removed",
state: { status: "error", error: { type: "tool.unknown" } },
},
],
},
])
}),
)
@@ -841,19 +867,22 @@ describe("SessionRunnerLLM", () => {
const session = yield* setup
const registry = yield* ToolRegistry.Service
const contexts: Tool.Context[] = []
yield* registry.register({
location_context: Tool.make({
description: "Read application context",
input: Schema.Struct({ query: Schema.String }),
output: Schema.Struct({ answer: Schema.String }),
execute: ({ query }, context) =>
Effect.gen(function* () {
contexts.push(context)
yield* context.progress({ structured: { phase: "reading" } })
return { answer: query.toUpperCase() }
}),
}),
}, { codemode: false })
yield* registry.register(
{
location_context: Tool.make({
description: "Read application context",
input: Schema.Struct({ query: Schema.String }),
output: Schema.Struct({ answer: Schema.String }),
execute: ({ query }, context) =>
Effect.gen(function* () {
contexts.push(context)
yield* context.progress({ metadata: { phase: "reading" } })
return { answer: query.toUpperCase() }
}),
}),
},
{ codemode: false },
)
yield* admit(session, "Use application context")
responses = [reply.tool("call-location", "location_context", { query: "hello" }), []]
const events = yield* EventV2.Service
@@ -876,7 +905,7 @@ describe("SessionRunnerLLM", () => {
progress: expect.any(Function),
},
])
expect(Array.from(yield* Fiber.join(progressFiber))[0]?.data.structured).toEqual({ phase: "reading" })
expect(Array.from(yield* Fiber.join(progressFiber))[0]?.data.metadata).toEqual({ phase: "reading" })
expect(yield* session.context(sessionID)).toMatchObject([
{ type: "user", text: "Use application context" },
{
@@ -885,7 +914,7 @@ describe("SessionRunnerLLM", () => {
{
type: "tool",
id: "call-location",
state: { status: "completed", structured: { answer: "HELLO" } },
state: { status: "completed", content: [{ type: "text", text: '{"answer":"HELLO"}' }] },
},
],
},
@@ -897,21 +926,24 @@ describe("SessionRunnerLLM", () => {
Effect.gen(function* () {
const session = yield* setup
const registry = yield* ToolRegistry.Service
yield* registry.register({
failing_progress: Tool.make({
description: "Report progress and fail",
input: Schema.Struct({}),
output: Schema.Struct({}),
execute: (_, context) =>
Effect.gen(function* () {
yield* context.progress({
structured: { phase: "running" },
content: [{ type: "text", text: "before failure" }],
})
return yield* new ToolFailure({ message: "failed after progress" })
}),
}),
}, { codemode: false })
yield* registry.register(
{
failing_progress: Tool.make({
description: "Report progress and fail",
input: Schema.Struct({}),
output: Schema.Struct({}),
execute: (_, context) =>
Effect.gen(function* () {
yield* context.progress({
metadata: { phase: "running" },
content: [{ type: "text", text: "before failure" }],
})
return yield* new ToolFailure({ message: "failed after progress" })
}),
}),
},
{ codemode: false },
)
yield* admit(session, "Run failing progress")
responses = [reply.tool("call-failing-progress", "failing_progress", {}), reply.stop()]
@@ -927,7 +959,7 @@ describe("SessionRunnerLLM", () => {
id: "call-failing-progress",
state: {
status: "error",
structured: { phase: "running" },
metadata: { phase: "running" },
content: [{ type: "text", text: "before failure" }],
error: { message: "failed after progress" },
},
@@ -946,14 +978,17 @@ describe("SessionRunnerLLM", () => {
const scope = yield* Scope.make()
const executions: string[] = []
yield* registry
.register({
reloaded: Tool.make({
description: "Record the advertised tool",
input: Schema.Struct({}),
output: Schema.Struct({ value: Schema.String }),
execute: () => Effect.sync(() => executions.push("advertised")).pipe(Effect.as({ value: "advertised" })),
}),
}, { codemode: false })
.register(
{
reloaded: Tool.make({
description: "Record the advertised tool",
input: Schema.Struct({}),
output: Schema.Struct({ value: Schema.String }),
execute: () => Effect.sync(() => executions.push("advertised")).pipe(Effect.as({ value: "advertised" })),
}),
},
{ codemode: false },
)
.pipe(Scope.provide(scope))
yield* admit(session, "Use the reloaded tool")
responses = [
@@ -971,14 +1006,17 @@ describe("SessionRunnerLLM", () => {
const run = yield* session.resume(sessionID).pipe(Effect.forkChild)
yield* Deferred.await(streamStarted)
yield* Scope.close(scope, Exit.void)
yield* registry.register({
reloaded: Tool.make({
description: "Record the replacement tool",
input: Schema.Struct({}),
output: Schema.Struct({ value: Schema.String }),
execute: () => Effect.sync(() => executions.push("replacement")).pipe(Effect.as({ value: "replacement" })),
}),
}, { codemode: false })
yield* registry.register(
{
reloaded: Tool.make({
description: "Record the replacement tool",
input: Schema.Struct({}),
output: Schema.Struct({ value: Schema.String }),
execute: () => Effect.sync(() => executions.push("replacement")).pipe(Effect.as({ value: "replacement" })),
}),
},
{ codemode: false },
)
yield* Deferred.succeed(streamGate, undefined)
yield* Fiber.join(run)
@@ -991,7 +1029,7 @@ describe("SessionRunnerLLM", () => {
{
type: "tool",
id: "call-reloaded",
state: { status: "completed", structured: { value: "advertised" } },
state: { status: "completed", content: [{ type: "text", text: '{"value":"advertised"}' }] },
},
],
},
@@ -2377,7 +2415,6 @@ describe("SessionRunnerLLM", () => {
state: {
status: "completed",
input: { query: "hello" },
structured: {},
content: [
{ type: "text", text: "Hello" },
{ type: "file", mime: "image/png", uri: "data:image/png;base64,aGVsbG8=", name: "hello.png" },
@@ -2417,7 +2454,6 @@ describe("SessionRunnerLLM", () => {
state: {
status: "completed",
input: { text: "hello" },
structured: { text: "hello" },
content: [{ type: "text", text: "hello" }],
},
},
@@ -2429,7 +2465,7 @@ describe("SessionRunnerLLM", () => {
expect((yield* recordedStepSettlementEvents(sessionID, assistant.id)).map((event) => event.type)).toEqual([
"session.step.started.1",
"session.tool.called.1",
"session.tool.success.1",
"session.tool.success.2",
"session.step.ended.1",
])
}),
@@ -2581,7 +2617,8 @@ describe("SessionRunnerLLM", () => {
type: "tool-result",
id: "hosted-search",
name: "web_search",
result: { type: "json", value: [{ title: "Effect" }] },
// The generic replay result derives from canonical stored content.
result: { type: "text", value: '[{"title":"Effect"}]' },
providerExecuted: true,
providerMetadata: { openai: { blockType: "web_search_tool_result" } },
},
@@ -2667,7 +2704,7 @@ describe("SessionRunnerLLM", () => {
{
type: "tool",
id: "tool_0",
state: { status: "completed", structured: { text: "first" }, content: [{ type: "text", text: "first" }] },
state: { status: "completed", content: [{ type: "text", text: "first" }] },
},
],
},
@@ -2677,11 +2714,7 @@ describe("SessionRunnerLLM", () => {
{
type: "tool",
id: "tool_0",
state: {
status: "completed",
structured: { text: "second" },
content: [{ type: "text", text: "second" }],
},
state: { status: "completed", content: [{ type: "text", text: "second" }] },
},
],
},
@@ -2697,7 +2730,7 @@ describe("SessionRunnerLLM", () => {
{
type: "tool",
id: "tool_0",
state: { status: "completed", structured: { text: "first" }, content: [{ type: "text", text: "first" }] },
state: { status: "completed", content: [{ type: "text", text: "first" }] },
},
],
},
@@ -2707,11 +2740,7 @@ describe("SessionRunnerLLM", () => {
{
type: "tool",
id: "tool_0",
state: {
status: "completed",
structured: { text: "second" },
content: [{ type: "text", text: "second" }],
},
state: { status: "completed", content: [{ type: "text", text: "second" }] },
},
],
},
@@ -3404,7 +3433,7 @@ describe("SessionRunnerLLM", () => {
expect((yield* recordedStepSettlementEvents(sessionID, assistant.id)).map((event) => event.type)).toEqual([
"session.step.started.1",
"session.tool.called.1",
"session.tool.failed.1",
"session.tool.failed.2",
"session.step.ended.1",
])
}),
@@ -3414,17 +3443,20 @@ describe("SessionRunnerLLM", () => {
Effect.gen(function* () {
const session = yield* setup
const registry = yield* ToolRegistry.Service
yield* registry.register({
blocked: Tool.make({
description: "Fail because policy blocked execution",
input: Schema.Struct({}),
output: Schema.Struct({}),
execute: () =>
Effect.fail(new PermissionV2.BlockedError({ rules: [], permission: "blocked", resources: ["*"] })).pipe(
Effect.mapError(() => new Tool.Failure({ message: "Permission blocked" })),
),
}),
}, { codemode: false })
yield* registry.register(
{
blocked: Tool.make({
description: "Fail because policy blocked execution",
input: Schema.Struct({}),
output: Schema.Struct({}),
execute: () =>
Effect.fail(new PermissionV2.BlockedError({ rules: [], permission: "blocked", resources: ["*"] })).pipe(
Effect.mapError(() => new Tool.Failure({ message: "Permission blocked" })),
),
}),
},
{ codemode: false },
)
yield* admit(session, "Call blocked")
responses = [reply.tool("call-blocked", "blocked", {}), reply.stop()]
@@ -3449,14 +3481,17 @@ describe("SessionRunnerLLM", () => {
Effect.gen(function* () {
const session = yield* setup
const registry = yield* ToolRegistry.Service
yield* registry.register({
declined: Tool.make({
description: "Fail because the user declined approval",
input: Schema.Struct({}),
output: Schema.Struct({}),
execute: () => Effect.die(new PermissionV2.DeclinedError()),
}),
}, { codemode: false })
yield* registry.register(
{
declined: Tool.make({
description: "Fail because the user declined approval",
input: Schema.Struct({}),
output: Schema.Struct({}),
execute: () => Effect.die(new PermissionV2.DeclinedError()),
}),
},
{ codemode: false },
)
yield* admit(session, "Call declined")
response = reply.tool("call-declined", "declined", {})
@@ -3486,17 +3521,20 @@ describe("SessionRunnerLLM", () => {
Effect.gen(function* () {
const session = yield* setup
const registry = yield* ToolRegistry.Service
yield* registry.register({
corrected: Tool.make({
description: "Fail with user correction feedback",
input: Schema.Struct({}),
output: Schema.Struct({}),
execute: () =>
Effect.fail(new PermissionV2.CorrectedError({ feedback: "Use another tool" })).pipe(
Effect.mapError(() => new Tool.Failure({ message: "Use another tool" })),
),
}),
}, { codemode: false })
yield* registry.register(
{
corrected: Tool.make({
description: "Fail with user correction feedback",
input: Schema.Struct({}),
output: Schema.Struct({}),
execute: () =>
Effect.fail(new PermissionV2.CorrectedError({ feedback: "Use another tool" })).pipe(
Effect.mapError(() => new Tool.Failure({ message: "Use another tool" })),
),
}),
},
{ codemode: false },
)
yield* admit(session, "Call corrected")
responses = [reply.tool("call-corrected", "corrected", {}), reply.stop()]
@@ -3540,13 +3578,13 @@ describe("SessionRunnerLLM", () => {
status: "error",
error: {
type: "unknown",
message: expect.stringContaining("Failed to encode tool output"),
message: expect.stringContaining("Failed to write tool output"),
},
},
},
],
finish: "error",
error: { type: "unknown", message: expect.stringContaining("Failed to encode tool output") },
error: { type: "unknown", message: expect.stringContaining("Failed to write tool output") },
},
])
}),
@@ -3594,14 +3632,17 @@ describe("SessionRunnerLLM", () => {
Effect.gen(function* () {
const session = yield* setup
const registry = yield* ToolRegistry.Service
yield* registry.register({
question: Tool.make({
description: "Ask the user",
input: Schema.Struct({}),
output: Schema.Struct({}),
execute: () => Effect.die(new QuestionTool.CancelledError()),
}),
}, { codemode: false })
yield* registry.register(
{
question: Tool.make({
description: "Ask the user",
input: Schema.Struct({}),
output: Schema.Struct({}),
execute: () => Effect.die(new QuestionTool.CancelledError()),
}),
},
{ codemode: false },
)
yield* admit(session, "Ask then stop")
responses = [reply.tool("call-question", "question", {}), []]
@@ -3655,7 +3696,11 @@ describe("SessionRunnerLLM", () => {
{
type: "assistant",
content: [
{ type: "tool", id: "call-before-failure", state: { status: "completed", structured: { text: "settle" } } },
{
type: "tool",
id: "call-before-failure",
state: { status: "completed", content: [{ type: "text", text: "settle" }] },
},
],
},
])
@@ -3663,7 +3708,7 @@ describe("SessionRunnerLLM", () => {
expect((yield* recordedStepSettlementEvents(sessionID, assistant.id)).map((event) => event.type)).toEqual([
"session.step.started.1",
"session.tool.called.1",
"session.tool.success.1",
"session.tool.success.2",
"session.step.failed.1",
])
}),
@@ -3707,7 +3752,7 @@ describe("SessionRunnerLLM", () => {
expect((yield* recordedStepSettlementEvents(sessionID, assistant.id)).map((event) => event.type)).toEqual([
"session.step.started.1",
"session.tool.called.1",
"session.tool.failed.1",
"session.tool.failed.2",
"session.step.failed.1",
])
@@ -3808,7 +3853,8 @@ describe("SessionRunnerLLM", () => {
expect(requests).toHaveLength(2)
expect(requests[0]?.toolChoice).toBeUndefined()
expect(requests[1]?.toolChoice).toMatchObject({ type: "none" })
expect(requests[1]?.tools).toEqual([])
// The final step keeps tool definitions to preserve provider prompt caching.
expect(requests[1]?.tools.map((tool) => tool.name)).toContain("echo")
expect(requests[1]?.messages.at(-1)).toMatchObject({
role: "assistant",
content: [{ type: "text", text: expect.stringContaining("MAXIMUM STEPS REACHED") }],
@@ -3953,7 +3999,7 @@ describe("SessionRunnerLLM", () => {
expect(events.map((event) => event.type)).toEqual([
"session.step.started.1",
"session.tool.called.1",
"session.tool.success.1",
"session.tool.success.2",
"session.step.failed.1",
])
expect(
@@ -4146,7 +4192,8 @@ describe("SessionRunnerLLM", () => {
content: [{ type: "text", text: expect.stringContaining("MAXIMUM STEPS REACHED") }],
})
expect(requests[2]?.toolChoice).toMatchObject({ type: "none" })
expect(requests[2]?.tools).toEqual([])
// The final step keeps tool definitions to preserve provider prompt caching.
expect(requests[2]?.tools.map((tool) => tool.name)).toContain("echo")
expect(requests[2]?.messages.at(-1)).toMatchObject({
role: "assistant",
content: [{ type: "text", text: expect.stringContaining("MAXIMUM STEPS REACHED") }],
@@ -4197,7 +4244,7 @@ describe("SessionRunnerLLM", () => {
expect(yield* recordedStepSettlementEvents(sessionID, assistant.id)).toMatchObject([
{ type: "session.step.started.1" },
{
type: "session.tool.failed.1",
type: "session.tool.failed.2",
data: {
callID: "call-malformed",
error: { type: "provider.invalid-output", message: "Invalid JSON input for tool call echo" },
@@ -4292,7 +4339,7 @@ describe("SessionRunnerLLM", () => {
expect(failed.error).toBeUndefined()
expect((yield* recordedStepSettlementEvents(sessionID, failed.id)).map((event) => event.type)).toEqual([
"session.step.started.1",
"session.tool.failed.1",
"session.tool.failed.2",
"session.step.ended.1",
])
const database = (yield* Database.Service).db
@@ -4521,7 +4568,7 @@ describe("SessionRunnerLLM", () => {
expect(requests).toHaveLength(2)
expect(requests[0]?.toolChoice).toBeUndefined()
expect(requests[1]?.toolChoice).toMatchObject({ type: "none" })
expect((yield* recordedEventTypes(sessionID)).filter((type) => type === "session.tool.failed.1")).toHaveLength(2)
expect((yield* recordedEventTypes(sessionID)).filter((type) => type === "session.tool.failed.2")).toHaveLength(2)
}),
)
@@ -4553,7 +4600,7 @@ describe("SessionRunnerLLM", () => {
expect((yield* recordedStepSettlementEvents(sessionID, assistant.id)).map((event) => event.type)).toEqual([
"session.step.started.1",
"session.tool.called.1",
"session.tool.success.1",
"session.tool.success.2",
"session.step.failed.1",
])
}),
@@ -4585,7 +4632,7 @@ describe("SessionRunnerLLM", () => {
expect((yield* recordedStepSettlementEvents(sessionID, assistant.id)).map((event) => event.type)).toEqual([
"session.step.started.1",
"session.tool.called.1",
"session.tool.failed.1",
"session.tool.failed.2",
"session.step.failed.1",
])
}),
@@ -4609,7 +4656,7 @@ describe("SessionRunnerLLM", () => {
expect(events.map((event) => event.type)).toEqual([
"session.step.started.1",
"session.tool.called.1",
"session.tool.failed.1",
"session.tool.failed.2",
"session.step.failed.1",
])
expect(events[2]?.data.error).toMatchObject({ type: "unknown", message: "unexpected tool defect" })
@@ -4646,7 +4693,7 @@ describe("SessionRunnerLLM", () => {
expect(events.map((event) => event.type)).toEqual([
"session.step.started.1",
"session.tool.called.1",
"session.tool.failed.1",
"session.tool.failed.2",
"session.step.failed.1",
])
expect(
@@ -4684,7 +4731,7 @@ describe("SessionRunnerLLM", () => {
expect(events.map((event) => event.type)).toEqual([
"session.step.started.1",
"session.tool.called.1",
"session.tool.failed.1",
"session.tool.failed.2",
"session.step.ended.1",
])
expect(
@@ -4721,8 +4768,8 @@ describe("SessionRunnerLLM", () => {
{ type: "session.step.started.1", callID: undefined },
{ type: "session.tool.called.1", callID: "call-local-raw-failure" },
{ type: "session.tool.called.1", callID: "call-hosted-raw-failure-pair" },
{ type: "session.tool.failed.1", callID: "call-local-raw-failure" },
{ type: "session.tool.failed.1", callID: "call-hosted-raw-failure-pair" },
{ type: "session.tool.failed.2", callID: "call-local-raw-failure" },
{ type: "session.tool.failed.2", callID: "call-hosted-raw-failure-pair" },
{ type: "session.step.failed.1", callID: undefined },
])
expect(
@@ -4748,7 +4795,7 @@ describe("SessionRunnerLLM", () => {
expect(events.map((event) => event.type)).toEqual([
"session.step.started.1",
"session.tool.called.1",
"session.tool.failed.1",
"session.tool.failed.2",
"session.step.failed.1",
])
expect(
@@ -84,30 +84,30 @@ describe("Tool.Progress", () => {
yield* start("call-success")
expect((yield* readAssistant).content[0]).toMatchObject({
state: { status: "running", structured: {}, content: [] },
state: { status: "running", metadata: {}, content: [] },
})
const progress = yield* service.publish(SessionEvent.Tool.Progress, {
sessionID,
assistantMessageID,
callID: "call-success",
structured: { phase: "checkpoint" },
metadata: { phase: "checkpoint" },
content: content("saved"),
})
expect((yield* readAssistant).content[0]).toMatchObject({
state: { status: "running", structured: {}, content: [] },
state: { status: "running", metadata: {}, content: [] },
})
const success = yield* service.publish(SessionEvent.Tool.Success, {
sessionID,
assistantMessageID,
callID: "call-success",
structured: { phase: "done" },
metadata: { phase: "done" },
content: content("complete"),
executed: false,
})
expect((yield* readAssistant).content[0]).toMatchObject({
state: { status: "completed", structured: { phase: "done" }, content: content("complete") },
state: { status: "completed", metadata: { phase: "done" }, content: content("complete") },
})
yield* start("call-failed")
@@ -115,7 +115,7 @@ describe("Tool.Progress", () => {
sessionID,
assistantMessageID,
callID: "call-failed",
structured: { phase: "checkpoint" },
metadata: { phase: "checkpoint" },
content: content("before failure"),
})
const failed = yield* service.publish(SessionEvent.Tool.Failed, {
@@ -130,7 +130,7 @@ describe("Tool.Progress", () => {
expect((yield* readAssistant).content[1]).toMatchObject({
state: {
status: "error",
structured: { phase: "checkpoint" },
metadata: { phase: "checkpoint" },
content: content("before failure"),
error: { type: "unknown", message: "boom" },
},
@@ -147,8 +147,8 @@ describe("Tool.Progress", () => {
.all()
.pipe(Effect.orDie)
expect(rows.map((row) => row.type)).not.toContain(EventV2.versionedType(SessionEvent.Tool.Progress.type, 1))
expect(rows.map((row) => row.type)).toContain(EventV2.versionedType(SessionEvent.Tool.Success.type, 1))
expect(rows.map((row) => row.type)).toContain(EventV2.versionedType(SessionEvent.Tool.Failed.type, 1))
expect(rows.map((row) => row.type)).toContain(EventV2.versionedType(SessionEvent.Tool.Success.type, 2))
expect(rows.map((row) => row.type)).toContain(EventV2.versionedType(SessionEvent.Tool.Failed.type, 2))
}),
)
})
+56 -29
View File
@@ -18,7 +18,7 @@ import { location } from "./fixture/location"
import { tmpdir } from "./fixture/tmpdir"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { testEffect } from "./lib/effect"
import { toolIdentity, executeTool, registerToolPlugin, settleTool, toolDefinitions } from "./lib/tool"
import { toolIdentity, executeTool, registerToolPlugin, toolDefinitions } from "./lib/tool"
const editToolNode = makeLocationNode({
name: "test/edit-tool-plugin",
@@ -141,15 +141,20 @@ describe("EditTool", () => {
expect(yield* toolDefinitions(registry, [{ action: "edit", resource: "*", effect: "deny" }])).toEqual(
[],
)
const settled = yield* settleTool(
const settled = yield* executeTool(
registry,
call({ path: "hello.txt", oldString: "before", newString: "after" }),
)
expect(settled.result).toEqual({
type: "text",
value: "Edited file successfully: hello.txt\nReplacements: 1\n```diff\n-before\n+after\n```",
})
expect(settled.output?.structured).toEqual({
expect(settled.status).toBe("completed")
if (settled.status !== "completed") return
expect(settled.content).toEqual([
{
type: "text",
text: "Edited file successfully: hello.txt\nReplacements: 1\n```diff\n-before\n+after\n```",
},
])
expect(settled.metadata).toBeUndefined()
expect(settled.output).toEqual({
replacements: 1,
files: [
{
@@ -187,7 +192,7 @@ describe("EditTool", () => {
),
Effect.andThen((result) =>
Effect.gen(function* () {
expect(result.type).toBe("text")
expect(result.status).toBe("completed")
expect(assertions.map((input) => input.action)).toEqual(["edit"])
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("after")
}),
@@ -217,7 +222,7 @@ describe("EditTool", () => {
),
Effect.andThen((result) =>
Effect.sync(() => {
expect(result.type).toBe("text")
expect(result.status).toBe("completed")
expect(assertions.map((input) => input.action)).toEqual(["edit"])
expect(assertions[0]?.resources).toEqual(["link.txt"])
}),
@@ -247,7 +252,7 @@ describe("EditTool", () => {
),
Effect.andThen((result) =>
Effect.gen(function* () {
expect(result.type).toBe("text")
expect(result.status).toBe("completed")
expect(assertions.map((input) => input.action)).toEqual(["external_directory", "edit"])
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("after")
expect(writes).toHaveLength(1)
@@ -276,8 +281,8 @@ describe("EditTool", () => {
executeTool(registry, call({ path: external, oldString: "before", newString: "after" })),
),
).toEqual({
type: "error",
value: `Unable to edit ${external}`,
status: "error",
error: { type: "permission.rejected", message: "Permission denied: external_directory" },
})
expect(assertions.map((input) => input.action)).toEqual(["external_directory"])
expect(reads).toBe(0)
@@ -290,8 +295,8 @@ describe("EditTool", () => {
executeTool(registry, call({ path: external, oldString: "before", newString: "after" })),
),
).toEqual({
type: "error",
value: `Unable to edit ${external}`,
status: "error",
error: { type: "permission.rejected", message: "Permission denied: edit" },
})
expect(assertions.map((input) => input.action)).toEqual(["external_directory", "edit"])
expect(reads).toBe(0)
@@ -325,7 +330,10 @@ describe("EditTool", () => {
call({ path: "secret.txt", oldString: "not present", newString: "replacement" }),
)
expect(matching).toEqual({ type: "error", value: "Unable to edit secret.txt" })
expect(matching).toEqual({
status: "error",
error: { type: "permission.rejected", message: "Permission denied: edit" },
})
expect(missing).toEqual(matching)
expect(assertions.map((input) => input.action)).toEqual(["edit", "edit"])
expect(reads).toBe(0)
@@ -352,28 +360,40 @@ describe("EditTool", () => {
expect(
yield* executeTool(registry, call({ path: "matches.txt", oldString: "same", newString: "same" })),
).toEqual({
type: "error",
value: "No changes to apply: oldString and newString are identical.",
status: "error",
error: {
type: "tool.execution",
message: "No changes to apply: oldString and newString are identical.",
},
})
expect(
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.",
status: "error",
error: {
type: "tool.execution",
message: "oldString must not be empty. Use write to create or overwrite a file.",
},
})
expect(
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.",
status: "error",
error: {
type: "tool.execution",
message:
"Could not find oldString in the file. It must match exactly, including whitespace and indentation.",
},
})
expect(
yield* executeTool(registry, call({ path: "matches.txt", oldString: "same", newString: "after" })),
).toEqual({
type: "error",
value:
"Found multiple exact matches for oldString. Provide more surrounding context or set replaceAll to true.",
status: "error",
error: {
type: "tool.execution",
message:
"Found multiple exact matches for oldString. Provide more surrounding context or set replaceAll to true.",
},
})
expect(writes).toEqual([])
}),
@@ -394,12 +414,14 @@ describe("EditTool", () => {
return Effect.promise(() => fs.writeFile(target, "same same same")).pipe(
Effect.andThen(
withTool(tmp.path, (registry) =>
settleTool(registry, call({ path: "all.txt", oldString: "same", newString: "after", replaceAll: true })),
executeTool(registry, call({ path: "all.txt", oldString: "same", newString: "after", replaceAll: true })),
),
),
Effect.andThen((settled) =>
Effect.gen(function* () {
expect(settled.output?.structured).toMatchObject({ replacements: 3 })
expect(settled.status).toBe("completed")
if (settled.status !== "completed") return
expect(settled.output).toMatchObject({ replacements: 3 })
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("after after after")
expect(writes).toHaveLength(1)
}),
@@ -445,9 +467,14 @@ describe("EditTool", () => {
),
Effect.andThen((result) =>
Effect.gen(function* () {
// The message-less StaleContentError cause must not erase the tool's
// curated failure message; the canonical error is the sole authority.
expect(result).toEqual({
type: "error",
value: "File changed after permission approval. Read it again before editing.",
status: "error",
error: {
type: "tool.execution",
message: "File changed after permission approval. Read it again before editing.",
},
})
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("newer\n")
expect(writes).toEqual([])
+6 -16
View File
@@ -15,14 +15,9 @@ test("execute preserves successful results with visible unhandled rejections", a
})
const execute = ExecuteTool.create(new Map([["fail", { tool: child, name: "fail" }]]))
const result = await Effect.runPromise(
Tool.settle(
Tool.execute(
execute,
{
type: "tool-call",
id: "call_execute",
name: "execute",
input: { code: `tools.fail({}); return "done"` },
},
{ code: `tools.fail({}); return "done"` },
{
sessionID: Session.ID.make("ses_execute"),
agent: Agent.ID.make("build"),
@@ -33,7 +28,7 @@ test("execute preserves successful results with visible unhandled rejections", a
),
)
expect(result.structured).toEqual({ toolCalls: [{ tool: "fail", status: "error" }] })
expect(result.metadata).toEqual({ toolCalls: [{ tool: "fail", status: "error" }] })
expect(result.content).toEqual([
{
type: "text",
@@ -67,14 +62,9 @@ test("execute supports callable namespace tools", async () => {
]),
)
const result = await Effect.runPromise(
Tool.settle(
Tool.execute(
execute,
{
type: "tool-call",
id: "call_execute",
name: "execute",
input: { code: "return [await tools.slack.admin({}), await tools.slack.admin.create({})]" },
},
{ code: "return [await tools.slack.admin({}), await tools.slack.admin.create({})]" },
{
sessionID: Session.ID.make("ses_execute"),
agent: Agent.ID.make("build"),
@@ -85,7 +75,7 @@ test("execute supports callable namespace tools", async () => {
),
)
expect(result.structured).toEqual({
expect(result.metadata).toEqual({
toolCalls: [
{ tool: "slack.admin", status: "completed" },
{ tool: "slack.admin.create", status: "completed" },
+21 -55
View File
@@ -53,52 +53,31 @@ describe("ToolOutputStore", () => {
const result = yield* store.bound({
sessionID,
callID: "call-aggregate",
output: {
structured: { kind: "report" },
content: [
{ type: "text", text: first },
{ type: "text", text: second },
],
},
content: [
{ type: "text", text: first },
{ type: "text", text: second },
],
})
expect(result.output.structured).toEqual({ kind: "report" })
expect(result.outputPaths).toHaveLength(1)
expect(yield* fs.readFileString(result.outputPaths[0])).toBe(first + 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)
if (result.content[0]?.type !== "text") throw new Error("expected text preview")
expect(Buffer.byteLength(result.content[0].text)).toBeLessThanOrEqual(ToolOutputStore.MAX_BYTES)
}),
),
)
it.live("uses bounded text for oversized structured-only output", () =>
withStore(({ store, fs }) =>
Effect.gen(function* () {
const structured = { text: "x".repeat(ToolOutputStore.MAX_BYTES) }
const result = yield* store.bound({ sessionID, callID: "call-json", output: { structured, content: [] } })
expect(result.output.structured).toEqual(structured)
expect(result.outputPaths).toHaveLength(1)
expect(JSON.parse(yield* fs.readFileString(result.outputPaths[0]))).toEqual(structured)
expect(result.output.content).toHaveLength(1)
}),
),
)
it.live("preserves native media and structured metadata without applying a settlement media limit", () =>
it.live("preserves native media without applying an execution media limit", () =>
withStore(({ store }) =>
Effect.gen(function* () {
const data = "a".repeat(6 * 1024 * 1024)
const result = yield* store.bound({
sessionID,
callID: "call-file",
output: {
structured: { caption: "pixel" },
content: [{ type: "file", uri: `data:image/png;base64,${data}`, mime: "image/png", name: "pixel.png" }],
},
content: [{ type: "file", uri: `data:image/png;base64,${data}`, mime: "image/png", name: "pixel.png" }],
})
expect(result.outputPaths).toEqual([])
expect(result.output.structured).toEqual({ caption: "pixel" })
expect(result.output.content).toHaveLength(1)
expect(result.output.content[0]).toEqual({
expect(result.content).toHaveLength(1)
expect(result.content[0]).toEqual({
type: "file",
uri: `data:image/png;base64,${data}`,
mime: "image/png",
@@ -108,7 +87,7 @@ describe("ToolOutputStore", () => {
),
)
it.live("preserves structured metadata and native media when bounding text", () =>
it.live("preserves native media when bounding text", () =>
withStore(({ store, fs }) =>
Effect.gen(function* () {
const text = "x".repeat(ToolOutputStore.MAX_BYTES + 1)
@@ -121,30 +100,29 @@ describe("ToolOutputStore", () => {
const result = yield* store.bound({
sessionID,
callID: "call-text-and-media",
output: { structured: { caption: "pixel" }, content: [{ type: "text", text }, media] },
content: [{ type: "text", text }, media],
})
expect(result.output.structured).toEqual({ caption: "pixel" })
expect(result.output.content[1]).toEqual(media)
expect(result.content[1]).toEqual(media)
expect(yield* fs.readFileString(result.outputPaths[0])).toBe(text)
}),
),
)
it.live("does not double-count structured data duplicated in projected text", () =>
it.live("returns content within the limits unchanged", () =>
withStore(({ store }) =>
Effect.gen(function* () {
const text = "x".repeat(30_000)
const output = { structured: { output: text }, content: [{ type: "text" as const, text }] }
expect(yield* store.bound({ sessionID, callID: "call-duplicated", output })).toEqual({
output,
const content = [{ type: "text" as const, text }]
expect(yield* store.bound({ sessionID, callID: "call-duplicated", content })).toEqual({
content,
outputPaths: [],
})
}),
),
)
it.live("fails oversized settlement when complete retention cannot be written", () =>
it.live("fails oversized execution when complete retention cannot be written", () =>
withStore(({ root, store, fs }) =>
Effect.gen(function* () {
yield* fs.writeFileString(path.join(root, "tool-output"), "not a directory")
@@ -152,7 +130,7 @@ describe("ToolOutputStore", () => {
.bound({
sessionID,
callID: "call-lossy",
output: { structured: {}, content: [{ type: "text", text: "x".repeat(ToolOutputStore.MAX_BYTES + 1) }] },
content: [{ type: "text", text: "x".repeat(ToolOutputStore.MAX_BYTES + 1) }],
})
.pipe(Effect.exit)
expect(Exit.isFailure(exit)).toBe(true)
@@ -162,18 +140,6 @@ describe("ToolOutputStore", () => {
),
)
it.live("does not encode ignored structured metadata when projected content exists", () =>
withStore(({ store }) =>
Effect.gen(function* () {
const output = { structured: { value: 1n }, content: [{ type: "text" as const, text: "readable text" }] }
expect(yield* store.bound({ sessionID, callID: "call-unencodable", output })).toEqual({
output,
outputPaths: [],
})
}),
),
)
it.live("preserves interruption while retaining complete output", () =>
Effect.gen(function* () {
const root = yield* Effect.promise(() => tmpdir())
@@ -198,7 +164,7 @@ describe("ToolOutputStore", () => {
.bound({
sessionID,
callID: "call-interrupted",
output: { structured: {}, content: [{ type: "text", text: "x".repeat(ToolOutputStore.MAX_BYTES + 1) }] },
content: [{ type: "text", text: "x".repeat(ToolOutputStore.MAX_BYTES + 1) }],
})
.pipe(Effect.forkChild)
yield* Fiber.interrupt(fiber)
@@ -217,7 +183,7 @@ describe("ToolOutputStore", () => {
const result = yield* store.bound({
sessionID,
callID: "call-config",
output: { structured: {}, content: [{ type: "text", text: "one\ntwo\nthree" }] },
content: [{ type: "text", text: "one\ntwo\nthree" }],
})
expect(result.outputPaths).toHaveLength(1)
}),
+92 -77
View File
@@ -16,7 +16,7 @@ import { location } from "./fixture/location"
import { tmpdir } from "./fixture/tmpdir"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { testEffect } from "./lib/effect"
import { toolIdentity, executeTool, registerToolPlugin, settleTool, toolDefinitions } from "./lib/tool"
import { toolIdentity, executeTool, registerToolPlugin, toolDefinitions } from "./lib/tool"
const patchToolNode = makeLocationNode({
name: "test/patch-tool-plugin",
@@ -96,29 +96,19 @@ const withTool = <A, E, R>(
const activeLocation = Layer.succeed(
Location.Service,
Location.Service.of(
location(
{ directory: AbsolutePath.make(directory) },
{ projectDirectory: AbsolutePath.make(projectDirectory) },
),
location({ directory: AbsolutePath.make(directory) }, { projectDirectory: AbsolutePath.make(projectDirectory) }),
),
)
return Effect.gen(function* () {
return yield* body(yield* ToolRegistry.Service)
}).pipe(
Effect.provide(
AppNodeBuilder.build(
LayerNode.group([
ToolRegistry.node,
ToolRegistry.toolsNode,
patchToolNode,
]),
[
[FSUtil.node, filesystem],
[Location.node, activeLocation],
[PermissionV2.node, permission],
[ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig],
],
),
AppNodeBuilder.build(LayerNode.group([ToolRegistry.node, ToolRegistry.toolsNode, patchToolNode]), [
[FSUtil.node, filesystem],
[Location.node, activeLocation],
[PermissionV2.node, permission],
[ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig],
]),
),
)
}
@@ -162,18 +152,23 @@ describe("PatchTool", () => {
withTool(tmp.path, (registry) =>
Effect.gen(function* () {
expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["patch"])
const settled = yield* settleTool(
const settled = yield* executeTool(
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",
),
)
expect(settled.result).toEqual({
type: "text",
value: "Success. Updated the following files:\nA nested/new.txt\nM update.txt\nD remove.txt",
})
if (process.platform === "win32") expect(settled.result.value).not.toContain("\\")
expect(settled.output?.structured).toMatchObject({
expect(settled.status).toBe("completed")
if (settled.status !== "completed") return
expect(settled.content).toEqual([
{
type: "text",
text: "Success. Updated the following files:\nA nested/new.txt\nM update.txt\nD remove.txt",
},
])
const modelText = settled.content[0]?.type === "text" ? settled.content[0].text : ""
if (process.platform === "win32") expect(modelText).not.toContain("\\")
expect(settled.output).toMatchObject({
applied: [
{ type: "add", resource: "nested/new.txt" },
{ type: "update", resource: "update.txt" },
@@ -248,9 +243,11 @@ describe("PatchTool", () => {
"*** 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",
),
),
).toEqual({
type: "text",
value: "Success. Updated the following files:\nA created.txt\nM moved.txt",
).toMatchObject({
status: "completed",
content: [
{ type: "text", text: "Success. Updated the following files:\nA created.txt\nM moved.txt" },
],
})
expect(yield* exists(source)).toBe(false)
expect(yield* Effect.promise(() => fs.readFile(path.join(tmp.path, "moved.txt"), "utf8"))).toBe(
@@ -278,7 +275,9 @@ describe("PatchTool", () => {
return Effect.promise(() =>
Promise.all([
fs.writeFile(source, "before\n"),
fs.mkdir(path.dirname(destination), { recursive: true }).then(() => fs.writeFile(destination, "existing\n")),
fs
.mkdir(path.dirname(destination), { recursive: true })
.then(() => fs.writeFile(destination, "existing\n")),
]),
).pipe(
Effect.andThen(
@@ -291,7 +290,7 @@ describe("PatchTool", () => {
"*** Begin Patch\n*** Update File: old.txt\n*** Move to: nested/moved.txt\n@@\n-before\n+after\n*** End Patch",
),
),
).toMatchObject({ type: "text" })
).toMatchObject({ status: "completed" })
expect(yield* exists(source)).toBe(false)
expect(yield* Effect.promise(() => fs.readFile(destination, "utf8"))).toBe("after\n")
}),
@@ -331,13 +330,15 @@ describe("PatchTool", () => {
const source = path.join(directory, "old", "name.txt")
yield* Effect.promise(() => fs.mkdir(path.dirname(source), { recursive: true }))
yield* Effect.promise(() => fs.writeFile(source, "old content\n"))
const settled = yield* settleTool(
const settled = yield* executeTool(
registry,
call(
"*** Begin Patch\n*** Update File: old/name.txt\n*** Move to: renamed/dir/name.txt\n@@\n-old content\n+new content\n*** End Patch",
),
)
expect(settled.output?.structured).toMatchObject({
expect(settled.status).toBe("completed")
if (settled.status !== "completed") return
expect(settled.output).toMatchObject({
applied: [{ type: "update", resource: "renamed/dir/name.txt" }],
files: [
{
@@ -393,7 +394,7 @@ describe("PatchTool", () => {
yield* Effect.promise(() => fs.mkdir(path.join(directory, "dir")))
expect(
yield* executeTool(registry, call("*** Begin Patch\n*** Delete File: dir\n*** End Patch")),
).toMatchObject({ type: "error" })
).toMatchObject({ status: "error" })
expect(yield* exists(path.join(directory, "dir"))).toBe(true)
}),
),
@@ -407,11 +408,9 @@ describe("PatchTool", () => {
expect(
yield* executeTool(
registry,
call(
"*** Begin Patch\n*** Update File: two-chunks.txt\n@@\n-b\n+B\n\n-d\n+D\n*** End Patch",
),
call("*** Begin Patch\n*** Update File: two-chunks.txt\n@@\n-b\n+B\n\n-d\n+D\n*** End Patch"),
),
).toMatchObject({ type: "error" })
).toMatchObject({ status: "error" })
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("a\nb\nc\nd\n")
}),
),
@@ -420,7 +419,10 @@ describe("PatchTool", () => {
it.live("requires patchText", () =>
withTempTool((_directory, registry) =>
Effect.gen(function* () {
expect(yield* executeTool(registry, call(""))).toEqual({ type: "error", value: "patchText is required" })
expect(yield* executeTool(registry, call(""))).toEqual({
status: "error",
error: { type: "tool.execution", message: "patchText is required" },
})
}),
),
)
@@ -429,12 +431,18 @@ describe("PatchTool", () => {
withTempTool((_directory, registry) =>
Effect.gen(function* () {
expect(yield* executeTool(registry, call("invalid patch"))).toEqual({
type: "error",
value: "patch verification failed: The first line of the patch must be '*** Begin Patch'",
status: "error",
error: {
type: "tool.execution",
message: "patch verification failed: The first line of the patch must be '*** Begin Patch'",
},
})
expect(yield* executeTool(registry, call("*** Begin Patch\n*** Add File: foo\n+hello"))).toEqual({
type: "error",
value: "patch verification failed: The last line of the patch must be '*** End Patch'",
status: "error",
error: {
type: "tool.execution",
message: "patch verification failed: The last line of the patch must be '*** End Patch'",
},
})
}),
),
@@ -444,8 +452,8 @@ describe("PatchTool", () => {
withTempTool((_directory, registry) =>
Effect.gen(function* () {
expect(yield* executeTool(registry, call("*** Begin Patch\n*** End Patch"))).toEqual({
type: "error",
value: "patch rejected: empty patch",
status: "error",
error: { type: "tool.execution", message: "patch rejected: empty patch" },
})
}),
),
@@ -454,15 +462,13 @@ describe("PatchTool", () => {
it.live("rejects an invalid hunk header", () =>
withTempTool((_directory, registry) =>
Effect.gen(function* () {
expect(
yield* executeTool(
registry,
call("*** Begin Patch\n*** Frobnicate File: foo\n*** End Patch"),
),
).toEqual({
type: "error",
value:
"patch verification failed: Invalid hunk at line 2: '*** Frobnicate File: foo' is not a valid hunk header. Valid hunk headers: '*** Add File: {path}', '*** Delete File: {path}', '*** Update File: {path}'",
expect(yield* executeTool(registry, call("*** Begin Patch\n*** Frobnicate File: foo\n*** End Patch"))).toEqual({
status: "error",
error: {
type: "tool.execution",
message:
"patch verification failed: Invalid hunk at line 2: '*** Frobnicate File: foo' is not a valid hunk header. Valid hunk headers: '*** Add File: {path}', '*** Delete File: {path}', '*** Update File: {path}'",
},
})
}),
),
@@ -490,13 +496,13 @@ describe("PatchTool", () => {
const bom = "\uFEFF"
const target = path.join(directory, "example.cs")
yield* Effect.promise(() => fs.writeFile(target, `${bom}using System;\n\nclass Test {}\n`))
const settled = yield* settleTool(
const settled = yield* executeTool(
registry,
call(
"*** Begin Patch\n*** Update File: example.cs\n@@\n class Test {}\n+class Next {}\n*** End Patch",
),
call("*** Begin Patch\n*** Update File: example.cs\n@@\n class Test {}\n+class Next {}\n*** End Patch"),
)
const output = Schema.decodeUnknownSync(PatchTool.Output)(settled.output?.structured)
expect(settled.status).toBe("completed")
if (settled.status !== "completed") return
const output = Schema.decodeUnknownSync(PatchTool.Output)(settled.output)
expect(output.files[0]?.patch).not.toContain(bom)
expect(output.files[0]?.patch).not.toContain("-using System;")
expect(output.files[0]?.patch).not.toContain("+using System;")
@@ -517,7 +523,10 @@ describe("PatchTool", () => {
registry,
call("*** Begin Patch\n*** Update File: unchanged.txt\n@@\n-missing\n+changed\n*** End Patch"),
),
).toMatchObject({ type: "error", value: expect.stringContaining("Failed to find expected lines") })
).toMatchObject({
status: "error",
error: { message: expect.stringContaining("Failed to find expected lines") },
})
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("line1\nline2\n")
}),
),
@@ -532,10 +541,12 @@ describe("PatchTool", () => {
call("*** Begin Patch\n*** Update File: missing.txt\n@@\n-old\n+new\n*** End Patch"),
),
).toMatchObject({
type: "error",
value: expect.stringContaining(
`patch verification failed: Failed to read file to update ${path.join(directory, "missing.txt")}: `,
),
status: "error",
error: {
message: expect.stringContaining(
`patch verification failed: Failed to read file to update ${path.join(directory, "missing.txt")}: `,
),
},
})
}),
),
@@ -548,8 +559,11 @@ describe("PatchTool", () => {
expect(
yield* executeTool(registry, call("*** Begin Patch\n*** Update File: nested\n@@\n-old\n+new\n*** End Patch")),
).toEqual({
type: "error",
value: `patch verification failed: Failed to read file to update ${path.join(directory, "nested")}: path is a directory`,
status: "error",
error: {
type: "tool.execution",
message: `patch verification failed: Failed to read file to update ${path.join(directory, "nested")}: path is a directory`,
},
})
}),
),
@@ -560,7 +574,7 @@ describe("PatchTool", () => {
Effect.gen(function* () {
expect(
yield* executeTool(registry, call("*** Begin Patch\n*** Delete File: missing.txt\n*** End Patch")),
).toMatchObject({ type: "error", value: expect.stringContaining("patch verification failed") })
).toMatchObject({ status: "error", error: { message: expect.stringContaining("patch verification failed") } })
}),
),
)
@@ -580,7 +594,7 @@ describe("PatchTool", () => {
registry,
call(`*** Begin Patch\n*** Update File: ${target}\n@@\n-before\n+after\n*** End Patch`),
),
).toMatchObject({ type: "text" })
).toMatchObject({ status: "completed" })
expect(assertions.map((input) => input.action)).toEqual(["external_directory", "edit"])
expect(readsBeforeEditApproval).toBe(1)
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("after\n")
@@ -614,7 +628,7 @@ describe("PatchTool", () => {
registry,
call(`*** Begin Patch\n*** Update File: ${target}\n@@\n-before\n+after\n*** End Patch`),
),
).toMatchObject({ type: "error" })
).toMatchObject({ status: "error" })
expect(assertions.map((input) => input.action)).toEqual(["external_directory"])
expect(readsBeforeEditApproval).toBe(0)
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("before\n")
@@ -649,7 +663,7 @@ describe("PatchTool", () => {
registry,
call("*** Begin Patch\n*** Update File: ../sibling.txt\n@@\n-before\n+after\n*** End Patch"),
),
).toMatchObject({ type: "text" })
).toMatchObject({ status: "completed" })
expect(assertions.map((input) => input.action)).toEqual(["edit"])
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("after\n")
}),
@@ -680,7 +694,7 @@ describe("PatchTool", () => {
registry,
call("*** Begin Patch\n*** Update File: link.txt\n@@\n-before\n+after\n*** End Patch"),
),
).toMatchObject({ type: "text" })
).toMatchObject({ status: "completed" })
expect(assertions.map((input) => input.action)).toEqual(["edit"])
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("after\n")
}),
@@ -711,7 +725,7 @@ describe("PatchTool", () => {
registry,
call(`*** Begin Patch\n*** Update File: ${relative}\n@@\n-before\n+after\n*** End Patch`),
),
).toMatchObject({ type: "text" })
).toMatchObject({ status: "completed" })
expect(assertions.map((input) => input.action)).toEqual(["external_directory", "edit"])
expect(readsBeforeEditApproval).toBe(1)
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("after\n")
@@ -747,7 +761,7 @@ describe("PatchTool", () => {
`*** Begin Patch\n*** Update File: ${first}\n@@\n-before\n+after\n*** Update File: ${second}\n@@\n-before\n+after\n*** End Patch`,
),
),
).toMatchObject({ type: "text" })
).toMatchObject({ status: "completed" })
expect(assertions.map((input) => input.action)).toEqual([
"external_directory",
"external_directory",
@@ -786,8 +800,10 @@ describe("PatchTool", () => {
),
),
).toMatchObject({
type: "error",
value: expect.stringContaining("patch verification failed: Failed to read file to update"),
status: "error",
error: {
message: expect.stringContaining("patch verification failed: Failed to read file to update"),
},
})
expect(yield* exists(path.join(tmp.path, "created.txt"))).toBe(false)
}),
@@ -812,7 +828,7 @@ describe("PatchTool", () => {
registry,
call("*** Begin Patch\n*** Add File: existing.txt\n+replacement\n*** End Patch"),
),
).toMatchObject({ type: "text" })
).toMatchObject({ status: "completed" })
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("replacement\n")
}),
),
@@ -837,7 +853,7 @@ describe("PatchTool", () => {
registry,
call("*** Begin Patch\n*** Add File: appeared.txt\n+replacement\n*** End Patch"),
),
).toMatchObject({ type: "text" })
).toMatchObject({ status: "completed" })
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("replacement\n")
}),
)
@@ -876,5 +892,4 @@ describe("PatchTool", () => {
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
),
)
})
+12 -18
View File
@@ -12,7 +12,7 @@ import { Image } from "@opencode-ai/core/image"
import { testEffect } from "./lib/effect"
import { imagePassthrough } from "./lib/image"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { toolIdentity, executeTool, registerToolPlugin, settleTool, toolDefinitions } from "./lib/tool"
import { toolIdentity, executeTool, registerToolPlugin, toolDefinitions } from "./lib/tool"
const sessionID = SessionV2.ID.make("ses_question_tool_test")
const assertions: PermissionV2.AssertInput[] = []
@@ -99,13 +99,13 @@ describe("QuestionTool", () => {
expect(yield* toolDefinitions(registry, [{ action: "question", resource: "*", effect: "deny" }])).toEqual([])
expect(
yield* settleTool(registry, {
yield* executeTool(registry, {
sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-question-denied", name: "question", input: questionInput },
}),
).toEqual({
result: { type: "error", value: "Permission denied: question" },
status: "error",
error: {
type: "permission.rejected",
message: "Permission denied: question",
@@ -144,26 +144,20 @@ describe("QuestionTool", () => {
expect((yield* toolDefinitions(registry)).map((definition) => definition.name)).toEqual(["question"])
expect(
yield* settleTool(registry, {
yield* executeTool(registry, {
sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-question", name: "question", input: { questions } },
}),
).toEqual({
result: {
type: "text",
value:
'User has answered your questions: "What should happen?"="Build", "Which environment?"="Dev", "Anything else?"="Unanswered". You can now continue with the user\'s answers in mind.',
},
output: {
structured: { answers: [["Build"], ["Dev"], []] },
content: [
{
type: "text",
text: 'User has answered your questions: "What should happen?"="Build", "Which environment?"="Dev", "Anything else?"="Unanswered". You can now continue with the user\'s answers in mind.',
},
],
},
status: "completed",
output: { answers: [["Build"], ["Dev"], []] },
content: [
{
type: "text",
text: 'User has answered your questions: "What should happen?"="Build", "Which environment?"="Dev", "Anything else?"="Unanswered". You can now continue with the user\'s answers in mind.',
},
],
})
expect(assertions).toMatchObject([{ sessionID, action: "question", resources: ["*"] }])
expect(capturedInput()).toEqual({
+67 -74
View File
@@ -22,7 +22,7 @@ import { ReadToolFileSystem } from "@opencode-ai/core/tool/read-filesystem"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { SessionInstructions } from "@opencode-ai/core/session/instructions"
import { testEffect } from "./lib/effect"
import { toolIdentity, executeTool, registerToolPlugin, settleTool, toolDefinitions } from "./lib/tool"
import { toolIdentity, executeTool, registerToolPlugin, toolDefinitions } from "./lib/tool"
const readToolNode = makeLocationNode({
name: "test/read-tool-plugin",
@@ -199,21 +199,19 @@ describe("ReadTool", () => {
expect(yield* toolDefinitions(registry)).toMatchObject([{ name: "read" }])
expect(yield* toolDefinitions(registry, [{ action: "read", resource: "*", effect: "deny" }])).toEqual([])
expect(
yield* executeTool(registry, {
sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-read", name: "read", input: { path: "README.md" } },
}),
).toEqual({
type: "json",
value: {
uri: "file:///README.md",
name: "README.md",
content: "hello",
encoding: "utf8",
mime: "text/plain",
},
const execution = yield* executeTool(registry, {
sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-read", name: "read", input: { path: "README.md" } },
})
expect(execution.status).toBe("completed")
if (execution.status !== "completed") return
expect(execution.output).toEqual({
uri: "file:///README.md",
name: "README.md",
content: "hello",
encoding: "utf8",
mime: "text/plain",
})
expect(assertions).toMatchObject([{ sessionID, action: "read", resources: ["README.md"], save: ["*"] }])
expect(readCalls).toEqual([
@@ -236,7 +234,7 @@ describe("ReadTool", () => {
...toolIdentity,
call: { type: "tool-call", id: "call-external-read", name: "read", input: { path: external } },
}),
).toMatchObject({ type: "json" })
).toMatchObject({ status: "completed" })
expect(assertions).toMatchObject([
{
sessionID,
@@ -261,19 +259,17 @@ describe("ReadTool", () => {
}
const registry = yield* ToolRegistry.Service
expect(
yield* executeTool(registry, {
sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-image", name: "read", input: { path: "pixel.png" } },
}),
).toEqual({
type: "content",
value: [
{ type: "text", text: "Image read successfully" },
{ type: "file", uri: `data:image/png;base64,${png}`, mime: "image/png", name: "pixel.png" },
],
const execution = yield* executeTool(registry, {
sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-image", name: "read", input: { path: "pixel.png" } },
})
expect(execution.status).toBe("completed")
if (execution.status !== "completed") return
expect(execution.content).toEqual([
{ type: "text", text: "Image read successfully" },
{ type: "file", uri: `data:image/png;base64,${png}`, mime: "image/png", name: "pixel.png" },
])
expect(readCalls).toEqual([
{
input: AbsolutePath.make(path.join(process.cwd(), "pixel.png")),
@@ -281,21 +277,17 @@ describe("ReadTool", () => {
},
])
const settled = yield* settleTool(registry, {
const settled = yield* executeTool(registry, {
sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-image-settle", name: "read", input: { path: "pixel.png" } },
})
expect(settled.output?.structured).toMatchObject({
uri: "file:///pixel.png",
name: "pixel.png",
mime: "image/png",
encoding: "base64",
// Image base64 is carried by the content file item only; structured is slimmed
// so the original bytes are never persisted twice.
content: "",
})
expect(settled.output?.content).toMatchObject([
expect(settled.status).toBe("completed")
if (settled.status !== "completed") return
// Image base64 is carried by the content file item only; read produces no
// metadata, so the original bytes are never persisted twice.
expect(settled.metadata).toBeUndefined()
expect(settled.content).toMatchObject([
{ type: "text", text: "Image read successfully" },
{ type: "file", mime: "image/png", uri: `data:image/png;base64,${png}` },
])
@@ -319,26 +311,25 @@ describe("ReadTool", () => {
}
const registry = yield* ToolRegistry.Service
const settled = yield* settleTool(registry, {
const settled = yield* executeTool(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).toMatchObject({
expect(settled.status).toBe("completed")
if (settled.status !== "completed") return
expect(settled.output).toMatchObject({
uri: "file:///large.png",
name: "large.png",
mime: "image/png",
encoding: "base64",
})
expect(settled.result).toEqual({
type: "content",
value: [
{ type: "text", text: "Image read successfully" },
{ type: "file", uri: `data:image/png;base64,${png}`, mime: "image/png", name: "large.png" },
],
})
expect(settled.content).toEqual([
{ type: "text", text: "Image read successfully" },
{ type: "file", uri: `data:image/png;base64,${png}`, mime: "image/png", name: "large.png" },
])
}),
)
@@ -361,8 +352,8 @@ describe("ReadTool", () => {
call: { type: "tool-call", id: "call-image-fallback", name: "read", input: { path: "pixel.png" } },
}),
).toMatchObject({
type: "content",
value: [{ type: "text" }, { type: "file", uri: `data:image/png;base64,${png}`, mime: "image/png" }],
status: "completed",
content: [{ type: "text" }, { type: "file", uri: `data:image/png;base64,${png}`, mime: "image/png" }],
})
}),
)
@@ -384,9 +375,9 @@ describe("ReadTool", () => {
...toolIdentity,
call: { type: "tool-call", id: "call-truncated-image", name: "read", input: { path: "truncated.png" } },
}),
).toEqual({
type: "content",
value: [
).toMatchObject({
status: "completed",
content: [
{ type: "text", text: "Image read successfully" },
{ type: "text", text: "[1 image omitted: could not be decoded.]" },
],
@@ -425,9 +416,9 @@ describe("ReadTool", () => {
...toolIdentity,
call: { type: "tool-call", id: "call-wide-image", name: "read", input: { path: "wide.png" } },
}),
).toEqual({
type: "content",
value: [
).toMatchObject({
status: "completed",
content: [
{ type: "text", text: "Image read successfully" },
{ type: "text", text: "[1 image omitted: could not be resized below the image size limit.]" },
],
@@ -463,9 +454,9 @@ describe("ReadTool", () => {
call: { type: "tool-call", id: "call-resize-image", name: "read", input: { path: "wide.png" } },
})
expect(result.type).toBe("content")
if (result.type !== "content") return
const media = result.value[1]
expect(result.status).toBe("completed")
if (result.status !== "completed") return
const media = result.content[1]
expect(media?.type).toBe("file")
if (media?.type !== "file") return
const resized = photon.PhotonImage.new_from_byteslice(Buffer.from(media.uri.split(",")[1] ?? "", "base64"))
@@ -503,9 +494,9 @@ describe("ReadTool", () => {
...toolIdentity,
call: { type: "tool-call", id: "call-max-bytes", name: "read", input: { path: "pixel.png" } },
}),
).toEqual({
type: "content",
value: [
).toMatchObject({
status: "completed",
content: [
{ type: "text", text: "Image read successfully" },
{ type: "text", text: "[1 image omitted: could not be resized below the image size limit.]" },
],
@@ -532,8 +523,8 @@ describe("ReadTool", () => {
call: { type: "tool-call", id: "call-disguised-image", name: "read", input: { path: "pixel.bin" } },
}),
).toMatchObject({
type: "content",
value: [{ type: "text" }, { type: "file", mime: "image/png", name: "pixel.bin" }],
status: "completed",
content: [{ type: "text" }, { type: "file", mime: "image/png", name: "pixel.bin" }],
})
}),
)
@@ -554,7 +545,7 @@ describe("ReadTool", () => {
input: { path: "archive.dat", offset: 2, limit: 1 },
},
}),
).toEqual({ type: "error", value: "Cannot read binary file: archive.dat" })
).toEqual({ status: "error", error: { type: "unknown", message: "Cannot read binary file: archive.dat" } })
expect(readCalls).toEqual([
{ input: AbsolutePath.make(path.join(process.cwd(), "archive.dat")), page: { offset: 2, limit: 1 } },
])
@@ -589,7 +580,7 @@ describe("ReadTool", () => {
...toolIdentity,
call: { type: "tool-call", id: "call-read", name: "read", input: { path: "README.md" } },
}),
).toEqual({ type: "error", value: "Unable to read README.md" })
).toEqual({ status: "error", error: { type: "permission.rejected", message: "Permission denied: read" } })
expect(readCalls).toEqual([])
}),
)
@@ -604,7 +595,9 @@ describe("ReadTool", () => {
...toolIdentity,
call: { type: "tool-call", id: "call-missing-path", name: "read", input: { path: missingPath } },
}),
).toEqual({ type: "error", value: `Unable to read ${missingPath}` })
// The message-less PathError cause must not erase the tool's curated
// failure message; the canonical error is the sole authority.
).toEqual({ status: "error", error: { type: "tool.execution", message: `Unable to read ${missingPath}` } })
expect(assertions).toEqual([])
expect(readCalls).toEqual([])
}),
@@ -626,7 +619,7 @@ describe("ReadTool", () => {
input: { path: "src", offset: 2, limit: 10 },
},
}),
).toEqual({ type: "json", value: { entries: [], truncated: false } })
).toMatchObject({ status: "completed", output: { entries: [], truncated: false } })
expect(assertions).toMatchObject([{ sessionID, action: "read", resources: ["src"], save: ["*"] }])
expect(listCalls).toEqual([{ offset: 2, limit: 10 }])
}),
@@ -644,7 +637,7 @@ describe("ReadTool", () => {
...toolIdentity,
call: { type: "tool-call", id: "call-read-directory-denied", name: "read", input: { path: "src" } },
}),
).toEqual({ type: "error", value: "Unable to read src" })
).toEqual({ status: "error", error: { type: "permission.rejected", message: "Permission denied: read" } })
expect(listCalls).toEqual([])
}),
)
@@ -691,9 +684,9 @@ describe("ReadTool", () => {
input: { path: "large.txt", offset: 2, limit: 1 },
},
}),
).toEqual({
type: "json",
value: { type: "text-page", content: "hello", mime: "text/plain", offset: 2, truncated: true, next: 3 },
).toMatchObject({
status: "completed",
output: { type: "text-page", content: "hello", mime: "text/plain", offset: 2, truncated: true, next: 3 },
})
expect(readCalls).toEqual([
{ input: AbsolutePath.make(path.join(process.cwd(), "large.txt")), page: { offset: 2, limit: 1 } },
@@ -718,7 +711,7 @@ describe("ReadTool", () => {
...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" })
).toEqual({ status: "error", error: { type: "unknown", message: "Cannot read binary file: late-binary" } })
}),
)
})
+15 -10
View File
@@ -19,7 +19,7 @@ import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
import { location } from "./fixture/location"
import { tmpdir } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect"
import { executeTool, registerToolPlugin, settleTool, toolIdentity } from "./lib/tool"
import { executeTool, registerToolPlugin, toolIdentity } from "./lib/tool"
const globToolNode = makeLocationNode({
name: "test/glob-tool-plugin",
@@ -83,15 +83,17 @@ describe("search tools", () => {
)
yield* withTools(tmp.path, (registry) =>
Effect.gen(function* () {
const glob = yield* settleTool(registry, call("glob", { pattern: "*" }))
const grep = yield* settleTool(registry, call("grep", { pattern: "needle" }))
const glob = yield* executeTool(registry, call("glob", { pattern: "*" }))
const grep = yield* executeTool(registry, call("grep", { pattern: "needle" }))
expect(glob.output?.structured).toEqual({ count: FileSystem.DEFAULT_SEARCH_LIMIT })
expect(grep.output?.structured).toEqual({ matches: FileSystem.DEFAULT_SEARCH_LIMIT })
expect(glob.output?.content).toEqual([{ type: "text", text: String(glob.result.value) }])
expect(grep.output?.content).toEqual([{ type: "text", text: String(grep.result.value) }])
expect(String(glob.result.value).split("\n")).toHaveLength(FileSystem.DEFAULT_SEARCH_LIMIT)
expect(grep.result.value).toStartWith(`Found ${FileSystem.DEFAULT_SEARCH_LIMIT} matches\n`)
expect(glob.metadata).toEqual({ count: FileSystem.DEFAULT_SEARCH_LIMIT })
expect(grep.metadata).toEqual({ matches: FileSystem.DEFAULT_SEARCH_LIMIT })
expect(glob.content).toHaveLength(1)
expect(grep.content).toHaveLength(1)
const globText = glob.content?.[0]?.type === "text" ? glob.content[0].text : ""
const grepText = grep.content?.[0]?.type === "text" ? grep.content[0].text : ""
expect(globText.split("\n")).toHaveLength(FileSystem.DEFAULT_SEARCH_LIMIT)
expect(grepText).toStartWith(`Found ${FileSystem.DEFAULT_SEARCH_LIMIT} matches\n`)
}),
)
}),
@@ -110,7 +112,10 @@ describe("search tools", () => {
registry,
call(name, { path: "missing", pattern: name === "glob" ? "*" : "needle" }),
)
expect(result).toEqual({ type: "error", value: "Search path does not exist: missing" })
expect(result).toEqual({
status: "error",
error: { type: "tool.execution", message: "Search path does not exist: missing" },
})
}),
),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
+51 -47
View File
@@ -33,7 +33,7 @@ import { ToolRegistry } from "@opencode-ai/core/tool/registry"
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
import { tmpdir } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect"
import { toolIdentity, executeTool, settleTool, toolDefinitions, waitForTool } from "./lib/tool"
import { toolIdentity, executeTool, toolDefinitions, waitForTool } from "./lib/tool"
const sessionID = SessionV2.ID.make("ses_shell_tool_test")
const sessionModel = ModelV2.Ref.make({ id: ModelV2.ID.make("test"), providerID: ProviderV2.ID.make("test") })
@@ -204,17 +204,19 @@ describe("ShellTool", () => {
const definitions = yield* toolDefinitions(registry)
const shell = definitions.find((tool) => tool.name === "shell")
expect(shell).toBeDefined()
expect(shell?.outputSchema).not.toHaveProperty("properties.output")
// Code Mode receives the declared output schema, including the command output text.
expect(shell?.outputSchema).toHaveProperty("properties.output")
expect(
(yield* toolDefinitions(registry, [{ action: "shell", resource: "*", effect: "deny" }])).map(
(tool) => tool.name,
),
).not.toContain("shell")
const settled = yield* settleTool(registry, call({ command: helloCommand }))
expect(settled.output?.structured).toMatchObject({ exit: 0, truncated: false })
expect(settled.output?.content[0]).toEqual({ type: "text", text: "hello" })
expect(settled.output?.content[1]).toMatchObject({
const settled = yield* executeTool(registry, call({ command: helloCommand }))
expect(settled.status).toBe("completed")
expect(settled.metadata).toMatchObject({ exit: 0, truncated: false })
expect(settled.content?.[0]).toEqual({ type: "text", text: "hello" })
expect(settled.content?.[1]).toMatchObject({
type: "text",
text: expect.stringContaining("Command exited with code 0."),
})
@@ -233,11 +235,11 @@ describe("ShellTool", () => {
reset()
return Effect.promise(() => fs.mkdir(path.join(tmp.path, "src"))).pipe(
Effect.andThen(
withSession(tmp.path, (registry) => settleTool(registry, call({ command: cwdCommand, workdir: "src" }))),
withSession(tmp.path, (registry) => executeTool(registry, call({ command: cwdCommand, workdir: "src" }))),
),
Effect.andThen((settled) =>
Effect.sync(() =>
expect(settled.output?.content[0]).toMatchObject({
expect(settled.content?.[0]).toMatchObject({
type: "text",
text: expect.stringContaining(realpathSync(path.join(tmp.path, "src"))),
}),
@@ -256,13 +258,13 @@ describe("ShellTool", () => {
reset()
return withSession(tmp.path, (registry) =>
Effect.gen(function* () {
const stderr = yield* settleTool(registry, call({ command: stderrCommand }, "call-stderr"))
expect(stderr.output?.structured).toMatchObject({ exit: 0, truncated: false })
expect(stderr.output?.content[0]).toEqual({ type: "text", text: "stderr only" })
const stderr = yield* executeTool(registry, call({ command: stderrCommand }, "call-stderr"))
expect(stderr.metadata).toMatchObject({ exit: 0, truncated: false })
expect(stderr.content?.[0]).toEqual({ type: "text", text: "stderr only" })
const mixed = yield* settleTool(registry, call({ command: mixedOutputCommand }, "call-mixed"))
expect(mixed.output?.structured).toMatchObject({ exit: 0, truncated: false })
const output = mixed.output?.content[0]?.type === "text" ? mixed.output.content[0].text : ""
const mixed = yield* executeTool(registry, call({ command: mixedOutputCommand }, "call-mixed"))
expect(mixed.metadata).toMatchObject({ exit: 0, truncated: false })
const output = mixed.content?.[0]?.type === "text" ? mixed.content[0].text : ""
expect(output).toContain("stdout")
expect(output).toContain("stderr")
}),
@@ -352,12 +354,12 @@ describe("ShellTool", () => {
reset()
denyAction = "external_directory"
const target = path.join(outside.path, "secret.txt")
return withSession(active.path, (registry) => settleTool(registry, call({ command: `cat ${target}` }))).pipe(
return withSession(active.path, (registry) => executeTool(registry, call({ command: `cat ${target}` }))).pipe(
Effect.andThen((settled) =>
Effect.sync(() => {
expect(assertions.map((item) => item.action)).toEqual(["shell"])
expect(settled.output?.structured).not.toHaveProperty("warnings")
expect(settled.output?.content[1]).toMatchObject({
expect(settled.metadata).not.toHaveProperty("warnings")
expect(settled.content?.[1]).toMatchObject({
type: "text",
text: expect.stringContaining("Warnings:"),
})
@@ -378,13 +380,14 @@ describe("ShellTool", () => {
(tmp) => {
reset()
return withSession(tmp.path, (registry) =>
settleTool(registry, call({ command: bodyExitCommand }, "call-nonzero")),
executeTool(registry, call({ command: bodyExitCommand }, "call-nonzero")),
).pipe(
Effect.andThen((settled) =>
Effect.sync(() => {
expect(settled.output?.structured).toMatchObject({ exit: 7, truncated: false })
expect(settled.output?.content[0]).toEqual({ type: "text", text: "body" })
expect(settled.output?.content[1]).toMatchObject({
expect(settled.status).toBe("completed")
expect(settled.metadata).toMatchObject({ exit: 7, truncated: false })
expect(settled.content?.[0]).toEqual({ type: "text", text: "body" })
expect(settled.content?.[1]).toMatchObject({
type: "text",
text: expect.stringContaining("Command exited with code 7"),
})
@@ -403,12 +406,12 @@ describe("ShellTool", () => {
reset()
const bytes = ShellTool.MAX_CAPTURE_BYTES + 1024
return withSession(tmp.path, (registry) =>
settleTool(registry, call({ command: overflowCommand(bytes) }, "call-overflow")),
executeTool(registry, call({ command: overflowCommand(bytes) }, "call-overflow")),
).pipe(
Effect.andThen((settled) =>
Effect.sync(() => {
expect(settled.output?.structured).toMatchObject({ exit: 0, truncated: true })
expect(settled.output?.content[0]).toMatchObject({
expect(settled.metadata).toMatchObject({ exit: 0, truncated: true })
expect(settled.content?.[0]).toMatchObject({
type: "text",
text: expect.stringContaining("output truncated; full output saved to:"),
})
@@ -432,17 +435,20 @@ describe("ShellTool", () => {
return withSession(tmp.path, (registry) =>
Effect.gen(function* () {
const observed = yield* Deferred.make<ToolRegistry.Progress>()
yield* settleTool(registry, {
yield* executeTool(registry, {
...call(
{ command: progressOverflowCommand(ShellTool.MAX_CAPTURE_BYTES + 1024, release) },
"call-progress",
),
progress: (update) =>
Effect.gen(function* () {
if (update.structured.truncated !== true) return
if (update.metadata.truncated !== true) return
const content = update.content[0]
if (content?.type !== "text") return
if (content.text.indexOf("\n\n[output truncated; full output saved to:") !== ShellTool.MAX_CAPTURE_BYTES)
if (
content.text.indexOf("\n\n[output truncated; full output saved to:") !==
ShellTool.MAX_CAPTURE_BYTES
)
return
yield* Deferred.succeed(observed, update)
yield* Effect.promise(() => fs.writeFile(releasePath, ""))
@@ -450,7 +456,7 @@ describe("ShellTool", () => {
})
const progress = yield* Deferred.await(observed)
expect(progress.structured).toEqual({ truncated: true })
expect(progress.metadata).toEqual({ truncated: true })
const content = progress.content[0]
expect(content?.type).toBe("text")
if (content?.type !== "text") return
@@ -475,13 +481,13 @@ describe("ShellTool", () => {
return withSession(tmp.path, (registry) =>
Effect.gen(function* () {
const updates: ToolRegistry.Progress[] = []
yield* settleTool(registry, {
yield* executeTool(registry, {
...call({ command: steadyProgressCommand }, "call-steady-progress"),
progress: (update) => Effect.sync(() => updates.push(update)),
})
expect(updates).toEqual([
{
structured: { truncated: false },
metadata: { truncated: false },
content: [{ type: "text", text: "steady" }],
},
])
@@ -499,12 +505,12 @@ describe("ShellTool", () => {
(tmp) => {
reset()
return withSession(tmp.path, (registry) =>
settleTool(registry, call({ command: idleCommand, timeout: 50 })),
executeTool(registry, call({ command: idleCommand, timeout: 50 })),
).pipe(
Effect.andThen((settled) =>
Effect.sync(() => {
expect(settled.output?.structured).toMatchObject({ timeout: true, truncated: false })
expect(settled.output?.content[1]).toMatchObject({
expect(settled.metadata).toMatchObject({ timeout: true, truncated: false })
expect(settled.content?.[1]).toMatchObject({
type: "text",
text: expect.stringContaining("Command timed out"),
})
@@ -529,10 +535,9 @@ describe("ShellTool", () => {
Stream.runHead,
Effect.forkScoped({ startImmediately: true }),
)
const settled = yield* settleTool(registry, call({ command: idleCommand, timeout: 50, background: true }))
const structured = settled.output?.structured as Record<string, unknown> | undefined
const shellID = typeof structured?.shellID === "string" ? structured.shellID : undefined
expect(settled.output?.structured).toMatchObject({ truncated: false })
const settled = yield* executeTool(registry, call({ command: idleCommand, timeout: 50, background: true }))
const shellID = typeof settled.metadata?.shellID === "string" ? settled.metadata.shellID : undefined
expect(settled.metadata).toMatchObject({ truncated: false })
expect(shellID).toStartWith("sh_")
const shell = yield* Shell.Service
@@ -562,22 +567,22 @@ describe("ShellTool", () => {
return withSession(tmp.path, (registry) =>
Effect.gen(function* () {
const shell = yield* Shell.Service
const timed = yield* settleTool(
const timed = yield* executeTool(
registry,
call({ command: idleCommand, background: true }, "call-updated-timeout"),
)
const timedID = (timed.output?.structured as Record<string, unknown> | undefined)?.shellID
const timedID = timed.metadata?.shellID
expect(typeof timedID).toBe("string")
if (typeof timedID !== "string") return
const timedShellID = ShellSchema.ID.make(timedID)
yield* shell.timeout(timedShellID, 50)
expect((yield* shell.wait(timedShellID)).status).toBe("timeout")
const cleared = yield* settleTool(
const cleared = yield* executeTool(
registry,
call({ command: idleCommand, timeout: 50, background: true }, "call-cleared-timeout"),
)
const clearedID = (cleared.output?.structured as Record<string, unknown> | undefined)?.shellID
const clearedID = cleared.metadata?.shellID
expect(typeof clearedID).toBe("string")
if (typeof clearedID !== "string") return
const clearedShellID = ShellSchema.ID.make(clearedID)
@@ -601,7 +606,7 @@ describe("ShellTool", () => {
Effect.gen(function* () {
const jobs = yield* Job.Service
const scope = yield* Scope.Scope
const waiting = yield* settleTool(
const waiting = yield* executeTool(
registry,
call({ command: idleCommand, timeout: 50 }, "call-background-signal"),
).pipe(Effect.forkIn(scope, { startImmediately: true }))
@@ -616,14 +621,13 @@ describe("ShellTool", () => {
})
expect(yield* backgroundWhenReady()).toMatchObject([{ id: "call-background-signal", type: "shell" }])
const settled = yield* Fiber.join(waiting)
const structured = settled.output?.structured as Record<string, unknown> | undefined
const shellID = typeof structured?.shellID === "string" ? structured.shellID : undefined
expect(settled.output?.structured).toMatchObject({ truncated: false })
expect(settled.output?.content[0]).toEqual({
const shellID = typeof settled.metadata?.shellID === "string" ? settled.metadata.shellID : undefined
expect(settled.metadata).toMatchObject({ truncated: false })
expect(settled.content?.[0]).toEqual({
type: "text",
text: "The command was moved to the background.",
})
expect(settled.output?.content[1]).toMatchObject({
expect(settled.content?.[1]).toMatchObject({
type: "text",
text: expect.stringContaining("DO NOT sleep, poll"),
})
+21 -13
View File
@@ -17,7 +17,7 @@ import { it } from "./lib/effect"
import { imagePassthrough } from "./lib/image"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { toolIdentity, executeTool, registerToolPlugin, settleTool, toolDefinitions } from "./lib/tool"
import { toolIdentity, executeTool, registerToolPlugin, toolDefinitions } from "./lib/tool"
const skillToolNode = makeLocationNode({
name: "test/skill-tool-plugin",
@@ -108,23 +108,22 @@ describe("SkillTool", () => {
...toolIdentity,
call: { type: "tool-call", id: "call-skill", name: "skill", input: { id: "effect" } },
}),
).toEqual({
type: "text",
value: SkillTool.toModelOutput(info, [reference]),
).toMatchObject({
status: "completed",
content: [{ type: "text", text: SkillTool.toModelOutput(info, [reference]) }],
})
expect(SkillTool.toModelOutput(info, [reference])).toContain(`Base directory for this skill: ${directory}`)
expect(
yield* settleTool(registry, {
yield* executeTool(registry, {
sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-skill-overflow", name: "skill", input: { id: "effect" } },
}),
).toEqual({
result: { type: "text", value: SkillTool.toModelOutput(info, [reference]) },
output: {
structured: { name: "Effect", directory },
content: [{ type: "text", text: SkillTool.toModelOutput(info, [reference]) }],
},
status: "completed",
output: { name: "Effect", directory, output: SkillTool.toModelOutput(info, [reference]) },
content: [{ type: "text", text: SkillTool.toModelOutput(info, [reference]) }],
metadata: { name: "Effect", directory },
})
expect(assertions).toMatchObject([
{ sessionID, action: "skill", resources: ["effect"], save: ["effect"] },
@@ -136,7 +135,10 @@ describe("SkillTool", () => {
...toolIdentity,
call: { type: "tool-call", id: "call-missing-skill", name: "skill", input: { id: "missing" } },
}),
).toEqual({ type: "error", value: "Unable to load skill missing" })
).toEqual({
status: "error",
error: { type: "tool.execution", message: "Unable to load skill missing" },
})
deny = true
expect(
yield* executeTool(registry, {
@@ -144,7 +146,10 @@ describe("SkillTool", () => {
...toolIdentity,
call: { type: "tool-call", id: "call-denied-skill", name: "skill", input: { id: "effect" } },
}),
).toEqual({ type: "error", value: "Unable to load skill effect" })
).toEqual({
status: "error",
error: { type: "permission.rejected", message: "Permission denied: skill" },
})
deny = false
const flat = SkillV2.Info.make({
id: SkillV2.ID.make("public"),
@@ -166,7 +171,10 @@ describe("SkillTool", () => {
...toolIdentity,
call: { type: "tool-call", id: "call-flat-skill", name: "skill", input: { id: "public" } },
}),
).toEqual({ type: "text", value: SkillTool.toModelOutput(flat, []) })
).toMatchObject({
status: "completed",
content: [{ type: "text", text: SkillTool.toModelOutput(flat, []) }],
})
}).pipe(Effect.provide(skillToolLayer))
}),
),
+41 -31
View File
@@ -28,7 +28,7 @@ import { ToolRegistry } from "@opencode-ai/core/tool/registry"
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
import { tmpdir } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect"
import { executeTool, settleTool, toolIdentity, waitForTool } from "./lib/tool"
import { executeTool, toolIdentity, waitForTool } from "./lib/tool"
const childText = "child final response"
const childModel = ModelV2.Ref.make({ id: ModelV2.ID.make("child"), providerID: ProviderV2.ID.make("test") })
@@ -148,7 +148,7 @@ describe("SubagentTool", () => {
const locations = yield* LocationServiceMap.Service
const registry = yield* ToolRegistry.Service.pipe(Effect.provide(locations.get(parent.location)))
yield* waitForTool(registry, SubagentTool.name)
expect((yield* registry.materialize()).definitions.map((tool) => tool.name)).toContain(SubagentTool.name)
expect((yield* registry.snapshot()).definitions.map((tool) => tool.name)).toContain(SubagentTool.name)
expect(
yield* executeTool(registry, {
sessionID: parent.id,
@@ -160,7 +160,10 @@ describe("SubagentTool", () => {
input: { agent: "primary", description: "primary", prompt: "should fail" },
},
}),
).toEqual({ type: "error", value: "Agent primary cannot run as a subagent" })
).toEqual({
status: "error",
error: { type: "tool.execution", message: "Agent primary cannot run as a subagent" },
})
}),
),
),
@@ -193,7 +196,13 @@ describe("SubagentTool", () => {
input: { agent: "reviewer", description: "nested", prompt: "should fail" },
},
}),
).toEqual({ type: "error", value: expect.stringContaining("Subagent depth limit reached (1)") })
).toEqual({
status: "error",
error: {
type: "tool.execution",
message: expect.stringContaining("Subagent depth limit reached (1)"),
},
})
expect((yield* sessions.list({ parentID: parent.id })).data).toHaveLength(0)
}),
),
@@ -219,7 +228,7 @@ describe("SubagentTool", () => {
const registry = yield* ToolRegistry.Service.pipe(Effect.provide(locations.get(parent.location)))
yield* waitForTool(registry, SubagentTool.name)
const settled = yield* settleTool(registry, {
const settled = yield* executeTool(registry, {
sessionID: parent.id,
...toolIdentity,
call: {
@@ -231,17 +240,15 @@ describe("SubagentTool", () => {
})
expect(settled).toMatchObject({
result: { type: "text", value: childText },
output: {
structured: { status: "completed" },
content: [{ type: "text", text: childText }],
},
status: "completed",
metadata: { status: "completed" },
content: [{ type: "text", text: childText }],
})
expect(settled.output?.structured).toEqual({
sessionID: outputSessionID(settled.output?.structured),
expect(settled.metadata).toEqual({
sessionID: outputSessionID(settled.metadata),
status: "completed",
})
expect((yield* sessions.get(outputSessionID(settled.output?.structured))).parentID).toBe(parent.id)
expect((yield* sessions.get(outputSessionID(settled.metadata))).parentID).toBe(parent.id)
}),
),
),
@@ -263,7 +270,7 @@ describe("SubagentTool", () => {
yield* waitForTool(registry, SubagentTool.name)
const progress: ToolRegistry.Progress[] = []
const settled = yield* settleTool(registry, {
const settled = yield* executeTool(registry, {
sessionID: parent.id,
...toolIdentity,
progress: (update) => Effect.sync(() => progress.push(update)),
@@ -276,15 +283,13 @@ describe("SubagentTool", () => {
})
expect(settled).toMatchObject({
result: { type: "text", value: childText },
output: {
structured: { status: "completed" },
content: [{ type: "text", text: childText }],
},
status: "completed",
metadata: { status: "completed" },
content: [{ type: "text", text: childText }],
})
const child = yield* sessions.get(outputSessionID(settled.output?.structured))
expect(settled.output?.structured).toEqual({ sessionID: child.id, status: "completed" })
expect(progress[0]?.structured).toEqual({ sessionID: child.id, status: "running" })
const child = yield* sessions.get(outputSessionID(settled.metadata))
expect(settled.metadata).toEqual({ sessionID: child.id, status: "completed" })
expect(progress[0]?.metadata).toEqual({ sessionID: child.id, status: "running" })
expect(child).toMatchObject({
parentID: parent.id,
location: parent.location,
@@ -295,7 +300,7 @@ describe("SubagentTool", () => {
"You are a subagent spawned by another session.\nreview this",
)
const fallback = yield* settleTool(registry, {
const fallback = yield* executeTool(registry, {
sessionID: parent.id,
...toolIdentity,
call: {
@@ -305,7 +310,7 @@ describe("SubagentTool", () => {
input: { agent: "fallback", description: "fallback", prompt: "fallback" },
},
})
const fallbackChild = yield* sessions.get(outputSessionID(fallback.output?.structured))
const fallbackChild = yield* sessions.get(outputSessionID(fallback.metadata))
expect(fallbackChild).toMatchObject({ parentID: parent.id, model: parentModel })
}),
),
@@ -338,7 +343,13 @@ describe("SubagentTool", () => {
input: { agent: "reviewer", description: "fail review", prompt: "please fail" },
},
}),
).toEqual({ type: "error", value: expect.stringContaining("No model is available for session") })
).toEqual({
status: "error",
error: {
type: "tool.execution",
message: expect.stringContaining("No model is available for session"),
},
})
}),
),
),
@@ -366,7 +377,7 @@ describe("SubagentTool", () => {
Effect.forkScoped({ startImmediately: true }),
)
const settled = yield* settleTool(registry, {
const settled = yield* executeTool(registry, {
sessionID: parent.id,
...toolIdentity,
call: {
@@ -376,13 +387,12 @@ describe("SubagentTool", () => {
input: { agent: "reviewer", description: "background review", prompt: "review", background: true },
},
})
const childID = outputSessionID(settled.output?.structured)
expect(settled.output?.structured).toMatchObject({
const childID = outputSessionID(settled.metadata)
expect(settled.metadata).toMatchObject({
status: "running",
})
expect(settled.output?.structured).toEqual({ sessionID: childID, status: "running" })
expect(settled.result).toEqual({ type: "text", value: expect.stringContaining(`id: ${childID}`) })
expect(settled.output?.content).toEqual([{ type: "text", text: expect.stringContaining(`id: ${childID}`) }])
expect(settled.metadata).toEqual({ sessionID: childID, status: "running" })
expect(settled.content).toEqual([{ type: "text", text: expect.stringContaining(`id: ${childID}`) }])
const admission = Array.from(yield* Fiber.join(admitted))[0]
expect(admission?.data.input.data.text).toContain(`<subagent id="${childID}" state="completed"`)
+45 -36
View File
@@ -14,7 +14,7 @@ import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { Image } from "@opencode-ai/core/image"
import { testEffect } from "./lib/effect"
import { imagePassthrough } from "./lib/image"
import { toolIdentity, executeTool, registerToolPlugin, settleTool, toolDefinitions } from "./lib/tool"
import { toolIdentity, executeTool, registerToolPlugin, toolDefinitions } from "./lib/tool"
const webFetchToolNode = makeLocationNode({
name: "test/webfetch-tool-plugin",
@@ -93,12 +93,11 @@ describe("WebFetchTool registration", () => {
const url = "http://example.com/public"
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: { contentType: "text/plain" },
content: [{ type: "text", text: "hello" }],
},
expect(yield* executeTool(registry, call({ url, format: "text", timeout: 4 }))).toEqual({
status: "completed",
output: { url, contentType: "text/plain", format: "text", output: "hello" },
content: [{ type: "text", text: "hello" }],
metadata: { contentType: "text/plain" },
})
expect(assertions).toMatchObject([
{ sessionID, action: "webfetch", resources: [url], save: ["*"], metadata: { url, format: "text", timeout: 4 } },
@@ -113,9 +112,9 @@ describe("WebFetchTool registration", () => {
const registry = yield* ToolRegistry.Service
const url = "http://localhost/private"
expect(yield* executeTool(registry, call({ url, format: "text" }))).toEqual({
type: "text",
value: "hello",
expect(yield* executeTool(registry, call({ url, format: "text" }))).toMatchObject({
status: "completed",
content: [{ type: "text", text: "hello" }],
})
expect(assertions).toMatchObject([
{ sessionID, action: "webfetch", resources: [url], save: ["*"], metadata: { url, format: "text" } },
@@ -141,9 +140,9 @@ describe("WebFetchTool registration", () => {
const registry = yield* ToolRegistry.Service
const url = new URL("/redirect", server.url).toString()
expect(yield* executeTool(registry, call({ url, format: "text" }))).toEqual({
type: "text",
value: "redirected",
expect(yield* executeTool(registry, call({ url, format: "text" }))).toMatchObject({
status: "completed",
content: [{ type: "text", text: "redirected" }],
})
expect(assertions).toMatchObject([
{ sessionID, action: "webfetch", resources: [url], save: ["*"], metadata: { url, format: "text" } },
@@ -158,9 +157,10 @@ describe("WebFetchTool registration", () => {
reset()
const registry = yield* ToolRegistry.Service
// toSessionError unwraps the "Unable to fetch <url>" ToolFailure to its cause message.
expect(yield* executeTool(registry, call({ url: "file:///etc/passwd", format: "text" }))).toEqual({
type: "error",
value: "Unable to fetch file:///etc/passwd",
status: "error",
error: { type: "unknown", message: "URL must use http:// or https://" },
})
expect(assertions).toEqual([])
expect(requests).toEqual([])
@@ -178,13 +178,13 @@ describe("WebFetchTool registration", () => {
)
const registry = yield* ToolRegistry.Service
expect(yield* executeTool(registry, call({ url: "https://1.1.1.1", format: "markdown" }))).toEqual({
type: "text",
value: "# Hello\n\nworld",
expect(yield* executeTool(registry, call({ url: "https://1.1.1.1", format: "markdown" }))).toMatchObject({
status: "completed",
content: [{ type: "text", text: "# Hello\n\nworld" }],
})
expect(yield* executeTool(registry, call({ url: "https://1.1.1.1", format: "text" }))).toEqual({
type: "text",
value: "Helloworld",
expect(yield* executeTool(registry, call({ url: "https://1.1.1.1", format: "text" }))).toMatchObject({
status: "completed",
content: [{ type: "text", text: "Helloworld" }],
})
}),
)
@@ -201,9 +201,9 @@ describe("WebFetchTool registration", () => {
const registry = yield* ToolRegistry.Service
const url = "https://1.1.1.1/deep-html"
expect(yield* executeTool(registry, call({ url, format: "markdown" }))).toEqual({
type: "error",
value: `Unable to fetch ${url}`,
expect(yield* executeTool(registry, call({ url, format: "markdown" }))).toMatchObject({
status: "error",
error: { type: "unknown" },
})
}),
)
@@ -219,8 +219,11 @@ describe("WebFetchTool registration", () => {
}),
)
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",
status: "error",
error: {
type: "unknown",
message: `Response too large (exceeds ${WebFetchTool.MAX_RESPONSE_BYTES} byte limit)`,
},
})
respond = () =>
@@ -228,8 +231,11 @@ describe("WebFetchTool registration", () => {
new Response("x".repeat(WebFetchTool.MAX_RESPONSE_BYTES + 1), { headers: { "content-type": "text/plain" } }),
)
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",
status: "error",
error: {
type: "unknown",
message: `Response too large (exceeds ${WebFetchTool.MAX_RESPONSE_BYTES} byte limit)`,
},
})
}),
)
@@ -240,14 +246,14 @@ describe("WebFetchTool registration", () => {
const registry = yield* ToolRegistry.Service
respond = () => Effect.succeed(new Response("png", { headers: { "content-type": "image/png" } }))
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",
status: "error",
error: { type: "unknown", message: "Unsupported fetched image content type: image/png" },
})
respond = () => Effect.succeed(new Response("pdf", { headers: { "content-type": "application/pdf" } }))
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",
status: "error",
error: { type: "unknown", message: "Unsupported fetched file content type: application/pdf" },
})
}),
)
@@ -264,9 +270,9 @@ describe("WebFetchTool registration", () => {
)
const registry = yield* ToolRegistry.Service
expect(yield* executeTool(registry, call({ url: "https://1.1.1.1", format: "text" }))).toEqual({
type: "text",
value: "ok",
expect(yield* executeTool(registry, call({ url: "https://1.1.1.1", format: "text" }))).toMatchObject({
status: "completed",
content: [{ type: "text", text: "ok" }],
})
expect(requests).toHaveLength(2)
expect(requests[0]?.headers["user-agent"]).toContain("Mozilla/5.0")
@@ -285,7 +291,10 @@ describe("WebFetchTool registration", () => {
).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" })
expect(yield* Fiber.join(fiber)).toEqual({
status: "error",
error: { type: "unknown", message: "Request timed out" },
})
}),
)
})
+21 -11
View File
@@ -13,7 +13,7 @@ import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { Image } from "@opencode-ai/core/image"
import { testEffect } from "./lib/effect"
import { imagePassthrough } from "./lib/image"
import { toolIdentity, executeTool, registerToolPlugin, settleTool, toolDefinitions } from "./lib/tool"
import { toolIdentity, executeTool, registerToolPlugin, toolDefinitions } from "./lib/tool"
const webSearchToolNode = makeLocationNode({
name: "test/websearch-tool-plugin",
@@ -172,7 +172,10 @@ describe("WebSearchTool registration", () => {
},
},
}),
).toEqual({ type: "text", value: "exa results" })
).toMatchObject({
status: "completed",
content: [{ type: "text", text: "exa results" }],
})
expect(assertions).toMatchObject([
{
sessionID,
@@ -221,7 +224,7 @@ describe("WebSearchTool registration", () => {
config = { provider: "parallel", enableExa: false, enableParallel: false, parallelApiKey: "parallel-secret" }
const registry = yield* ToolRegistry.Service
const settled = yield* settleTool(registry, {
const settled = yield* executeTool(registry, {
sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-parallel", name: "websearch", input: { query: "effect layers" } },
@@ -242,11 +245,10 @@ describe("WebSearchTool registration", () => {
})
expect(requests[0]?.body).not.toHaveProperty("params.arguments.model_name")
expect(settled).toEqual({
result: { type: "text", value: "parallel results" },
output: {
structured: { provider: "parallel" },
content: [{ type: "text", text: "parallel results" }],
},
status: "completed",
output: { provider: "parallel", text: "parallel results" },
content: [{ type: "text", text: "parallel results" }],
metadata: { provider: "parallel" },
})
expect(JSON.stringify(settled)).not.toContain("parallel-secret")
}),
@@ -260,7 +262,7 @@ describe("WebSearchTool registration", () => {
config = { provider: "exa", enableExa: false, enableParallel: false, exaApiKey: "exa secret" }
const registry = yield* ToolRegistry.Service
const settled = yield* settleTool(registry, {
const settled = yield* executeTool(registry, {
sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-exa-key", name: "websearch", input: { query: "effect schema" } },
@@ -285,7 +287,10 @@ describe("WebSearchTool registration", () => {
...toolIdentity,
call: { type: "tool-call", id: "call-empty", name: "websearch", input: { query: "nothing" } },
}),
).toEqual({ type: "text", value: WebSearchTool.NO_RESULTS })
).toMatchObject({
status: "completed",
content: [{ type: "text", text: WebSearchTool.NO_RESULTS }],
})
}),
)
@@ -318,7 +323,12 @@ describe("WebSearchTool registration", () => {
...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" })
// toSessionError unwraps the "Unable to search the web for <query>" ToolFailure
// to its byte-limit cause message.
).toEqual({
status: "error",
error: { type: "unknown", message: expect.stringContaining("response exceeded") },
})
expect(chunksRead).toBeLessThan(10)
expect(cancelled).toBe(true)
}),
+32 -26
View File
@@ -18,7 +18,7 @@ import { location } from "./fixture/location"
import { tmpdir } from "./fixture/tmpdir"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { testEffect } from "./lib/effect"
import { toolIdentity, executeTool, registerToolPlugin, settleTool, toolDefinitions } from "./lib/tool"
import { toolIdentity, executeTool, registerToolPlugin, toolDefinitions } from "./lib/tool"
const writeToolNode = makeLocationNode({
name: "test/write-tool-plugin",
@@ -119,18 +119,16 @@ describe("WriteTool", () => {
return withTool(tmp.path, (registry) =>
Effect.gen(function* () {
expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["write"])
const settled = yield* settleTool(registry, call({ path: "src/new.txt", content: "created" }))
const settled = yield* executeTool(registry, call({ path: "src/new.txt", content: "created" }))
expect(settled).toEqual({
result: { type: "text", value: "Created file successfully: src/new.txt" },
status: "completed",
output: {
structured: {
operation: "write",
target: path.join(yield* Effect.promise(() => fs.realpath(tmp.path)), "src", "new.txt"),
resource: "src/new.txt",
existed: false,
},
content: [{ type: "text", text: "Created file successfully: src/new.txt" }],
operation: "write",
target: path.join(yield* Effect.promise(() => fs.realpath(tmp.path)), "src", "new.txt"),
resource: "src/new.txt",
existed: false,
},
content: [{ type: "text", text: "Created file successfully: src/new.txt" }],
})
expect(yield* Effect.promise(() => fs.readFile(path.join(tmp.path, "src", "new.txt"), "utf8"))).toBe(
"created",
@@ -151,12 +149,14 @@ describe("WriteTool", () => {
reset()
return Effect.promise(() => fs.writeFile(path.join(tmp.path, "existing.txt"), "before")).pipe(
Effect.andThen(
withTool(tmp.path, (registry) => settleTool(registry, call({ path: "existing.txt", content: "after" }))),
withTool(tmp.path, (registry) => executeTool(registry, call({ path: "existing.txt", content: "after" }))),
),
Effect.andThen((settled) =>
Effect.gen(function* () {
expect(settled.result).toEqual({ type: "text", value: "Wrote file successfully: existing.txt" })
expect(settled.output?.structured).toMatchObject({ resource: "existing.txt", existed: true })
expect(settled.status).toBe("completed")
if (settled.status !== "completed") return
expect(settled.content).toEqual([{ type: "text", text: "Wrote file successfully: existing.txt" }])
expect(settled.output).toMatchObject({ resource: "existing.txt", existed: true })
expect(yield* Effect.promise(() => fs.readFile(path.join(tmp.path, "existing.txt"), "utf8"))).toBe(
"after",
)
@@ -182,8 +182,8 @@ describe("WriteTool", () => {
Effect.andThen(
withTool(tmp.path, (registry) =>
Effect.gen(function* () {
yield* settleTool(registry, call({ path: "preserved.txt", content: "after" }, "call-preserved"))
yield* settleTool(
yield* executeTool(registry, call({ path: "preserved.txt", content: "after" }, "call-preserved"))
yield* executeTool(
registry,
call({ path: "deduplicated.txt", content: "\uFEFFafter" }, "call-deduplicated"),
)
@@ -208,7 +208,10 @@ describe("WriteTool", () => {
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" })
expect(result).toMatchObject({
status: "completed",
content: [{ type: "text", text: "Created file successfully: absolute.txt" }],
})
expect(assertions.map((input) => input.action)).toEqual(["edit"])
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("inside")
}),
@@ -236,7 +239,7 @@ describe("WriteTool", () => {
),
Effect.andThen((result) =>
Effect.sync(() => {
expect(result.type).toBe("text")
expect(result.status).toBe("completed")
expect(assertions.map((input) => input.action)).toEqual(["edit"])
expect(assertions[0]?.resources).toEqual(["link.txt"])
}),
@@ -259,7 +262,7 @@ describe("WriteTool", () => {
reset()
const target = path.join(outside.path, "external.txt")
return withTool(active.path, (registry) =>
settleTool(registry, call({ path: target, content: "external" })),
executeTool(registry, call({ path: target, content: "external" })),
).pipe(
Effect.andThen((settled) =>
Effect.gen(function* () {
@@ -271,10 +274,13 @@ describe("WriteTool", () => {
],
})
expect(assertions[1]).toMatchObject({ resources: [canonicalTarget.replaceAll("\\", "/")], save: ["*"] })
expect(settled.output?.structured).toMatchObject({
target: canonicalTarget,
resource: canonicalTarget.replaceAll("\\", "/"),
existed: false,
expect(settled).toMatchObject({
status: "completed",
output: {
target: canonicalTarget,
resource: canonicalTarget.replaceAll("\\", "/"),
existed: false,
},
})
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("external")
expect(writes).toEqual([canonicalTarget])
@@ -336,8 +342,8 @@ describe("WriteTool", () => {
executeTool(registry, call({ path: external, content: "blocked" })),
),
).toEqual({
type: "error",
value: `Unable to write ${external}`,
status: "error",
error: { type: "permission.rejected", message: "Permission denied: external_directory" },
})
expect(assertions.map((input) => input.action)).toEqual(["external_directory"])
expect(writes).toEqual([])
@@ -349,8 +355,8 @@ describe("WriteTool", () => {
executeTool(registry, call({ path: "denied.txt", content: "blocked" })),
),
).toEqual({
type: "error",
value: "Unable to write denied.txt",
status: "error",
error: { type: "permission.rejected", message: "Permission denied: edit" },
})
expect(assertions.map((input) => input.action)).toEqual(["edit"])
expect(writes).toEqual([])
@@ -264,19 +264,19 @@ test("controls arbitrary tools through scoped SDK overlays", async () => {
)
expect(yield* Queue.take(messages)).toMatchObject({ id: 1, result: { attached: true } })
const registry = yield* ToolRegistry.Service
const materialized = yield* registry.materialize()
expect(materialized.definitions).toContainEqual(
const toolSet = yield* registry.snapshot()
expect(toolSet.definitions).toContainEqual(
expect.objectContaining({ name: "lookup", description: "Look up a value" }),
)
const secondaryMaterialized = yield* ToolRegistry.Service.use((secondaryRegistry) =>
secondaryRegistry.materialize(),
const secondaryToolSet = yield* ToolRegistry.Service.use((secondaryRegistry) =>
secondaryRegistry.snapshot(),
).pipe(Effect.provide(secondary))
expect(secondaryMaterialized.definitions).toContainEqual(
expect(secondaryToolSet.definitions).toContainEqual(
expect.objectContaining({ name: "lookup", description: "Look up a value" }),
)
const progress: ToolRegistry.Progress[] = []
const settle = (callID: string, query: string) =>
materialized.settle({
const executeCall = (callID: string, query: string) =>
toolSet.execute({
sessionID: SessionV2.ID.make("ses_simulated_tools"),
agent: AgentV2.ID.make("build"),
messageID: SessionMessage.ID.make("msg_simulated_tools"),
@@ -289,7 +289,7 @@ test("controls arbitrary tools through scoped SDK overlays", async () => {
},
})
const successful = yield* settle("call_success", "answer").pipe(Effect.forkScoped)
const successful = yield* executeCall("call_success", "answer").pipe(Effect.forkScoped)
const successInvocation = yield* takeToolInvocation(messages)
expect(successInvocation.params).toMatchObject({
name: "lookup",
@@ -389,20 +389,18 @@ test("controls arbitrary tools through scoped SDK overlays", async () => {
)
expect(yield* Queue.take(messages)).toMatchObject({ id: 23, result: { ok: true } })
expect(yield* Fiber.join(successful)).toMatchObject({
result: { type: "text", value: "42" },
output: {
structured: { answer: 42 },
content: [{ type: "text", text: "42" }],
},
status: "completed",
output: { answer: 42 },
content: [{ type: "text", text: "42" }],
})
expect(progress).toEqual([
{
structured: { phase: "searching" },
metadata: { phase: "searching" },
content: [{ type: "text", text: "Searching" }],
},
])
const failed = yield* settle("call_failure", "missing").pipe(Effect.forkScoped)
const failed = yield* executeCall("call_failure", "missing").pipe(Effect.forkScoped)
const failedInvocation = yield* takeToolInvocation(messages)
const failedID = requireString(requireRecord(failedInvocation.params).id)
socket.send(
@@ -415,12 +413,13 @@ test("controls arbitrary tools through scoped SDK overlays", async () => {
)
expect(yield* Queue.take(messages)).toMatchObject({ id: 4, result: { ok: true } })
expect(yield* Fiber.join(failed)).toMatchObject({
result: { type: "error", value: "lookup failed" },
status: "error",
error: { message: "lookup failed" },
})
const concurrent = [
yield* settle("call_first", "first").pipe(Effect.forkScoped),
yield* settle("call_second", "second").pipe(Effect.forkScoped),
yield* executeCall("call_first", "first").pipe(Effect.forkScoped),
yield* executeCall("call_second", "second").pipe(Effect.forkScoped),
]
const invocations = [yield* takeToolInvocation(messages), yield* takeToolInvocation(messages)]
const byCall = new Map(
@@ -447,10 +446,18 @@ test("controls arbitrary tools through scoped SDK overlays", async () => {
)
expect(yield* Queue.take(messages)).toMatchObject({ id, result: { ok: true } })
}
expect((yield* Fiber.join(concurrent[0])).result).toEqual({ type: "text", value: "first result" })
expect((yield* Fiber.join(concurrent[1])).result).toEqual({ type: "text", value: "second result" })
expect(yield* Fiber.join(concurrent[0])).toMatchObject({
status: "completed",
output: "first result",
content: [{ type: "text", text: "first result" }],
})
expect(yield* Fiber.join(concurrent[1])).toMatchObject({
status: "completed",
output: "second result",
content: [{ type: "text", text: "second result" }],
})
const cancelled = yield* settle("call_cancelled", "slow").pipe(Effect.forkScoped)
const cancelled = yield* executeCall("call_cancelled", "slow").pipe(Effect.forkScoped)
const cancelledInvocation = yield* takeToolInvocation(messages)
const cancelledID = requireString(requireRecord(cancelledInvocation.params).id)
yield* Fiber.interrupt(cancelled)
@@ -474,7 +481,7 @@ test("controls arbitrary tools through scoped SDK overlays", async () => {
error: { message: expect.stringContaining("not found or already finished") },
})
const replayed = yield* settle("call_replayed", "reconnect").pipe(Effect.forkScoped)
const replayed = yield* executeCall("call_replayed", "reconnect").pipe(Effect.forkScoped)
const original = yield* takeToolInvocation(messages)
const originalID = requireString(requireRecord(original.params).id)
const replayedProgress = {
@@ -505,7 +512,7 @@ test("controls arbitrary tools through scoped SDK overlays", async () => {
error: { message: expect.stringContaining("already attached") },
})
yield* closeSocket(socket)
const disconnected = yield* registry.materialize()
const disconnected = yield* registry.snapshot()
expect(disconnected.definitions).toContainEqual(expect.objectContaining({ name: "lookup" }))
replacement.send(
JSON.stringify({
@@ -547,7 +554,7 @@ test("controls arbitrary tools through scoped SDK overlays", async () => {
}),
)
expect(yield* Queue.take(replacementMessages)).toMatchObject({ id: 26, result: { ok: true } })
expect(progress.filter((update) => update.structured.phase === "before-reconnect")).toHaveLength(1)
expect(progress.filter((update) => update.metadata.phase === "before-reconnect")).toHaveLength(1)
replacement.send(
JSON.stringify({
jsonrpc: "2.0",
@@ -563,12 +570,13 @@ test("controls arbitrary tools through scoped SDK overlays", async () => {
}),
)
expect(yield* Queue.take(replacementMessages)).toMatchObject({ id: 10, result: { ok: true } })
expect((yield* Fiber.join(replayed)).result).toEqual({
type: "text",
value: "replayed result",
expect(yield* Fiber.join(replayed)).toMatchObject({
status: "completed",
output: "replayed result",
content: [{ type: "text", text: "replayed result" }],
})
const preserved = yield* settle("call_preserved", "same generation").pipe(Effect.forkScoped)
const preserved = yield* executeCall("call_preserved", "same generation").pipe(Effect.forkScoped)
const preservedInvocation = yield* takeToolInvocation(replacementMessages)
const preservedID = requireString(requireRecord(preservedInvocation.params).id)
replacement.send(
@@ -583,7 +591,11 @@ test("controls arbitrary tools through scoped SDK overlays", async () => {
}),
)
expect(yield* Queue.take(replacementMessages)).toMatchObject({ id: 27, result: { ok: true } })
expect((yield* Fiber.join(preserved)).result).toEqual({ type: "text", value: "preserved" })
expect(yield* Fiber.join(preserved)).toMatchObject({
status: "completed",
output: "preserved",
content: [{ type: "text", text: "preserved" }],
})
const namespaced = [
{ ...registration, name: "search", options: { namespace: "github", codemode: false } },
@@ -598,18 +610,18 @@ test("controls arbitrary tools through scoped SDK overlays", async () => {
}),
)
expect(yield* Queue.take(replacementMessages)).toMatchObject({ id: 11, result: { attached: true } })
const replaced = yield* registry.materialize()
const replaced = yield* registry.snapshot()
const replacedNames = replaced.definitions.map((definition) => definition.name)
expect(replacedNames).toEqual(expect.arrayContaining(["github_search", "web_search"]))
expect(replacedNames).not.toContain("lookup")
const secondaryReplaced = yield* ToolRegistry.Service.use((secondaryRegistry) =>
secondaryRegistry.materialize(),
secondaryRegistry.snapshot(),
).pipe(Effect.provide(secondary))
const secondaryNames = secondaryReplaced.definitions.map((definition) => definition.name)
expect(secondaryNames).toEqual(expect.arrayContaining(["github_search", "web_search"]))
expect(secondaryNames).not.toContain("lookup")
const routed = yield* replaced
.settle({
.execute({
sessionID: SessionV2.ID.make("ses_simulated_tools"),
agent: AgentV2.ID.make("build"),
messageID: SessionMessage.ID.make("msg_simulated_tools"),
@@ -636,9 +648,13 @@ test("controls arbitrary tools through scoped SDK overlays", async () => {
}),
)
expect(yield* Queue.take(replacementMessages)).toMatchObject({ id: 12, result: { ok: true } })
expect((yield* Fiber.join(routed)).result).toEqual({ type: "text", value: "routed" })
expect(yield* Fiber.join(routed)).toMatchObject({
status: "completed",
output: "routed",
content: [{ type: "text", text: "routed" }],
})
expect(
yield* materialized.settle({
yield* toolSet.execute({
sessionID: SessionV2.ID.make("ses_simulated_tools"),
agent: AgentV2.ID.make("build"),
messageID: SessionMessage.ID.make("msg_simulated_tools"),
@@ -650,7 +666,8 @@ test("controls arbitrary tools through scoped SDK overlays", async () => {
},
}),
).toMatchObject({
result: { type: "error", value: expect.stringContaining("no longer active") },
status: "error",
error: { message: expect.stringContaining("no longer active") },
})
expect(activations).toBe(2)
}).pipe(Effect.provide(primary))