mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-25 20:55:42 -04:00
test(core): migrate tool tests to canonical execution model
This commit is contained in:
@@ -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 }
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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: [] })
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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: [] }])
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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"])
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -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))
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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([])
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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)
|
||||
}),
|
||||
|
||||
@@ -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,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({
|
||||
|
||||
@@ -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" } })
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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]()),
|
||||
|
||||
@@ -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"),
|
||||
})
|
||||
|
||||
@@ -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))
|
||||
}),
|
||||
),
|
||||
|
||||
@@ -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"`)
|
||||
|
||||
@@ -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" },
|
||||
})
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}),
|
||||
|
||||
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user