refactor(tools): unify tool APIs and result handling (#38367)

This commit is contained in:
Kit Langton 2026-07-23 17:13:31 -04:00 committed by GitHub
commit 79c1544072
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
133 changed files with 3602 additions and 2770 deletions

View file

@ -0,0 +1,8 @@
---
"@opencode-ai/plugin": minor
"@opencode-ai/sdk": minor
"@opencode-ai/client": minor
"@opencode-ai/protocol": minor
---
Replace the V2 tool result model with one canonical representation per fact. Tools lose `structured`, projection callbacks, the `Structured` generic, and the exported `Tool.settle` interpreter; tool responses carry schema-validated `output`, model-visible `content`, and optional compact JSON `metadata`. Code Mode receives the validated encoded output. Durable tool success stores non-empty model content plus optional metadata; failure stores one error plus the final bounded partial snapshot. Progress carries metadata only, while `execute.after` hooks receive the canonical terminal outcome and managed `outputPaths`. A one-time migration rewrites existing projected tool rows and moves provider-hosted result payloads into provider-owned result state.

View file

@ -330,7 +330,10 @@ const lowerServerToolResult = Effect.fn("AnthropicMessages.lowerServerToolResult
const wireType = serverToolResultType(part.name)
if (!wireType)
return yield* invalid(`Anthropic Messages does not know how to round-trip server tool result for ${part.name}`)
return { type: wireType, tool_use_id: part.id, content: part.result.value } satisfies AnthropicServerToolResultBlock
// Prefer the provider-owned replay payload; fall back to the result value for
// histories constructed directly from provider events.
const payload = part.providerMetadata?.anthropic?.["result"] ?? part.result.value
return { type: wireType, tool_use_id: part.id, content: payload } satisfies AnthropicServerToolResultBlock
})
const lowerMedia = Effect.fn("AnthropicMessages.lowerMedia")(function* (part: MediaPart) {
@ -682,7 +685,9 @@ const serverToolResultEvent = (block: NonNullable<AnthropicEvent["content_block"
name: SERVER_TOOL_RESULT_NAMES[block.type],
result: isError ? { type: "error", value: block.content } : { type: "json", value: block.content },
providerExecuted: true,
providerMetadata: anthropicMetadata({ blockType: block.type }),
// The complete payload is irreducible provider replay state: subsequent
// stateless requests must round-trip the typed result block verbatim.
providerMetadata: anthropicMetadata({ blockType: block.type, result: block.content }),
})
}

View file

@ -392,8 +392,13 @@ const fromRequest = Effect.fn("BedrockConverse.fromRequest")(function* (request:
// tools → system → messages order to favour the highest-impact prefixes.
const breakpoints = BedrockCache.breakpoints()
const toolConfig =
request.tools.length > 0 && request.toolChoice?.type !== "none"
? { tools: lowerTools(request.model.compatibility?.toolSchema, breakpoints, request.tools), toolChoice }
request.tools.length > 0
? {
tools: lowerTools(request.model.compatibility?.toolSchema, breakpoints, request.tools),
// Converse has no native "none". Keep definitions stable for prompt
// caching and omit only the unsupported choice.
toolChoice,
}
: undefined
const system = request.system.length === 0 ? undefined : lowerSystem(breakpoints, request.system)
const messages = yield* lowerMessages(request, breakpoints)

View file

@ -664,7 +664,14 @@ describe("Anthropic Messages route", () => {
name: "web_search",
result: { type: "json", value: [{ type: "web_search_result", url: "https://example.com", title: "Example" }] },
providerExecuted: true,
providerMetadata: { anthropic: { blockType: "web_search_tool_result" } },
// The complete payload rides in provider metadata as irreducible replay
// state for later stateless requests.
providerMetadata: {
anthropic: {
blockType: "web_search_tool_result",
result: [{ type: "web_search_result", url: "https://example.com", title: "Example" }],
},
},
})
expect(response.text).toBe("Found it.")
expect(response.events.at(-1)).toMatchObject({ type: "finish", reason: "stop" })

View file

@ -154,6 +154,36 @@ describe("Bedrock Converse route", () => {
}),
)
it.effect("keeps tools and omits the unsupported choice when tool choice is none", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare(
LLM.updateRequest(baseRequest, {
tools: [
{
name: "lookup",
description: "Lookup data",
inputSchema: { type: "object", properties: { query: { type: "string" } } },
},
],
toolChoice: ToolChoice.make({ type: "none" }),
}),
)
expect(prepared.body.toolConfig).toMatchObject({
tools: [
{
toolSpec: {
name: "lookup",
description: "Lookup data",
inputSchema: { json: { type: "object", properties: { query: { type: "string" } } } },
},
},
],
})
expect(prepared.body.toolConfig?.toolChoice).toBeUndefined()
}),
)
it.effect("lowers assistant tool-call + tool-result message history", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare(

View file

@ -28,7 +28,7 @@ export type TurnControl = {
type ToolState = {
readonly name: string
input: ToolInput
structured: Record<string, unknown>
metadata: Record<string, unknown>
content: ToolContent
}
@ -38,7 +38,7 @@ export type TurnStart =
| { readonly type: "compaction"; readonly id: string }
function emptyToolState(): ToolState {
return { name: "tool", input: {}, structured: {}, content: [] }
return { name: "tool", input: {}, metadata: {}, content: [] }
}
export async function streamTurn(input: {
@ -120,7 +120,7 @@ export async function streamTurn(input: {
}
if (event.type === "session.tool.input.started") {
assistantMessageID = event.data.assistantMessageID
tools.set(event.data.callID, { name: event.data.name, input: {}, structured: {}, content: [] })
tools.set(event.data.callID, { name: event.data.name, input: {}, metadata: {}, content: [] })
await update({
sessionUpdate: "tool_call",
...pendingToolCall({
@ -151,15 +151,13 @@ export async function streamTurn(input: {
if (event.type === "session.tool.progress") {
const current = tools.get(event.data.callID)
if (!current) continue
current.structured = event.data.structured
current.content = event.data.content
current.metadata = event.data.metadata
await update({
sessionUpdate: "tool_call_update",
...runningToolUpdate({
toolCallId: event.data.callID,
toolName: current.name,
state: { input: current.input },
content: current.content,
cwd: input.cwd,
}),
})
@ -175,7 +173,7 @@ export async function streamTurn(input: {
cwd: input.cwd,
toolName: current.name,
toolInput: current.input,
structured: event.data.structured,
metadata: event.data.metadata ?? {},
}).catch(() => {})
await update({
sessionUpdate: "tool_call_update",
@ -183,9 +181,8 @@ export async function streamTurn(input: {
toolCallId: event.data.callID,
toolName: current.name,
input: current.input,
structured: event.data.structured,
metadata: event.data.metadata,
content: event.data.content,
result: event.data.result,
}),
})
continue
@ -199,7 +196,7 @@ export async function streamTurn(input: {
toolCallId: event.data.callID,
toolName: current.name,
input: current.input,
structured: event.data.metadata ?? current.structured,
metadata: event.data.metadata ?? current.metadata,
content: event.data.content ?? current.content,
error: event.data.error.message,
cwd: input.cwd,
@ -342,9 +339,8 @@ async function replayMessage(
toolCallId: part.id,
toolName: part.name,
input: part.state.input,
structured: part.state.structured,
metadata: part.state.metadata,
content: part.state.content,
result: part.state.result,
}),
},
})
@ -358,7 +354,6 @@ async function replayMessage(
toolCallId: part.id,
toolName: part.name,
state: { input: part.state.input },
content: part.state.content,
cwd,
}),
},
@ -373,7 +368,7 @@ async function replayMessage(
toolCallId: part.id,
toolName: part.name,
input: part.state.input,
structured: part.state.structured,
metadata: part.state.metadata,
content: part.state.content,
error: part.state.error.message,
cwd,

View file

@ -58,11 +58,11 @@ export async function syncEditedFiles(input: {
readonly cwd: string
readonly toolName: string
readonly toolInput: ToolInput
readonly structured: Readonly<Record<string, unknown>>
readonly metadata: Readonly<Record<string, unknown>>
}) {
if (!input.writeTextFile || !input.connection.writeTextFile || toToolKind(input.toolName) !== "edit") return
const files = Array.isArray(input.structured.files)
? input.structured.files.flatMap((file): string[] => {
const files = Array.isArray(input.metadata.files)
? input.metadata.files.flatMap((file): string[] => {
if (!file || typeof file !== "object") return []
const path = Reflect.get(file, "file")
return typeof path === "string" ? [path] : []

View file

@ -1,5 +1,6 @@
import { isAbsolute, resolve } from "node:path"
import type { ToolCall, ToolCallContent, ToolCallLocation, ToolCallUpdate, ToolKind } from "@agentclientprotocol/sdk"
import { readDisplayText } from "@opencode-ai/tui/mini/tool"
export type ToolInput = Record<string, unknown>
export type ToolContent = ReadonlyArray<
@ -100,11 +101,12 @@ export function completedToolUpdate(input: {
readonly toolName: string
readonly input: ToolInput
readonly content: ToolContent
readonly structured: Readonly<Record<string, unknown>>
readonly result?: unknown
readonly metadata?: Readonly<Record<string, unknown>>
}): ToolCallUpdate {
const normalized = toolContent(input.content)
const read = input.toolName.toLocaleLowerCase() === "read" ? readDisplayText(input.structured) : undefined
// Read's model content is a JSON page envelope; show the clean text instead.
const firstText = input.content.find((part) => part.type === "text")
const read = input.toolName.toLocaleLowerCase() === "read" && firstText ? readDisplayText(firstText.text) : undefined
const images = normalized.filter((part) => part.type === "content" && part.content.type === "image")
const primary =
read === undefined
@ -128,8 +130,7 @@ export function completedToolUpdate(input: {
status: "completed",
content: [...primary, ...diff, ...images],
rawOutput: {
structured: input.structured,
...(input.result === undefined ? {} : { result: input.result }),
...(input.metadata === undefined ? {} : { metadata: input.metadata }),
},
}
}
@ -138,8 +139,8 @@ export function errorToolUpdate(input: {
readonly toolCallId: string
readonly toolName: string
readonly input: ToolInput
readonly content: ToolContent
readonly structured: Readonly<Record<string, unknown>>
readonly content?: ToolContent
readonly metadata?: Readonly<Record<string, unknown>>
readonly error: string
readonly cwd?: string
}): ToolCallUpdate {
@ -150,8 +151,11 @@ export function errorToolUpdate(input: {
title: toolTitle(input.toolName, input.input, undefined),
locations: toLocations(input.toolName, input.input, input.cwd),
rawInput: rawInput(input.toolName, input.input, input.cwd),
content: [...toolContent(input.content), { type: "content", content: { type: "text", text: input.error } }],
rawOutput: { structured: input.structured, error: input.error },
content: [...toolContent(input.content ?? []), { type: "content", content: { type: "text", text: input.error } }],
rawOutput: {
...(input.metadata === undefined ? {} : { metadata: input.metadata }),
error: input.error,
},
}
}
@ -164,21 +168,6 @@ function toolContent(content: ToolContent): ToolCallContent[] {
})
}
function readDisplayText(structured: Readonly<Record<string, unknown>>) {
if (typeof structured.content === "string") {
if (structured.type === "text-page" || structured.encoding === "utf8") return structured.content
}
if (!Array.isArray(structured.entries)) return undefined
return structured.entries
.flatMap((entry): string[] => {
if (typeof entry === "string") return [entry]
if (!entry || typeof entry !== "object") return []
const path = Reflect.get(entry, "path")
return typeof path === "string" ? [path] : []
})
.join("\n")
}
function toolTitle(toolName: string, input: ToolInput, fallback: string | undefined) {
if (isShell(toolName)) return stringValue(input.command) ?? stringValue(input.cmd) ?? fallback ?? toolName
return fallback || toolName

View file

@ -10,6 +10,7 @@ import {
Reference,
Skill,
} from "@opencode-ai/plugin/v2"
import { Tool } from "@opencode-ai/plugin/v2/tool"
const key = Symbol.for("opencode.plugin.v2.promise")
;(globalThis as typeof globalThis & { [key]?: unknown })[key] = {
@ -23,4 +24,5 @@ const key = Symbol.for("opencode.plugin.v2.promise")
Provider,
Reference,
Skill,
Tool,
}

View file

@ -10,7 +10,7 @@ import type {
import { SessionMessage } from "@opencode-ai/schema/session-message"
import { EOL } from "node:os"
import { readFile } from "node:fs/promises"
import { toolOutputText, type MiniToolPart } from "@opencode-ai/tui/mini/tool"
import { nonEmptyToolContent, toolOutputText, type MiniToolPart } from "@opencode-ai/tui/mini/tool"
import { UI } from "./ui"
type Model = {
@ -55,7 +55,7 @@ type ToolState = StartedPart & {
raw?: string
provider?: unknown
providerState?: SessionMessageAssistantTool["providerState"]
structured: Record<string, JsonValue>
metadata: Record<string, JsonValue>
content: LLMToolContent[]
}
@ -306,7 +306,7 @@ export async function runNonInteractivePrompt(input: Input) {
assistantMessageID: event.data.assistantMessageID,
tool: event.data.name,
input: {},
structured: {},
metadata: {},
content: [],
})
continue
@ -334,7 +334,7 @@ export async function runNonInteractivePrompt(input: Input) {
raw: current?.raw,
provider: { executed: event.data.executed, state: event.data.state },
providerState: event.data.state,
structured: {},
metadata: {},
content: [],
})
continue
@ -342,8 +342,7 @@ export async function runNonInteractivePrompt(input: Input) {
if (event.type === "session.tool.progress") {
const current = tools.get(toolKey(event.data.assistantMessageID, event.data.callID))
if (current) {
current.structured = event.data.structured
current.content = event.data.content
current.metadata = event.data.metadata
}
continue
}
@ -360,9 +359,8 @@ export async function runNonInteractivePrompt(input: Input) {
state: {
status: "completed",
input: current.input,
structured: event.data.structured,
metadata: event.data.metadata,
content: event.data.content,
result: event.data.result,
},
time: { created: current.timestamp, ran: current.timestamp, completed: time },
}
@ -379,9 +377,8 @@ export async function runNonInteractivePrompt(input: Input) {
output: toolOutputText(current.tool, event.data.content),
title: current.tool,
metadata: {
structured: event.data.structured,
metadata: event.data.metadata,
content: event.data.content,
result: event.data.result,
providerCall: current.provider,
providerResult: { executed: event.data.executed, state: event.data.resultState },
rawInput: current.raw,
@ -398,8 +395,8 @@ export async function runNonInteractivePrompt(input: Input) {
const key = toolKey(event.data.assistantMessageID, event.data.callID)
const current = tools.get(key) ?? fallbackTool(event)
const error = event.data.error.message
const structured = event.data.metadata ?? current.structured
const content = event.data.content ?? current.content
const metadata = event.data.metadata ?? current.metadata
const content = event.data.content ?? nonEmptyToolContent(current.content)
const tool: SessionMessageAssistantTool = {
type: "tool",
id: event.data.callID,
@ -410,10 +407,9 @@ export async function runNonInteractivePrompt(input: Input) {
state: {
status: "error",
input: current.input,
structured,
metadata,
content,
error: event.data.error,
result: event.data.result,
},
time: { created: current.timestamp, ran: current.timestamp, completed: time },
}
@ -429,7 +425,6 @@ export async function runNonInteractivePrompt(input: Input) {
input: current.input,
error,
metadata: {
result: event.data.result,
providerCall: current.provider,
providerResult: { executed: event.data.executed, state: event.data.resultState },
rawInput: current.raw,
@ -441,15 +436,14 @@ export async function runNonInteractivePrompt(input: Input) {
renderedTools.add(key)
if (input.compatibility === "v1" && (permissionRejected || formCancelled)) continue
if (!emit("tool_use", time, { part })) {
if (toolOutputText(current.tool, content).trim())
if (content && toolOutputText(current.tool, content).trim())
await input.renderTool({
...tool,
state: {
status: "completed",
input: current.input,
structured,
metadata,
content,
result: event.data.result,
},
})
await input.renderToolError(tool)
@ -597,14 +591,14 @@ export async function runNonInteractivePrompt(input: Input) {
input: item.state.input,
output: toolOutputText(item.name, item.state.content),
title: item.name,
metadata: { structured: item.state.structured, content: item.state.content, result: item.state.result },
metadata: { metadata: item.state.metadata, content: item.state.content },
time: { start: item.time.ran ?? item.time.created, end: item.time.completed ?? timestamp },
}
: {
status: "error",
input: item.state.input,
error: item.state.error.message,
metadata: { structured: item.state.structured, content: item.state.content, result: item.state.result },
metadata: { metadata: item.state.metadata, content: item.state.content },
time: { start: item.time.ran ?? item.time.created, end: item.time.completed ?? timestamp },
},
}
@ -614,8 +608,16 @@ export async function runNonInteractivePrompt(input: Input) {
await input.renderTool(item)
continue
}
if (toolOutputText(item.name, item.state.content).trim()) {
await input.renderTool({ ...item, state: { ...item.state, status: "completed" } })
if (item.state.content && toolOutputText(item.name, item.state.content).trim()) {
await input.renderTool({
...item,
state: {
status: "completed",
input: item.state.input,
metadata: item.state.metadata,
content: item.state.content,
},
})
}
await input.renderToolError(item)
UI.error(item.state.error.message)
@ -792,7 +794,7 @@ function fallbackTool(event: {
assistantMessageID: event.data.assistantMessageID,
tool: "tool",
input: {},
structured: {},
metadata: {},
content: [],
}
}

View file

@ -218,8 +218,7 @@ describe("acp event behavior", () => {
sessionID: "ses_tools",
assistantMessageID: "msg_tools",
callID: "call_ok",
structured: { phase: 1 },
content: [{ type: "text", text: "working" }],
metadata: { phase: 1 },
}),
)
send(
@ -227,9 +226,8 @@ describe("acp event behavior", () => {
sessionID: "ses_tools",
assistantMessageID: "msg_tools",
callID: "call_ok",
structured: { exit: 0 },
metadata: { exit: 0 },
content: [{ type: "text", text: "done" }],
result: { code: 0 },
executed: true,
}),
)
@ -255,8 +253,7 @@ describe("acp event behavior", () => {
sessionID: "ses_tools",
assistantMessageID: "msg_tools",
callID: "call_fail",
structured: { bytes: 0 },
content: [{ type: "text", text: "opening" }],
metadata: { bytes: 0 },
}),
)
send(
@ -313,12 +310,10 @@ describe("acp event behavior", () => {
locations: [{ path: resolve("/workspace", "sub") }],
rawInput: { command: "printf done", workdir: "sub" },
})
expect(updates[2]?.update).toMatchObject({
content: [{ type: "content", content: { type: "text", text: "working" } }],
})
expect(updates[2]?.update).not.toHaveProperty("content")
expect(updates[3]?.update).toMatchObject({
content: [{ type: "content", content: { type: "text", text: "done" } }],
rawOutput: { structured: { exit: 0 }, result: { code: 0 } },
rawOutput: { metadata: { exit: 0 } },
})
expect(updates[7]?.update).toMatchObject({
kind: "read",
@ -327,7 +322,7 @@ describe("acp event behavior", () => {
{ type: "content", content: { type: "text", text: "opening" } },
{ type: "content", content: { type: "text", text: "not found" } },
],
rawOutput: { structured: { bytes: 0 }, error: "not found" },
rawOutput: { metadata: { bytes: 0 }, error: "not found" },
})
expect(response.stopReason).toBe("end_turn")
} finally {
@ -379,7 +374,7 @@ describe("acp event behavior", () => {
{ type: "content", content: { type: "text", text: "done" } },
{ type: "content", content: { type: "image", mimeType: "image/png", data: "AAAA" } },
],
rawOutput: { structured: { exit: 0 }, result: { code: 0 } },
rawOutput: { metadata: { exit: 0 } },
})
expect(updates[8]?.update).toMatchObject({
toolCallId: "call_running",
@ -618,12 +613,11 @@ function replayFixtureMessages(): SessionMessageInfo[] {
state: {
status: "completed",
input: { command: "printf done" },
structured: { exit: 0 },
metadata: { exit: 0 },
content: [
{ type: "text", text: "done" },
{ type: "file", uri: "data:image/png;base64,AAAA", mime: "image/png", name: "image.png" },
],
result: { code: 0 },
},
},
{
@ -634,8 +628,7 @@ function replayFixtureMessages(): SessionMessageInfo[] {
state: {
status: "running",
input: { command: "pwd" },
structured: {},
content: [{ type: "text", text: "/workspace" }],
metadata: {},
},
},
{
@ -646,7 +639,7 @@ function replayFixtureMessages(): SessionMessageInfo[] {
state: {
status: "error",
input: { filePath: "/workspace/missing.ts" },
structured: { bytes: 0 },
metadata: { bytes: 0 },
content: [{ type: "text", text: "partial" }],
error: { type: "tool.error", message: "failed hard" },
},
@ -679,7 +672,7 @@ function replayToolMessage(id: string) {
state: {
status: "completed",
input: { command: "printf done" },
structured: { exit: 0 },
metadata: { exit: 0 },
content: [{ type: "text", text: "done" }],
},
},

View file

@ -28,7 +28,7 @@ describe("acp permission behavior", () => {
cwd: "/workspace",
toolName: "edit",
toolInput: { filePath: "/workspace/file.ts" },
structured: {},
metadata: {},
})
expect(writes).toEqual([])
@ -193,7 +193,7 @@ describe("acp permission behavior", () => {
sessionID: "ses_edit",
assistantMessageID: "msg_edit",
callID: "call_edit",
structured: { files: [{ file: "file.ts" }], replacements: 1 },
metadata: { files: [{ file: "file.ts" }], replacements: 1 },
content: [{ type: "text", text: "edited" }],
executed: true,
}),
@ -286,7 +286,7 @@ describe("acp permission behavior", () => {
sessionID: "ses_patch",
assistantMessageID: "msg_patch",
callID: "call_patch",
structured: { files: [{ file: "first.ts" }, { file: "second.ts" }] },
metadata: { files: [{ file: "first.ts" }, { file: "second.ts" }] },
content: [{ type: "text", text: "patched" }],
executed: true,
}),

View file

@ -63,7 +63,7 @@ describe("acp tools", () => {
{ type: "file", mime: "image/png", name: "image.png", uri: `data:image/png;base64,${image}` },
{ type: "file", mime: "text/plain", name: "note.txt", uri: "data:text/plain;base64,bm90ZQ==" },
],
structured: {},
metadata: {},
}).content,
).toEqual([
{
@ -93,7 +93,7 @@ describe("acp tools", () => {
content: "created",
},
content: [{ type: "text", text: "wrote /tmp/file.ts" }],
structured: {},
metadata: {},
}).content,
).toEqual([
{
@ -103,20 +103,22 @@ describe("acp tools", () => {
])
})
test("uses clean structured read content instead of model-facing formatting", () => {
test("unwraps read's JSON page envelope instead of showing model-facing formatting", () => {
expect(
completedToolUpdate({
toolCallId: "tool-read",
toolName: "read",
input: { path: "/tmp/file.ts" },
content: [{ type: "text", text: "<content>1: first\n2: second</content>" }],
structured: {
type: "text-page",
content: "first\nsecond",
mime: "text/plain",
offset: 1,
truncated: false,
},
content: [
{
type: "text",
text: JSON.stringify(
{ type: "text-page", content: "first\nsecond", mime: "text/plain", offset: 1, truncated: false },
null,
2,
),
},
],
}).content,
).toEqual([{ type: "content", content: { type: "text", text: "first\nsecond" } }])
@ -125,13 +127,17 @@ describe("acp tools", () => {
toolCallId: "tool-list",
toolName: "read",
input: { path: "/tmp" },
content: [],
structured: {
entries: [
{ path: "a.ts", type: "file" },
{ path: "src", type: "directory" },
],
},
content: [
{
type: "text",
text: JSON.stringify({
entries: [
{ path: "a.ts", type: "file" },
{ path: "src", type: "directory" },
],
}),
},
],
}).content,
).toEqual([{ type: "content", content: { type: "text", text: "a.ts\nsrc" } }])
})
@ -171,7 +177,7 @@ describe("acp tools", () => {
newString: "after",
},
content: [{ type: "text", text: "Edit applied successfully." }],
structured: { output: "Edit applied successfully." },
metadata: { output: "Edit applied successfully." },
}),
).toEqual({
toolCallId: "tool-1",
@ -189,7 +195,7 @@ describe("acp tools", () => {
},
],
rawOutput: {
structured: { output: "Edit applied successfully." },
metadata: { output: "Edit applied successfully." },
},
})
})
@ -209,7 +215,7 @@ describe("acp tools", () => {
})
})
test("builds completed raw output with structured data and optional result", () => {
test("builds completed raw output with optional metadata", () => {
const attachments = [
{
type: "file",
@ -225,12 +231,10 @@ describe("acp tools", () => {
toolName: "read",
input: {},
content: [],
structured: { output: "done", metadata: { exit: 0 }, attachments },
result: "done",
metadata: { output: "done", metadata: { exit: 0 }, attachments },
}).rawOutput,
).toEqual({
structured: { output: "done", metadata: { exit: 0 }, attachments },
result: "done",
metadata: { output: "done", metadata: { exit: 0 }, attachments },
})
expect(
@ -239,9 +243,8 @@ describe("acp tools", () => {
toolName: "read",
input: {},
content: [],
structured: { output: "done" },
}).rawOutput,
).toEqual({ structured: { output: "done" } })
).toEqual({})
})
test("extracts image attachments only from data URLs", () => {
@ -255,7 +258,7 @@ describe("acp tools", () => {
{ type: "file", mime: "image/png", uri: "https://example.com/image.png" },
{ type: "file", mime: "text/plain", uri: "data:text/plain;base64,BBBB" },
],
structured: {},
metadata: {},
}).content,
).toEqual([
{
@ -272,7 +275,7 @@ describe("acp tools", () => {
toolName: "read",
input: { filePath: "/tmp/a" },
content: [{ type: "text", text: "partial output" }],
structured: { path: "/tmp/a" },
metadata: { path: "/tmp/a" },
error: "failed",
}),
).toEqual({
@ -286,7 +289,7 @@ describe("acp tools", () => {
{ type: "content", content: { type: "text", text: "partial output" } },
{ type: "content", content: { type: "text", text: "failed" } },
],
rawOutput: { structured: { path: "/tmp/a" }, error: "failed" },
rawOutput: { metadata: { path: "/tmp/a" }, error: "failed" },
})
})
})

View file

@ -23,7 +23,12 @@ describe("CLI frontend import boundaries", () => {
expect(Object.keys(run).sort()).toEqual(["runNonInteractive", "runV1Bridge"])
expect(Object.keys(mini).sort()).toEqual(["runMiniFrontend"])
expect(Object.keys(tool).sort()).toEqual(["toolInlineInfo", "toolOutputText"])
expect(Object.keys(tool).sort()).toEqual([
"nonEmptyToolContent",
"readDisplayText",
"toolInlineInfo",
"toolOutputText",
])
expect(Object.keys(cli.exports).filter((key) => key === "./mini" || key.startsWith("./mini/"))).toEqual([])
})

View file

@ -134,15 +134,14 @@ function failedTool(inputID: string): V2Event[] {
sessionID: "ses_1",
assistantMessageID: "msg_failed_tool",
callID: "call_failed_tool",
structured: { checkpoint: 1 },
content: [{ type: "text", text: "partial output" }],
metadata: { checkpoint: 1 },
},
},
{
id: "evt_failed_tool_terminal",
created: 4,
type: "session.tool.failed",
durable: { aggregateID: "ses_1", seq: 4, version: 1 },
durable: { aggregateID: "ses_1", seq: 4, version: 2 },
data: {
sessionID: "ses_1",
assistantMessageID: "msg_failed_tool",
@ -190,12 +189,12 @@ function successfulGrep(inputID: string): V2Event[] {
id: "evt_grep_success",
created: 3,
type: "session.tool.success",
durable: { aggregateID: "ses_1", seq: 3, version: 1 },
durable: { aggregateID: "ses_1", seq: 3, version: 2 },
data: {
sessionID: "ses_1",
assistantMessageID: "msg_grep",
callID: "call_grep",
structured: { matches: 2 },
metadata: { matches: 2 },
content: [{ type: "text", text }],
executed: false,
},
@ -258,9 +257,7 @@ async function run(input: {
spyOn(sdk.session, "wait").mockImplementation(() => input.wait?.() ?? wait.promise)
spyOn(sdk.message, "list").mockImplementation(() =>
ok({
data: input.messages?.(promptID) ?? [
{ id: promptID, type: "user", text: "hello", time: { created: 1 } },
],
data: input.messages?.(promptID) ?? [{ id: promptID, type: "user", text: "hello", time: { created: 1 } }],
cursor: {},
}),
)
@ -316,7 +313,7 @@ afterEach(() => {
})
describe("runNonInteractivePrompt", () => {
test("keeps formatted tool output and compact structured metadata in JSON", async () => {
test("keeps formatted tool output and compact tool metadata in JSON", async () => {
const output = await capture({ format: "json", turn: successfulGrep })
const events = output.stdout
.split("\n")
@ -332,13 +329,13 @@ describe("runNonInteractivePrompt", () => {
status: "completed",
output: expect.stringContaining("Found 2 matches"),
metadata: {
structured: { matches: 2 },
metadata: { matches: 2 },
content: [{ type: "text", text: expect.stringContaining("/src/a.ts") }],
},
},
},
})
expect(events[0].part.state.metadata.structured).toEqual({ matches: 2 })
expect(events[0].part.state.metadata.metadata).toEqual({ matches: 2 })
expect(events[0].part.state.metadata.result).toBeUndefined()
})
@ -534,7 +531,7 @@ describe("runNonInteractivePrompt", () => {
id: "call_failed_tool",
state: {
status: "completed",
structured: { checkpoint: 1 },
metadata: { checkpoint: 1 },
content: [{ type: "text", text: "partial output" }],
},
},
@ -544,7 +541,7 @@ describe("runNonInteractivePrompt", () => {
id: "call_failed_tool",
state: {
status: "error",
structured: { checkpoint: 1 },
metadata: { checkpoint: 1 },
content: [{ type: "text", text: "partial output" }],
error: { message: "tool failed" },
},
@ -574,7 +571,7 @@ describe("runNonInteractivePrompt", () => {
},
})
expect(events[0].part.state.output).toBeUndefined()
expect(events[0].part.state.metadata.structured).toBeUndefined()
expect(events[0].part.state.metadata.metadata).toBeUndefined()
expect(events[0].part.state.metadata.content).toBeUndefined()
expect(output.stderr).toBe("")
})

View file

@ -75,6 +75,10 @@ export const define = sdk.Plugin.define`
const effectPluginModule = promisePluginModule
.replace("opencode.plugin.v2.promise", "opencode.plugin.v2.effect")
.replace("Promise plugin", "Effect plugin")
const promiseToolModule = `const sdk = globalThis[Symbol.for("opencode.plugin.v2.promise")]
if (!sdk) throw new Error("OpenCode Promise plugin SDK is unavailable")
export const Tool = sdk.Tool
export const make = sdk.Tool.make`
const effectToolModule = `const sdk = globalThis[Symbol.for("opencode.plugin.v2.effect")]
if (!sdk) throw new Error("OpenCode Effect plugin SDK is unavailable")
export const Tool = sdk.Tool
@ -83,10 +87,8 @@ export const RegistrationError = sdk.Tool.RegistrationError
export const make = sdk.Tool.make
export const validateName = sdk.Tool.validateName
export const registrationEntries = sdk.Tool.registrationEntries
export const withPermission = sdk.Tool.withPermission
export const permission = sdk.Tool.permission
export const definition = sdk.Tool.definition
export const settle = sdk.Tool.settle`
export const validateNamespace = sdk.Tool.validateNamespace
export const toLLMDefinition = sdk.Tool.toLLMDefinition`
return `#!/usr/bin/env -S node ${nodeExecArgv.join(" ")}
import __cjs_mod__ from "node:module"
import { chmodSync as __ocChmod, existsSync as __ocExists, lstatSync as __ocLstat, mkdirSync as __ocMkdir, renameSync as __ocRename, rmSync as __ocRm, writeFileSync as __ocWrite } from "node:fs"
@ -100,6 +102,7 @@ const require = __cjs_mod__.createRequire(import.meta.url)
const __ocPluginModules = ${JSON.stringify({
"@opencode-ai/plugin/v2": "opencode:plugin-v2",
"@opencode-ai/plugin/v2/plugin": "opencode:plugin-v2-plugin",
"@opencode-ai/plugin/v2/tool": "opencode:plugin-v2-tool",
"@opencode-ai/plugin/v2/effect": "opencode:plugin-v2-effect",
"@opencode-ai/plugin/v2/effect/plugin": "opencode:plugin-v2-effect-plugin",
"@opencode-ai/plugin/v2/effect/tool": "opencode:plugin-v2-effect-tool",
@ -107,6 +110,7 @@ const __ocPluginModules = ${JSON.stringify({
const __ocPluginSources = ${JSON.stringify({
"opencode:plugin-v2": promiseModule,
"opencode:plugin-v2-plugin": promisePluginModule,
"opencode:plugin-v2-tool": promiseToolModule,
"opencode:plugin-v2-effect": effectModule,
"opencode:plugin-v2-effect-plugin": effectPluginModule,
"opencode:plugin-v2-effect-tool": effectToolModule,

View file

@ -104,6 +104,12 @@ export type SessionMessageProviderState = { [x: string]: JsonValue }
export type SessionMessageToolStateStreaming = { status: "streaming"; input: string }
export type SessionMessageToolStateRunning = {
status: "running"
input: { [x: string]: JsonValue }
metadata: { [x: string]: JsonValue }
}
export type ToolTextContent = { type: "text"; text: string }
export type ToolFileContent = { type: "file"; uri: string; mime: string; name?: string }
@ -918,6 +924,15 @@ export type SessionToolInputDelta = {
data: { sessionID: string; assistantMessageID: string; callID: string; delta: string }
}
export type SessionToolProgress = {
id: string
created: number
metadata?: { [x: string]: any }
type: "session.tool.progress"
location?: LocationRef
data: { sessionID: string; assistantMessageID: string; callID: string; metadata: { [x: string]: JsonValue } }
}
export type SessionCompactionDelta = {
id: string
created: number
@ -1809,28 +1824,19 @@ export type SessionPendingUserData1 = {
metadata?: { [x: string]: any }
}
export type SessionMessageToolStateRunning = {
status: "running"
input: { [x: string]: JsonValue }
structured: { [x: string]: JsonValue }
content: Array<LLMToolContent>
}
export type SessionMessageToolStateCompleted = {
status: "completed"
input: { [x: string]: JsonValue }
content: Array<LLMToolContent>
structured: { [x: string]: JsonValue }
result?: JsonValue
content: [LLMToolContent, ...Array<LLMToolContent>]
metadata?: { [x: string]: JsonValue }
}
export type SessionMessageToolStateError = {
status: "error"
input: { [x: string]: JsonValue }
content: Array<LLMToolContent>
structured: { [x: string]: JsonValue }
error: SessionStructuredError
result?: JsonValue
content?: [LLMToolContent, ...Array<LLMToolContent>]
metadata?: { [x: string]: JsonValue }
}
export type SessionToolSuccess = {
@ -1838,15 +1844,14 @@ export type SessionToolSuccess = {
created: number
metadata?: { [x: string]: any }
type: "session.tool.success"
durable: { aggregateID: string; seq: number; version: 1 }
durable: { aggregateID: string; seq: number; version: 2 }
location?: LocationRef
data: {
sessionID: string
assistantMessageID: string
callID: string
structured: { [x: string]: any }
content: Array<LLMToolContent>
result?: any
content: [LLMToolContent, ...Array<LLMToolContent>]
metadata?: { [x: string]: JsonValue }
executed: boolean
resultState?: SessionMessageProviderState6
}
@ -1857,7 +1862,7 @@ export type SessionToolFailed = {
created: number
metadata?: { [x: string]: any }
type: "session.tool.failed"
durable: { aggregateID: string; seq: number; version: 1 }
durable: { aggregateID: string; seq: number; version: 2 }
location?: LocationRef
data: {
sessionID: string
@ -1865,28 +1870,12 @@ export type SessionToolFailed = {
callID: string
error: SessionStructuredError
content?: [LLMToolContent, ...Array<LLMToolContent>]
metadata?: { [x: string]: any }
result?: any
metadata?: { [x: string]: JsonValue }
executed: boolean
resultState?: SessionMessageProviderState7
}
}
export type SessionToolProgress = {
id: string
created: number
metadata?: { [x: string]: any }
type: "session.tool.progress"
location?: LocationRef
data: {
sessionID: string
assistantMessageID: string
callID: string
structured: { [x: string]: any }
content: Array<LLMToolContent>
}
}
export type SessionMessageCompaction =
| SessionMessageCompactionRunning
| SessionMessageCompactionCompleted

View file

@ -33,7 +33,7 @@ const lookupOrder = Tool.make({
description: "Look up an order by ID",
input: Schema.Struct({ id: Schema.String }),
output: Schema.Struct({ id: Schema.String, status: Schema.String }),
run: ({ id }) => Effect.succeed({ id, status: "open" }),
execute: ({ id }) => Effect.succeed({ id, status: "open" }),
})
const runtime = CodeMode.make({
@ -55,10 +55,10 @@ const result = await Effect.runPromise(
### `Tool.make`
`input` and `output` accept either an Effect Schema or a render-only JSON Schema document. Effect Schema input is
decoded before `run`; Effect Schema output is decoded and safely copied before the program sees it. JSON Schemas only
shape the model-visible signature. Without `output`, the signature uses `Promise<unknown>`.
decoded before `execute`; Effect Schema output is decoded and safely copied before the program sees it. JSON Schemas
only shape the model-visible signature. Without `output`, the signature uses `Promise<void>`.
Descriptions and schemas are model-visible contracts. Authorization belongs in `run`.
Descriptions and schemas are model-visible contracts. Authorization belongs in `execute`.
Dots in tool names create namespaces: `{ "issues.list": tool }` and `{ issues: { list: tool } }` both expose
`tools.issues.list(...)`. Other characters use bracket notation, such as

View file

@ -188,7 +188,7 @@ ultimate source of truth.
first-call-wins resolve/reject functions, and ignore throws after settlement. Inherited/accessor `then` fields
and a JavaScript `this` receiver remain outside the supported object/function model.
- [x] Dotted tool names are canonicalized into namespace paths; a path can be both callable and a namespace, and the
last definition supplied for a canonical path wins.
last tool supplied for a canonical path wins.
- [x] Tool path segments may be named `constructor`, `prototype`, or `__proto__` because paths use inert Map keys.
- [x] Outbound tool arguments follow JSON serialization semantics, like `JSON.stringify`: object properties with
`undefined` values are dropped, `undefined` array elements and non-finite numbers become `null`, and sparse

View file

@ -45,7 +45,7 @@ export const executeWithLimits = <const Provided extends Record<string, unknown>
const program = parseProgram(options.code)
const promises = new PromiseRuntime<Services<Provided>>(scope)
const interpreter = new Interpreter<Services<Provided>>(
tools.invoke,
tools.execute,
tools.search,
tools.keys,
promises,

View file

@ -271,7 +271,10 @@ const promiseResolutionNode: AstNode = { type: "PromiseResolution" }
export class Interpreter<R> {
private scopes: ScopeStack
private readonly invokeTool: (path: ReadonlyArray<string>, args: Array<unknown>) => Effect.Effect<unknown, unknown, R>
private readonly executeTool: (
path: ReadonlyArray<string>,
args: Array<unknown>,
) => Effect.Effect<unknown, unknown, R>
private readonly invokeSearch: (args: Array<unknown>) => Effect.Effect<unknown, unknown, R>
private readonly toolKeys: (path: ReadonlyArray<string>) => ReadonlyArray<string>
private readonly logs: Array<string>
@ -286,7 +289,7 @@ export class Interpreter<R> {
}
constructor(
invokeTool: (path: ReadonlyArray<string>, args: Array<unknown>) => Effect.Effect<unknown, unknown, R>,
executeTool: (path: ReadonlyArray<string>, args: Array<unknown>) => Effect.Effect<unknown, unknown, R>,
invokeSearch: (args: Array<unknown>) => Effect.Effect<unknown, unknown, R>,
toolKeys: (path: ReadonlyArray<string>) => ReadonlyArray<string>,
promises: PromiseRuntime<R>,
@ -294,7 +297,7 @@ export class Interpreter<R> {
) {
const globalScope = new Map<string, Binding>()
this.scopes = new ScopeStack([globalScope])
this.invokeTool = invokeTool
this.executeTool = executeTool
this.invokeSearch = invokeSearch
this.toolKeys = toolKeys
this.logs = logs
@ -369,7 +372,7 @@ export class Interpreter<R> {
path: ReadonlyArray<string>,
args: Array<unknown>,
): Effect.Effect<CodeModePromise, never, R> {
return this.createPromise(Effect.suspend(() => this.invokeTool(path, args)))
return this.createPromise(Effect.suspend(() => this.executeTool(path, args)))
}
private createPromise(effect: Effect.Effect<unknown, unknown, R>): Effect.Effect<CodeModePromise, never, R> {
@ -2079,7 +2082,7 @@ export class Interpreter<R> {
}
private invokeFunction(fn: CodeModeFunction, args: Array<unknown>): Effect.Effect<unknown, unknown, R> {
const invocation = new Interpreter(this.invokeTool, this.invokeSearch, this.toolKeys, this.promises, this.logs)
const invocation = new Interpreter(this.executeTool, this.invokeSearch, this.toolKeys, this.promises, this.logs)
invocation.scopes = new ScopeStack([...fn.capturedScopes, new Map()])
const run = Effect.gen(function* () {
// Seed all parameters first so defaults cannot fall through to same-named outer bindings.

View file

@ -1,5 +1,5 @@
import { HttpClient } from "effect/unstable/http"
import { make, type Definition } from "../tool.js"
import { make, type Tool } from "../tool.js"
import { invoke } from "./runtime.js"
import {
componentDefinitions,
@ -108,7 +108,7 @@ export const fromSpec = (options: Options): Result => {
description: operation.description ?? operation.summary ?? `${operation.method} ${path}`,
input: inputSchema(input.fields, requestDefinitions),
output: output.value,
run: (input) => invoke(plan, input),
execute: (input) => invoke(plan, input),
}),
)
}
@ -117,16 +117,16 @@ export const fromSpec = (options: Options): Result => {
return { tools, skipped }
}
const setTool = (tools: Tools, path: ReadonlyArray<string>, definition: Definition<HttpClient.HttpClient>): void => {
const setTool = (tools: Tools, path: ReadonlyArray<string>, tool: Tool<HttpClient.HttpClient>): void => {
const [head, ...rest] = path
if (head === undefined) return
if (rest.length === 0) {
tools[head] = definition
tools[head] = tool
return
}
const child = tools[head]
if (child === undefined || !isRecord(child) || child._tag === "CodeModeTool") {
tools[head] = Object.create(null) as Tools
}
setTool(tools[head] as Tools, rest, definition)
setTool(tools[head] as Tools, rest, tool)
}

View file

@ -1,6 +1,6 @@
import { Effect } from "effect"
import { HttpClient } from "effect/unstable/http"
import type { Definition, JsonSchema } from "../tool.js"
import type { Tool, JsonSchema } from "../tool.js"
/** A parsed OpenAPI 3.x document. YAML must be parsed by the host. */
export type Document = Record<string, unknown>
@ -58,7 +58,7 @@ export type Skipped = {
readonly reason: string
}
export type Tools = { [name: string]: Definition<HttpClient.HttpClient> | Tools }
export type Tools = { [name: string]: Tool<HttpClient.HttpClient> | Tools }
export type Result = {
/** Namespaced tools; the host places them under a key in its `tools` object. */

View file

@ -8,7 +8,7 @@ import {
inputTypeScript,
outputTypeScript,
} from "./tool-schema.js"
import { isDefinition as isToolDefinition, type Definition } from "./tool.js"
import { isTool, type Tool } from "./tool.js"
import type { Tools } from "./tools.js"
import {
CodeModeDate,
@ -28,7 +28,7 @@ type ServicesOf<T, Depth extends ReadonlyArray<unknown>> = Depth["length"] exten
? never
: T extends {
readonly _tag: "CodeModeTool"
readonly run: (input: unknown) => Effect.Effect<unknown, unknown, infer R>
readonly execute: (input: unknown) => Effect.Effect<unknown, unknown, infer R>
}
? R
: T extends object
@ -118,8 +118,6 @@ export class ToolRuntimeError extends Error {
}
}
const isDefinition = <R>(value: Definition<R> | Tools<R>): value is Definition<R> => isToolDefinition<R>(value)
const runHost = <A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, ToolError, R> =>
effect.pipe(
Effect.catchCause((cause) => {
@ -286,9 +284,9 @@ export const copyOut = (value: unknown, mode: CopyOutMode): unknown => {
return value
}
// Dots in tool names are namespace separators; the last definition for a canonical path wins.
// Dots in tool names are namespace separators; the last tool for a canonical path wins.
type ToolNode<R> = {
definition?: Definition<R>
tool?: Tool<R>
readonly children: Map<string, ToolNode<R>>
}
@ -303,7 +301,7 @@ const toolTrie = <R>(tools: Tools<R>): ToolNode<R> => {
current.children.set(segment, child)
current = child
}
if (isDefinition(value)) current.definition = value
if (isTool<R>(value)) current.tool = value
else insert(current, value)
}
}
@ -314,25 +312,25 @@ const toolTrie = <R>(tools: Tools<R>): ToolNode<R> => {
const canonicalSegments = (path: ReadonlyArray<string>): ReadonlyArray<string> =>
path.flatMap((segment) => segment.split("."))
const definitions = <R>(
const flattenTools = <R>(
node: ToolNode<R>,
path: ReadonlyArray<string> = [],
): Array<{ path: string; definition: Definition<R> }> => [
...(node.definition === undefined ? [] : [{ path: path.join("."), definition: node.definition }]),
...Array.from(node.children, ([name, child]) => definitions(child, [...path, name])).flat(),
): Array<{ path: string; tool: Tool<R> }> => [
...(node.tool === undefined ? [] : [{ path: path.join("."), tool: node.tool }]),
...Array.from(node.children, ([name, child]) => flattenTools(child, [...path, name])).flat(),
]
const describeDefinition = <R>(path: string, definition: Definition<R>): ToolDescription => ({
const describeTool = <R>(path: string, tool: Tool<R>): ToolDescription => ({
path,
description: definition.description,
signature: `${toolExpression(path)}(input: ${inputTypeScript(definition, true)}): Promise<${outputTypeScript(definition, true)}>`,
description: tool.description,
signature: `${toolExpression(path)}(input: ${inputTypeScript(tool, true)}): Promise<${outputTypeScript(tool, true)}>`,
})
const visibleDefinitions = <R>(tools: Tools<R>) =>
definitions(toolTrie(tools)).map(({ path, definition }) => ({
const visibleTools = <R>(tools: Tools<R>) =>
flattenTools(toolTrie(tools)).map(({ path, tool }) => ({
path,
definition,
description: describeDefinition(path, definition),
tool,
description: describeTool(path, tool),
}))
export type DiscoveryPlan = {
@ -361,12 +359,12 @@ const termForms = (term: string): Array<string> => {
return forms
}
const makeSearchTool = (searchIndex: ReadonlyArray<SearchEntry>): Definition => ({
const makeSearchTool = (searchIndex: ReadonlyArray<SearchEntry>): Tool => ({
_tag: "CodeModeTool",
description: "Search available tools",
input: SearchInput,
output: SearchOutput,
run: (input) =>
execute: (input) =>
Effect.sync(() => {
const request = input as typeof SearchInput.Type
const query = request.query ?? ""
@ -422,8 +420,8 @@ const makeSearchTool = (searchIndex: ReadonlyArray<SearchEntry>): Definition =>
})
const searchSignature = (() => {
const definition = makeSearchTool([])
return `search(input: ${inputTypeScript(definition, true)}): ${outputTypeScript(definition, true)}`
const tool = makeSearchTool([])
return `search(input: ${inputTypeScript(tool, true)}): ${outputTypeScript(tool, true)}`
})()
const catalogLine = (tool: ToolDescription) => {
@ -432,13 +430,13 @@ const catalogLine = (tool: ToolDescription) => {
return description === "" ? ` - ${tool.signature}` : ` - ${tool.signature} // ${description}`
}
const toSearchEntry = <R>(path: string, definition: Definition<R>, description: ToolDescription): SearchEntry => ({
const toSearchEntry = <R>(path: string, tool: Tool<R>, description: ToolDescription): SearchEntry => ({
description,
namespace: path.split(".", 1)[0]!,
searchText: [
path,
definition.description,
...inputProperties(definition).flatMap(({ name, description: property }) =>
tool.description,
...inputProperties(tool).flatMap(({ name, description: property }) =>
property === undefined ? [name] : [name, property],
),
]
@ -447,14 +445,14 @@ const toSearchEntry = <R>(path: string, definition: Definition<R>, description:
})
export const searchIndex = <R>(tools: Tools<R>): ReadonlyArray<SearchEntry> =>
visibleDefinitions(tools).map(({ path, definition, description }) => toSearchEntry(path, definition, description))
visibleTools(tools).map(({ path, tool, description }) => toSearchEntry(path, tool, description))
// Budget signatures round-robin so every namespace remains visible.
export const prepare = <R>(tools: Tools<R>, catalogBudget = defaultCatalogBudget): DiscoveryPlan => {
if (!Number.isSafeInteger(catalogBudget) || catalogBudget < 0) {
throw new RangeError("discovery.catalogBudget must be a non-negative safe integer")
}
const visible = visibleDefinitions(tools)
const visible = visibleTools(tools)
const described = visible.map(({ description }) => description)
const namespaces = new Map<string, Array<ToolDescription>>()
@ -589,7 +587,7 @@ export const prepare = <R>(tools: Tools<R>, catalogBudget = defaultCatalogBudget
return {
catalog: described,
instructions: lines.join("\n"),
searchIndex: visible.map(({ path, definition, description }) => toSearchEntry(path, definition, description)),
searchIndex: visible.map(({ path, tool, description }) => toSearchEntry(path, tool, description)),
}
}
@ -605,7 +603,7 @@ const namespaceKeys = <R>(root: ToolNode<R>, path: ReadonlyArray<string>): Reado
return Array.from(node.children.keys())
}
const resolve = <R>(root: ToolNode<R>, path: ReadonlyArray<string>): Definition<R> => {
const resolve = <R>(root: ToolNode<R>, path: ReadonlyArray<string>): Tool<R> => {
const segments = canonicalSegments(path)
const node = lookup(root, segments)
if (node === undefined) {
@ -613,16 +611,16 @@ const resolve = <R>(root: ToolNode<R>, path: ReadonlyArray<string>): Definition<
"Use search({ query }) to find available described tools.",
])
}
if (node.definition === undefined) {
if (node.tool === undefined) {
throw new ToolRuntimeError("UnknownTool", `Tool '${segments.join(".")}' is not callable.`)
}
return node.definition
return node.tool
}
export type ToolRuntime<R = never> = {
readonly root: ToolReference
readonly calls: Array<ToolCall>
readonly invoke: (path: ReadonlyArray<string>, args: Array<unknown>) => Effect.Effect<unknown, unknown, R>
readonly execute: (path: ReadonlyArray<string>, args: Array<unknown>) => Effect.Effect<unknown, unknown, R>
readonly search: (args: Array<unknown>) => Effect.Effect<unknown, unknown, R>
readonly keys: (path: ReadonlyArray<string>) => ReadonlyArray<string>
}
@ -676,7 +674,7 @@ export const make = <R>(
return calls.length - 1
}).pipe(Effect.tap((index) => hooks?.onToolCallStart?.({ index, name, input }) ?? Effect.void))
const invokeDefinition = (name: string, tool: Definition<R>, externalArgs: Array<unknown>) =>
const executeTool = (name: string, tool: Tool<R>, externalArgs: Array<unknown>) =>
Effect.gen(function* () {
if (externalArgs.length !== 1)
throw new ToolRuntimeError("InvalidToolInput", `Tool '${name}' expects exactly one input object.`)
@ -688,7 +686,7 @@ export const make = <R>(
const index = yield* recordAndObserve(name, input)
return yield* observeEnd(
Effect.gen(function* () {
const raw = yield* runHost(Effect.suspend(() => tool.run(input)))
const raw = yield* runHost(Effect.suspend(() => tool.execute(input)))
const result = yield* Effect.try({
try: () => decodeToolOutput(tool, raw),
catch: () => new ToolRuntimeError("InvalidToolOutput", `Invalid output from tool '${name}'.`),
@ -705,18 +703,18 @@ export const make = <R>(
keys: (path) => namespaceKeys(root, path),
search: (args) =>
Effect.suspend(() =>
invokeDefinition(
executeTool(
"search",
searchTool,
args.map((arg) => copyOut(copyIn(arg, "Arguments for tool 'search'"), "json")),
),
),
invoke: (path, args) =>
execute: (path, args) =>
Effect.gen(function* () {
const name = canonicalSegments(path).join(".")
const externalArgs = args.map((arg) => copyOut(copyIn(arg, `Arguments for tool '${name}'`), "json"))
const tool = resolve(root, path)
return yield* invokeDefinition(name, tool, externalArgs)
return yield* executeTool(name, tool, externalArgs)
}),
}
}

View file

@ -1,5 +1,5 @@
import { JsonPointer, Schema } from "effect"
import type { Definition, JsonSchema, SchemaType } from "./tool.js"
import type { Tool, JsonSchema, SchemaType } from "./tool.js"
const isEffectSchema = (schema: SchemaType): schema is Schema.Decoder<unknown> & Schema.Top => Schema.isSchema(schema)
@ -192,16 +192,16 @@ export type InputProperty = {
readonly required: boolean
}
export const inputProperties = <R>(definition: Definition<R>): Array<InputProperty> => {
export const inputProperties = <R>(tool: Tool<R>): Array<InputProperty> => {
try {
const document = isEffectSchema(definition.input)
? (Schema.toJsonSchemaDocument(definition.input) as {
const document = isEffectSchema(tool.input)
? (Schema.toJsonSchemaDocument(tool.input) as {
readonly schema: JsonSchema
readonly definitions?: Readonly<Record<string, JsonSchema>>
})
: {
schema: definition.input,
definitions: { ...(definition.input.definitions ?? {}), ...(definition.input.$defs ?? {}) },
schema: tool.input,
definitions: { ...(tool.input.definitions ?? {}), ...(tool.input.$defs ?? {}) },
}
const definitions = document.definitions ?? {}
let schema = document.schema
@ -223,22 +223,22 @@ export const inputProperties = <R>(definition: Definition<R>): Array<InputProper
}
}
export const inputTypeScript = <R>(definition: Definition<R>, pretty = false): string =>
isEffectSchema(definition.input)
? toTypeScript(definition.input, false, pretty)
: jsonSchemaToTypeScript(definition.input, pretty)
export const inputTypeScript = <R>(tool: Tool<R>, pretty = false): string =>
isEffectSchema(tool.input) ? toTypeScript(tool.input, false, pretty) : jsonSchemaToTypeScript(tool.input, pretty)
export const outputTypeScript = <R>(definition: Definition<R>, pretty = false): string =>
definition.output === undefined
? "unknown"
: isEffectSchema(definition.output)
? toTypeScript(definition.output, true, pretty)
: jsonSchemaToTypeScript(definition.output, pretty)
export const outputTypeScript = <R>(tool: Tool<R>, pretty = false): string =>
tool.output === undefined
? "void"
: isEffectSchema(tool.output)
? toTypeScript(tool.output, true, pretty)
: jsonSchemaToTypeScript(tool.output, pretty)
export const decodeInput = <R>(definition: Definition<R>, value: unknown): unknown =>
isEffectSchema(definition.input) ? Schema.decodeUnknownSync(definition.input)(value) : value
export const decodeInput = <R>(tool: Tool<R>, value: unknown): unknown =>
isEffectSchema(tool.input) ? Schema.decodeUnknownSync(tool.input)(value) : value
export const decodeOutput = <R>(definition: Definition<R>, value: unknown): unknown =>
definition.output !== undefined && isEffectSchema(definition.output)
? Schema.decodeUnknownSync(definition.output)(value)
: value
export const decodeOutput = <R>(tool: Tool<R>, value: unknown): unknown =>
tool.output === undefined
? undefined
: isEffectSchema(tool.output)
? Schema.decodeUnknownSync(tool.output)(value)
: value

View file

@ -29,29 +29,29 @@ export type JsonSchema = {
/** Either a validating Effect Schema or a render-only JSON Schema document. */
export type SchemaType = Schema.Decoder<unknown> | JsonSchema
/** Schema-backed tool definition exposed through CodeMode's `tools` object. */
export type Definition<R = never> = {
/** Executable tool tool exposed through CodeMode's `tools` object. */
export type Tool<R = never> = {
readonly _tag: "CodeModeTool"
readonly description: string
readonly input: SchemaType
readonly output: SchemaType | undefined
readonly run: (input: unknown) => Effect.Effect<unknown, unknown, R>
readonly execute: (input: unknown) => Effect.Effect<unknown, unknown, R>
}
type InputType<S> = S extends Schema.Decoder<unknown> ? S["Type"] : unknown
type ResultType<S> = S extends Schema.Decoder<unknown> ? S["Encoded"] : unknown
type ResultType<S> = S extends undefined ? void : S extends Schema.Decoder<unknown> ? S["Encoded"] : unknown
/** Options for defining one CodeMode tool. */
/** Options for declaring one CodeMode tool. */
export type Options<I extends SchemaType, O extends SchemaType | undefined, R = never> = {
readonly description: string
readonly input: I
readonly output?: O
readonly run: (input: InputType<I>) => Effect.Effect<ResultType<O>, unknown, R>
readonly execute: (input: InputType<I>) => Effect.Effect<ResultType<O>, unknown, R>
}
// Object.hasOwn: an inherited _tag must not classify a namespace as a Definition.
export const isDefinition = <R = never>(value: unknown): value is Definition<R> =>
// Object.hasOwn: an inherited _tag must not classify a namespace as a Tool.
export const isTool = <R = never>(value: unknown): value is Tool<R> =>
typeof value === "object" &&
value !== null &&
"_tag" in value &&
@ -59,18 +59,18 @@ export const isDefinition = <R = never>(value: unknown): value is Definition<R>
value._tag === "CodeModeTool"
/**
* Defines one schema-described tool available to a CodeMode program through `tools.*`.
* Declares one schema-described tool available to a CodeMode program through `tools.*`.
*
* Effect Schemas validate values; JSON Schemas only shape the model-visible signature.
* Without `output`, results are exposed as `unknown`. Hosts remain responsible for authorization
* Without `output`, results are exposed as `void`. Hosts remain responsible for authorization
* and durable side effects.
*/
export const make = <I extends SchemaType, const O extends SchemaType | undefined = undefined, R = never>(
options: Options<I, O, R>,
): Definition<R> => ({
): Tool<R> => ({
_tag: "CodeModeTool",
description: options.description,
input: options.input,
output: options.output,
run: (input) => options.run(input as InputType<I>),
execute: (input) => options.execute(input as InputType<I>),
})

View file

@ -1,5 +1,5 @@
import type { Definition } from "./tool.js"
import type { Tool } from "./tool.js"
export type Tools<R = never> = {
readonly [name: string]: Definition<R> | Tools<R>
readonly [name: string]: Tool<R> | Tools<R>
}

View file

@ -27,7 +27,7 @@ const echo = Tool.make({
description: "Echo the input",
input: Schema.Struct({ id: Schema.Number }),
output: Schema.Number,
run: (input: { id: number }) => Effect.succeed(input.id),
execute: (input: { id: number }) => Effect.succeed(input.id),
})
const withTool = (code: string) => Effect.runPromise(CodeMode.make({ tools: { host: { echo } } }).execute(code))
const toolError = async (code: string) => {

View file

@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test"
import { Cause, Effect, Schema } from "effect"
import { CodeMode, Tool, toolError } from "../src/index.js"
const run = (tool: Tool.Definition<never>) =>
const run = (tool: Tool.Tool<never>) =>
Effect.runPromise(CodeMode.make({ tools: { host: { call: tool } } }).execute("return await tools.host.call({})"))
class UnsafeHostError extends Schema.TaggedErrorClass<UnsafeHostError>()("UnsafeHostError", {
@ -16,7 +16,7 @@ describe("CodeMode host failure boundary", () => {
description: "Fail safely",
input: Schema.Struct({}),
output: Schema.String,
run: () => Effect.fail(toolError("Authorized request was refused")),
execute: () => Effect.fail(toolError("Authorized request was refused")),
}),
)
@ -32,7 +32,7 @@ describe("CodeMode host failure boundary", () => {
description: "Fail safely",
input: Schema.Struct({}),
output: Schema.String,
run: () => Effect.fail(toolError("File not found: /tmp/report.json")),
execute: () => Effect.fail(toolError("File not found: /tmp/report.json")),
}),
)
@ -52,7 +52,7 @@ describe("CodeMode host failure boundary", () => {
description: "Fail internally",
input: Schema.Struct({}),
output: Schema.String,
run: () => failure,
execute: () => failure,
}),
)
@ -71,7 +71,7 @@ describe("CodeMode host failure boundary", () => {
description: "Return invalid output",
input: Schema.Struct({}),
output: Schema.Struct({ safe: Schema.String }),
run: () => Effect.succeed({ safe: 1, secret } as unknown as { readonly safe: string }),
execute: () => Effect.succeed({ safe: 1, secret } as unknown as { readonly safe: string }),
}),
)
@ -88,7 +88,7 @@ describe("CodeMode host failure boundary", () => {
description: "Return hostile output",
input: Schema.Struct({}),
output: Schema.Unknown,
run: () =>
execute: () =>
Effect.succeed(
new Proxy(
{},
@ -118,7 +118,7 @@ describe("CodeMode host failure boundary", () => {
description: "Refuse",
input: Schema.Struct({}),
output: Schema.String,
run: () => Effect.fail(toolError("Refused")),
execute: () => Effect.fail(toolError("Refused")),
}),
},
},
@ -145,7 +145,7 @@ describe("CodeMode host failure boundary", () => {
description: "Interrupt",
input: Schema.Struct({}),
output: Schema.String,
run: () => Effect.interrupt,
execute: () => Effect.interrupt,
}),
},
},
@ -166,7 +166,7 @@ describe("CodeMode tool-call observation", () => {
description: "Look up a value",
input: Schema.Struct({ query: Schema.String }),
output: Schema.String,
run: ({ query }) => Effect.succeed(query),
execute: ({ query }) => Effect.succeed(query),
})
const result = await Effect.runPromise(
@ -189,7 +189,7 @@ describe("CodeMode tool-call observation", () => {
description: "Look up a value",
input: Schema.Struct({ query: Schema.String }),
output: Schema.String,
run: ({ query }) => (query === "boom" ? Effect.fail(toolError("Lookup refused")) : Effect.succeed(query)),
execute: ({ query }) => (query === "boom" ? Effect.fail(toolError("Lookup refused")) : Effect.succeed(query)),
})
const runtime = CodeMode.make({
@ -430,7 +430,7 @@ describe("CodeMode schema flexibility", () => {
properties: { id: { type: "string" }, count: { type: "number" } },
required: ["id"],
},
run: (input) =>
execute: (input) =>
Effect.sync(() => {
observed.push(input)
return { echoed: input }
@ -442,14 +442,14 @@ describe("CodeMode schema flexibility", () => {
{
path: "adapter.call",
description: "Call an adapter-described tool",
signature: "tools.adapter.call(input: {\n id: string,\n count?: number,\n}): Promise<unknown>",
signature: "tools.adapter.call(input: {\n id: string,\n count?: number,\n}): Promise<void>",
},
])
// JSON Schema is render-only: mistyped input passes through unvalidated.
const result = await Effect.runPromise(runtime.execute(`return await tools.adapter.call({ id: 42 })`))
expect(result.ok).toBe(true)
if (result.ok) expect(result.value).toStrictEqual({ echoed: { id: 42 } })
if (result.ok) expect(result.value).toBeNull()
expect(observed).toStrictEqual([{ id: 42 }])
})
@ -458,7 +458,7 @@ describe("CodeMode schema flexibility", () => {
const call = Tool.make({
description: "Observe raw input",
input: { type: "object" },
run: (input) =>
execute: (input) =>
Effect.sync(() => {
observed.push(input)
return "ok"
@ -483,7 +483,7 @@ describe("CodeMode schema flexibility", () => {
const find = Tool.make({
description: "Find things",
input: Schema.Struct({ query: Schema.optionalKey(Schema.String), limit: Schema.optionalKey(Schema.Number) }),
run: (input) =>
execute: (input) =>
Effect.sync(() => {
observed.push(input)
return "ok"
@ -517,7 +517,7 @@ describe("CodeMode schema flexibility", () => {
},
},
},
run: () => Effect.succeed({ login: "kit", id: 7 }),
execute: () => Effect.succeed({ login: "kit", id: 7 }),
})
const runtime = CodeMode.make({ tools: { users: { lookup } } })
@ -534,18 +534,18 @@ describe("CodeMode schema flexibility", () => {
if (result.ok) expect(result.value).toStrictEqual({ login: "kit", id: 7 })
})
test("Effect Schema output without an input transform still renders unknown when omitted", async () => {
test("Effect Schema output without an input transform renders void when omitted", async () => {
const ping = Tool.make({
description: "Ping",
input: Schema.Struct({ host: Schema.String }),
run: () => Effect.succeed("pong"),
execute: () => Effect.succeed("pong"),
})
const runtime = CodeMode.make({ tools: { net: { ping } } })
expect(runtime.catalog()[0]?.signature).toBe("tools.net.ping(input: {\n host: string,\n}): Promise<unknown>")
expect(runtime.catalog()[0]?.signature).toBe("tools.net.ping(input: {\n host: string,\n}): Promise<void>")
const result = await Effect.runPromise(runtime.execute(`return await tools.net.ping({ host: "example.test" })`))
expect(result.ok).toBe(true)
if (result.ok) expect(result.value).toBe("pong")
if (result.ok) expect(result.value).toBeNull()
})
})
@ -554,7 +554,7 @@ describe("CodeMode public contract", () => {
description: "Look up an order by ID",
input: Schema.Struct({ id: Schema.String }),
output: Schema.Struct({ id: Schema.String, status: Schema.String }),
run: ({ id }) => Effect.succeed({ id, status: "open" }),
execute: ({ id }) => Effect.succeed({ id, status: "open" }),
})
const tools = { orders: { lookup } }
const source = `return await tools.orders.lookup({ id: "order_42" })`
@ -577,7 +577,7 @@ describe("CodeMode public contract", () => {
description: "echo",
input: Schema.Struct({}),
output: Schema.Number,
run: () => Effect.succeed(1),
execute: () => Effect.succeed(1),
})
const effect = CodeMode.execute({
tools: { host: { echo } },
@ -634,7 +634,7 @@ describe("CodeMode public contract", () => {
description: "Resolve a library ID",
input: Schema.Struct({ libraryName: Schema.String }),
output: Schema.String,
run: ({ libraryName }) => Effect.succeed(`/resolved/${libraryName}`),
execute: ({ libraryName }) => Effect.succeed(`/resolved/${libraryName}`),
})
const runtime = CodeMode.make({ tools: { context7: { "resolve-library-id": resolveLibrary } } })
@ -760,18 +760,18 @@ describe("CodeMode public contract", () => {
expect(instructions).not.toContain("search(")
})
test("uses one ranked search returning complete definitions for large catalogs", async () => {
test("uses one ranked search returning complete tools for large catalogs", async () => {
const upload = Tool.make({
description: "Upload one readable local file to the current Discord thread",
input: Schema.Struct({ path: Schema.String }),
output: Schema.Struct({ sent: Schema.Boolean }),
run: () => Effect.succeed({ sent: true }),
execute: () => Effect.succeed({ sent: true }),
})
const generate = Tool.make({
description: "Generate an image and upload it to the current Discord thread",
input: Schema.Struct({ prompt: Schema.String }),
output: Schema.Struct({ sent: Schema.Boolean }),
run: () => Effect.succeed({ sent: true }),
execute: () => Effect.succeed({ sent: true }),
})
const runtime = CodeMode.make({
tools: { thread: { uploadFile: upload, generateImage: generate }, orders: { lookup } },
@ -865,7 +865,7 @@ describe("CodeMode public contract", () => {
description: `Numbered tool ${index}`,
input: Schema.Struct({ id: Schema.String }),
output: Schema.String,
run: () => Effect.succeed("ok"),
execute: () => Effect.succeed("ok"),
})
const runtime = CodeMode.make({
tools: {
@ -911,7 +911,7 @@ describe("CodeMode public contract", () => {
description,
input: Schema.Struct({ id: Schema.String }),
output: Schema.String,
run: () => Effect.succeed("ok"),
execute: () => Effect.succeed("ok"),
})
const runtime = CodeMode.make({
tools: {
@ -954,13 +954,13 @@ describe("CodeMode public contract", () => {
properties: { attachment: { type: "string", description: "Local path of the payload to send" } },
required: ["attachment"],
},
run: () => Effect.succeed("ok"),
execute: () => Effect.succeed("ok"),
})
const other = Tool.make({
description: "Rename the workspace",
input: Schema.Struct({ name: Schema.String }),
output: Schema.String,
run: () => Effect.succeed("ok"),
execute: () => Effect.succeed("ok"),
})
const runtime = CodeMode.make({ tools: { files: { upload, other } } })
@ -990,7 +990,7 @@ describe("CodeMode public contract", () => {
description,
input: Schema.Struct({ id: Schema.String }),
output: Schema.String,
run: () => Effect.succeed("ok"),
execute: () => Effect.succeed("ok"),
})
const runtime = CodeMode.make({
tools: {
@ -1029,7 +1029,7 @@ describe("CodeMode public contract", () => {
description,
input: Schema.Struct({}),
output: Schema.String,
run: () => Effect.succeed("ok"),
execute: () => Effect.succeed("ok"),
})
// Deliberately declared out of alphabetical order.
const runtime = CodeMode.make({
@ -1071,7 +1071,7 @@ describe("CodeMode public contract", () => {
description: "Cheap",
input: Schema.Struct({ q: Schema.String }),
output: Schema.String,
run: () => Effect.succeed("ok"),
execute: () => Effect.succeed("ok"),
})
const expensive = Tool.make({
description:
@ -1081,7 +1081,7 @@ describe("CodeMode public contract", () => {
anotherEvenLongerParameterName: Schema.Number,
}),
output: Schema.String,
run: () => Effect.succeed("ok"),
execute: () => Effect.succeed("ok"),
})
// Round 1 places alpha.cheap (~17 estimated tokens) and beta.cheap (~17); in round 2
// alpha.expensive does not fit, which marks only alpha done - it must NOT prevent
@ -1112,7 +1112,7 @@ describe("CodeMode public contract", () => {
},
required: ["id"],
} as const,
run: () => Effect.succeed("ok"),
execute: () => Effect.succeed("ok"),
})
const runtime = CodeMode.make({
tools: { records: { lookup: documented } },
@ -1130,7 +1130,7 @@ describe("CodeMode public contract", () => {
description: "Double a number",
input: Schema.Struct({ value: Schema.NumberFromString }),
output: Schema.NumberFromString,
run: ({ value }) =>
execute: ({ value }) =>
Effect.sync(() => {
observed.push(value)
return String(value * 2)
@ -1226,7 +1226,7 @@ describe("CodeMode public contract", () => {
description: "Count invocations",
input: Schema.Struct({}),
output: Schema.Number,
run: () => Effect.succeed(1),
execute: () => Effect.succeed(1),
})
const result = await Effect.runPromise(
CodeMode.execute({

View file

@ -13,7 +13,7 @@ const echo = (description: string) =>
description,
input: Schema.Struct({ value: Schema.String }),
output: Schema.String,
run: ({ value }) => Effect.succeed(value),
execute: ({ value }) => Effect.succeed(value),
})
const tools = {

View file

@ -143,12 +143,7 @@ describe("OpenAPI.fromSpec", () => {
const remove = toolAt(api.tools, "users.remove")
expect(api.skipped).toEqual([])
if (
!Tool.isDefinition(get) ||
!Tool.isDefinition(create) ||
!Tool.isDefinition(search) ||
!Tool.isDefinition(remove)
) {
if (!Tool.isTool(get) || !Tool.isTool(create) || !Tool.isTool(search) || !Tool.isTool(remove)) {
throw new Error("happy-path fixture did not generate every operation")
}
expect(inputTypeScript(get)).toBe(
@ -241,23 +236,23 @@ describe("OpenAPI.fromSpec", () => {
expect(toolAt(result.tools, "v2.session.create")).not.toBeUndefined()
const sessionGet = toolAt(result.tools, "v2.session.get")
expect(Tool.isDefinition(sessionGet)).toBe(true)
if (!Tool.isDefinition(sessionGet)) throw new Error("v2.session.get was not generated")
expect(Tool.isTool(sessionGet)).toBe(true)
if (!Tool.isTool(sessionGet)) throw new Error("v2.session.get was not generated")
expect(inputTypeScript(sessionGet)).toBe("{ sessionID: string }")
expect(outputTypeScript(sessionGet)).toContain("id: string")
expect(outputTypeScript(sessionGet)).toContain("additions: number")
const switchAgent = toolAt(result.tools, "v2.session.switchAgent")
expect(Tool.isDefinition(switchAgent)).toBe(true)
if (!Tool.isDefinition(switchAgent)) throw new Error("v2.session.switchAgent was not generated")
expect(Tool.isTool(switchAgent)).toBe(true)
if (!Tool.isTool(switchAgent)) throw new Error("v2.session.switchAgent was not generated")
expect(inputTypeScript(switchAgent)).toBe("{ sessionID: string; agent: string }")
const instructionPut = toolAt(result.tools, "v2.session.instructions.entry.put")
expect(Tool.isDefinition(instructionPut)).toBe(true)
if (!Tool.isDefinition(instructionPut)) throw new Error("v2.session.instructions.entry.put was not generated")
expect(Tool.isTool(instructionPut)).toBe(true)
if (!Tool.isTool(instructionPut)) throw new Error("v2.session.instructions.entry.put was not generated")
expect(inputTypeScript(instructionPut)).toBe("{ sessionID: string; key: string; value: unknown }")
expect(toolAt(result.tools, "v2_session_instructions_entry_put_2")).toBeUndefined()
expect(Tool.isDefinition(toolAt(result.tools, "v2.pty.connect"))).toBe(false)
expect(Tool.isTool(toolAt(result.tools, "v2.pty.connect"))).toBe(false)
expect(toolAt(result.tools, "v2.session.log")).toBeUndefined()
expect(toolAt(result.tools, "v2.event.subscribe")).toBeUndefined()
expect(toolAt(result.tools, "v2.fs.read")).toBeUndefined()
@ -278,9 +273,9 @@ describe("OpenAPI.fromSpec", () => {
},
})
expect(Tool.isDefinition(toolAt(result.tools, "group.item"))).toBe(true)
expect(Tool.isDefinition(toolAt(result.tools, "group_item_2"))).toBe(true)
expect(Tool.isDefinition(toolAt(result.tools, "group.operation.other"))).toBe(true)
expect(Tool.isTool(toolAt(result.tools, "group.item"))).toBe(true)
expect(Tool.isTool(toolAt(result.tools, "group_item_2"))).toBe(true)
expect(Tool.isTool(toolAt(result.tools, "group.operation.other"))).toBe(true)
})
test("synthesizes flat operation IDs from methods and paths", () => {
@ -305,7 +300,7 @@ describe("OpenAPI.fromSpec", () => {
"deleteUsersById",
"getOrganizationsByOrganizationidUsersById",
]) {
expect(Tool.isDefinition(toolAt(tools, path))).toBe(true)
expect(Tool.isTool(toolAt(tools, path))).toBe(true)
}
})
@ -330,7 +325,7 @@ describe("OpenAPI.fromSpec", () => {
"test",
)
if (!Tool.isDefinition(tool)) throw new Error("test was not generated")
if (!Tool.isTool(tool)) throw new Error("test was not generated")
expect(inputTypeScript(tool)).toBe("{ limit: number }")
})
@ -358,8 +353,8 @@ describe("OpenAPI.fromSpec", () => {
})
const search = toolAt(result.tools, "search")
expect(Tool.isDefinition(search)).toBe(true)
if (!Tool.isDefinition(search)) throw new Error("search was not generated")
expect(Tool.isTool(search)).toBe(true)
if (!Tool.isTool(search)) throw new Error("search was not generated")
expect(inputTypeScript(search)).toBe("{ value?: string | null }")
const schema: unknown = search.input
const input = isRecord(schema) ? schema : {}
@ -397,14 +392,14 @@ describe("OpenAPI.fromSpec", () => {
"test",
)
if (!Tool.isDefinition(tool) || !isRecord(tool.output)) throw new Error("test output was not generated")
if (!Tool.isTool(tool) || !isRecord(tool.output)) throw new Error("test output was not generated")
expect(tool.output.$defs).toMatchObject({ Local: { type: "string" }, Global: { type: "number" } })
})
test("projects read-only and write-only properties by schema direction", () => {
for (const version of ["3.0.3", "3.1.0"]) {
const tool = toolAt(OpenAPI.fromSpec({ baseUrl, spec: directionalSpec(version) }).tools, "users.create")
if (!Tool.isDefinition(tool) || !isRecord(tool.input) || !isRecord(tool.output)) {
if (!Tool.isTool(tool) || !isRecord(tool.input) || !isRecord(tool.output)) {
throw new Error(`users.create was not generated for OpenAPI ${version}`)
}
@ -467,7 +462,7 @@ describe("OpenAPI.fromSpec", () => {
}).tools,
"test",
)
if (!Tool.isDefinition(tool)) throw new Error("test was not generated")
if (!Tool.isTool(tool)) throw new Error("test was not generated")
expect(inputTypeScript(tool)).toBe("{ name: string }")
})
@ -518,7 +513,7 @@ describe("OpenAPI.fromSpec", () => {
}).tools,
"test",
)
if (!Tool.isDefinition(tool) || !isRecord(tool.input)) throw new Error("test was not generated")
if (!Tool.isTool(tool) || !isRecord(tool.input)) throw new Error("test was not generated")
const properties = isRecord(tool.input.properties) ? tool.input.properties : {}
const record = isRecord(properties.record) ? properties.record : {}
const definitions = isRecord(tool.input.$defs) ? tool.input.$defs : {}
@ -567,7 +562,7 @@ describe("OpenAPI.fromSpec", () => {
}).tools,
"test",
)
if (!Tool.isDefinition(tool)) throw new Error("test was not generated")
if (!Tool.isTool(tool)) throw new Error("test was not generated")
expect(inputTypeScript(tool)).toBe("{ name: string }")
})
@ -607,7 +602,7 @@ describe("OpenAPI.fromSpec", () => {
}).tools,
"test",
)
if (!Tool.isDefinition(tool) || !isRecord(tool.input)) throw new Error("test was not generated")
if (!Tool.isTool(tool) || !isRecord(tool.input)) throw new Error("test was not generated")
const definitions = isRecord(tool.input.$defs) ? tool.input.$defs : {}
const node = isRecord(definitions.Node) ? definitions.Node : {}
@ -648,7 +643,7 @@ describe("OpenAPI.fromSpec", () => {
}).tools,
"test",
)
if (!Tool.isDefinition(tool) || !isRecord(tool.input)) throw new Error("test was not generated")
if (!Tool.isTool(tool) || !isRecord(tool.input)) throw new Error("test was not generated")
const definitions = isRecord(tool.input.$defs) ? tool.input.$defs : {}
const leaf = isRecord(definitions[`C${depth - 1}`]) ? definitions[`C${depth - 1}`] : {}
@ -686,7 +681,7 @@ describe("OpenAPI.fromSpec", () => {
}).tools,
"test",
)
if (!Tool.isDefinition(tool)) throw new Error("test was not generated")
if (!Tool.isTool(tool)) throw new Error("test was not generated")
expect(inputTypeScript(tool)).toBe("{ name: string }")
}
@ -725,7 +720,7 @@ describe("OpenAPI.fromSpec", () => {
}).tools,
"test",
)
if (!Tool.isDefinition(tool) || !isRecord(tool.input)) throw new Error("test was not generated")
if (!Tool.isTool(tool) || !isRecord(tool.input)) throw new Error("test was not generated")
const properties = isRecord(tool.input.properties) ? tool.input.properties : {}
const record: Record<string, unknown> = isRecord(properties.record) ? properties.record : {}
@ -763,7 +758,7 @@ describe("OpenAPI.fromSpec", () => {
}).tools,
"test",
)
if (!Tool.isDefinition(tool) || !isRecord(tool.input)) throw new Error("test was not generated")
if (!Tool.isTool(tool) || !isRecord(tool.input)) throw new Error("test was not generated")
const properties = isRecord(tool.input.properties) ? tool.input.properties : {}
const choice: Record<string, unknown> = isRecord(properties.choice) ? properties.choice : {}
const pick: Record<string, unknown> = isRecord(properties.pick) ? properties.pick : {}
@ -807,7 +802,7 @@ describe("OpenAPI.fromSpec", () => {
}).tools,
"test",
)
if (!Tool.isDefinition(tool) || !isRecord(tool.input)) throw new Error("test was not generated")
if (!Tool.isTool(tool) || !isRecord(tool.input)) throw new Error("test was not generated")
const properties = isRecord(tool.input.properties) ? tool.input.properties : {}
const record = isRecord(properties.record) ? properties.record : {}
@ -836,7 +831,7 @@ describe("OpenAPI.fromSpec", () => {
}).tools,
"test",
)
if (!Tool.isDefinition(tool)) throw new Error("test was not generated")
if (!Tool.isTool(tool)) throw new Error("test was not generated")
expect(inputTypeScript(tool)).toBe("{ filter: { state: string } }")
})
@ -866,7 +861,7 @@ describe("OpenAPI.fromSpec", () => {
}).tools,
"test",
)
if (!Tool.isDefinition(tool)) throw new Error("test was not generated")
if (!Tool.isTool(tool)) throw new Error("test was not generated")
expect(inputTypeScript(tool)).toBe("{ filter: { value: string } }")
})
@ -901,7 +896,7 @@ describe("OpenAPI.fromSpec", () => {
}).tools,
"test",
)
if (!Tool.isDefinition(tool) || !isRecord(tool.input)) throw new Error("test was not generated")
if (!Tool.isTool(tool) || !isRecord(tool.input)) throw new Error("test was not generated")
const properties = isRecord(tool.input.properties) ? tool.input.properties : {}
const body = isRecord(properties.body) ? properties.body : {}
const allOf = Array.isArray(body.allOf) ? body.allOf : []
@ -923,11 +918,11 @@ describe("OpenAPI.fromSpec", () => {
}),
)
const tool = toolAt(OpenAPI.fromSpec({ baseUrl, spec: directionalSpec("3.1.0") }).tools, "users.create")
if (!Tool.isDefinition(tool)) throw new Error("users.create was not generated")
if (!Tool.isTool(tool)) throw new Error("users.create was not generated")
const result = await Effect.runPromise(
tool
.run({
.execute({
id: "ignored-top-level",
generated: "ignored-generated",
name: "Ada",
@ -1022,10 +1017,12 @@ describe("OpenAPI.fromSpec", () => {
test("serializes deep-object query parameters from the opencode fixture", async () => {
const client = recordingClient(() => json({ directory: "/tmp" }))
const location = toolAt(OpenAPI.fromSpec({ spec: await opencodeSpec(), baseUrl }).tools, "v2.location.get")
if (!Tool.isDefinition(location)) throw new Error("v2.location.get was not generated")
if (!Tool.isTool(location)) throw new Error("v2.location.get was not generated")
await Effect.runPromise(
location.run({ location: { directory: "/tmp", workspace: "workspace-1" } }).pipe(Effect.provide(client.layer)),
location
.execute({ location: { directory: "/tmp", workspace: "workspace-1" } })
.pipe(Effect.provide(client.layer)),
)
const url = new URL(client.requests[0]!.url)
@ -1058,11 +1055,11 @@ describe("OpenAPI.fromSpec", () => {
},
})
const tool = toolAt(result.tools, "items")
if (!Tool.isDefinition(tool)) throw new Error("items was not generated")
if (!Tool.isTool(tool)) throw new Error("items was not generated")
await Effect.runPromise(
tool
.run({
.execute({
keys: ["a!", "b*"],
tags: ["x", "y"],
filter: { state: "open", page: 2 },
@ -1081,9 +1078,9 @@ describe("OpenAPI.fromSpec", () => {
expect(url.searchParams.get("nullable")).toBe("null")
expect(url.searchParams.get("constructor")).toBe("safe")
expect(client.requests[0]!.headers.meta).toBe("a=b,c=d")
await expect(Effect.runPromise(tool.run({ keys: [undefined] }).pipe(Effect.provide(client.layer)))).rejects.toThrow(
"unsupported nested value",
)
await expect(
Effect.runPromise(tool.execute({ keys: [undefined] }).pipe(Effect.provide(client.layer))),
).rejects.toThrow("unsupported nested value")
})
test("preserves ordered exploded and deep-object query parameters", async () => {
@ -1101,11 +1098,11 @@ describe("OpenAPI.fromSpec", () => {
}).tools,
"test",
)
if (!Tool.isDefinition(tool)) throw new Error("test was not generated")
if (!Tool.isTool(tool)) throw new Error("test was not generated")
await Effect.runPromise(
tool
.run({
.execute({
tags: ["first value", "second&value"],
filter: { state: "open now", page: 2 },
location: { directory: "/tmp/a b", workspace: "work&1" },
@ -1116,14 +1113,14 @@ describe("OpenAPI.fromSpec", () => {
expect(client.requests[0]?.url).toBe(
`${baseUrl}/test?tags=first+value&tags=second%26value&state=open+now&page=2&location%5Bdirectory%5D=%2Ftmp%2Fa+b&location%5Bworkspace%5D=work%261`,
)
await expect(Effect.runPromise(tool.run({ tags: [{}] }).pipe(Effect.provide(client.layer)))).rejects.toThrow(
await expect(Effect.runPromise(tool.execute({ tags: [{}] }).pipe(Effect.provide(client.layer)))).rejects.toThrow(
"Parameter 'tags' contains an unsupported nested value.",
)
await expect(
Effect.runPromise(tool.run({ filter: { state: {} } }).pipe(Effect.provide(client.layer))),
Effect.runPromise(tool.execute({ filter: { state: {} } }).pipe(Effect.provide(client.layer))),
).rejects.toThrow("Query parameter 'filter' contains an unsupported nested value.")
await expect(
Effect.runPromise(tool.run({ location: { directory: [] } }).pipe(Effect.provide(client.layer))),
Effect.runPromise(tool.execute({ location: { directory: [] } }).pipe(Effect.provide(client.layer))),
).rejects.toThrow("Deep-object parameter 'location' contains an unsupported nested value.")
expect(client.requests).toHaveLength(1)
})
@ -1203,9 +1200,9 @@ describe("OpenAPI.fromSpec", () => {
}).tools,
"getTest",
)
if (!Tool.isDefinition(tool)) throw new Error("test was not generated")
if (!Tool.isTool(tool)) throw new Error("test was not generated")
await Effect.runPromise(tool.run({}).pipe(Effect.provide(client.layer)))
await Effect.runPromise(tool.execute({}).pipe(Effect.provide(client.layer)))
expect(inputTypeScript(tool)).toBe("{}")
expect(client.requests[0]!.headers.authorization).toBe("Bearer secret")
@ -1240,9 +1237,9 @@ describe("OpenAPI.fromSpec", () => {
authenticated([{ key: [] }], { key: { type: "apiKey", in: "query", name: "__proto__" } }).tools,
"test",
)
if (!Tool.isDefinition(prototype)) throw new Error("prototype auth tool was not generated")
if (!Tool.isTool(prototype)) throw new Error("prototype auth tool was not generated")
await Effect.runPromise(prototype.run({}).pipe(Effect.provide(client.layer)))
await Effect.runPromise(prototype.execute({}).pipe(Effect.provide(client.layer)))
expect(new URL(client.requests[0]!.url).searchParams.get("__proto__")).toBe("secret")
const duplicate = toolAt(
@ -1252,8 +1249,8 @@ describe("OpenAPI.fromSpec", () => {
}).tools,
"test",
)
if (!Tool.isDefinition(duplicate)) throw new Error("duplicate auth tool was not generated")
await expect(Effect.runPromise(duplicate.run({}).pipe(Effect.provide(client.layer)))).rejects.toThrow(
if (!Tool.isTool(duplicate)) throw new Error("duplicate auth tool was not generated")
await expect(Effect.runPromise(duplicate.execute({}).pipe(Effect.provide(client.layer)))).rejects.toThrow(
"multiple credentials",
)
@ -1278,8 +1275,8 @@ describe("OpenAPI.fromSpec", () => {
},
})
const alternativeTool = toolAt(alternative.tools, "test")
if (!Tool.isDefinition(alternativeTool)) throw new Error("supported auth alternative was not generated")
await Effect.runPromise(alternativeTool.run({}).pipe(Effect.provide(client.layer)))
if (!Tool.isTool(alternativeTool)) throw new Error("supported auth alternative was not generated")
await Effect.runPromise(alternativeTool.execute({}).pipe(Effect.provide(client.layer)))
expect(client.requests.at(-1)?.headers.authorization).toBe("Bearer secret")
})
@ -1290,9 +1287,9 @@ describe("OpenAPI.fromSpec", () => {
servers: [{ url: "https://document.example" }],
} satisfies Document
const tool = toolAt(OpenAPI.fromSpec({ spec }).tools, "test")
if (!Tool.isDefinition(tool)) throw new Error("test was not generated")
if (!Tool.isTool(tool)) throw new Error("test was not generated")
await Effect.runPromise(tool.run({}).pipe(Effect.provide(client.layer)))
await Effect.runPromise(tool.execute({}).pipe(Effect.provide(client.layer)))
expect(client.requests[0]?.url).toBe("https://operation.example/v1/test")
const invalid = OpenAPI.fromSpec({ spec, baseUrl: "https://example.com/api?tenant=one" })
@ -1363,10 +1360,10 @@ describe("OpenAPI.fromSpec", () => {
}).tools,
"test",
)
if (!Tool.isDefinition(tool)) throw new Error("test was not generated")
if (!Tool.isTool(tool)) throw new Error("test was not generated")
await expect(
Effect.runPromise(tool.run({ filter: { value: undefined } }).pipe(Effect.provide(client.layer))),
Effect.runPromise(tool.execute({ filter: { value: undefined } }).pipe(Effect.provide(client.layer))),
).rejects.toThrow("unsupported nested value")
expect(resolutions).toEqual([])
expect(client.requests).toEqual([])
@ -1389,33 +1386,33 @@ describe("OpenAPI.fromSpec", () => {
}).tools,
"test",
)
if (!Tool.isDefinition(tool)) throw new Error("test was not generated")
if (!Tool.isTool(tool)) throw new Error("test was not generated")
await Effect.runPromise(tool.run({ body: { name: "updated" } }).pipe(Effect.provide(client.layer)))
await Effect.runPromise(tool.execute({ body: { name: "updated" } }).pipe(Effect.provide(client.layer)))
expect(client.requests[0]!.headers["content-type"]).toBe("application/merge-patch+json")
const cyclic: Record<string, unknown> = {}
cyclic.self = cyclic
await expect(Effect.runPromise(tool.run({ body: cyclic }).pipe(Effect.provide(client.layer)))).rejects.toThrow(
await expect(Effect.runPromise(tool.execute({ body: cyclic }).pipe(Effect.provide(client.layer)))).rejects.toThrow(
"Invalid JSON body",
)
})
test("rejects oversized and malformed JSON responses", async () => {
const tool = toolAt(OpenAPI.fromSpec({ baseUrl, spec: singleOperation({}) }).tools, "test")
if (!Tool.isDefinition(tool)) throw new Error("test was not generated")
if (!Tool.isTool(tool)) throw new Error("test was not generated")
const oversized = recordingClient(
() => new Response(null, { headers: { "content-length": String(50 * 1024 * 1024 + 1) } }),
)
const malformed = recordingClient(() => new Response("{", { headers: { "content-type": "application/json" } }))
const chunked = recordingClient(() => new Response(new Uint8Array(50 * 1024 * 1024 + 1)))
await expect(Effect.runPromise(tool.run({}).pipe(Effect.provide(oversized.layer)))).rejects.toThrow(
await expect(Effect.runPromise(tool.execute({}).pipe(Effect.provide(oversized.layer)))).rejects.toThrow(
"response exceeds 50 MiB",
)
await expect(Effect.runPromise(tool.run({}).pipe(Effect.provide(malformed.layer)))).rejects.toThrow(
await expect(Effect.runPromise(tool.execute({}).pipe(Effect.provide(malformed.layer)))).rejects.toThrow(
"returned malformed JSON",
)
await expect(Effect.runPromise(tool.run({}).pipe(Effect.provide(chunked.layer)))).rejects.toThrow(
await expect(Effect.runPromise(tool.execute({}).pipe(Effect.provide(chunked.layer)))).rejects.toThrow(
"response exceeds 50 MiB",
)
})
@ -1428,11 +1425,11 @@ describe("OpenAPI.fromSpec", () => {
},
})
const tool = toolAt(OpenAPI.fromSpec({ baseUrl, spec }).tools, "test")
if (!Tool.isDefinition(tool)) throw new Error("test was not generated")
if (!Tool.isTool(tool)) throw new Error("test was not generated")
const client = recordingClient(() => new Response("123", { headers: { "content-type": "text/plain" } }))
expect(outputTypeScript(tool)).toBe("string | null")
await expect(Effect.runPromise(tool.run({}).pipe(Effect.provide(client.layer)))).resolves.toBe("123")
await expect(Effect.runPromise(tool.execute({}).pipe(Effect.provide(client.layer)))).resolves.toBe("123")
})
test("fails missing required parameters before auth and network", async () => {
@ -1497,13 +1494,13 @@ describe("OpenAPI.fromSpec", () => {
const update = toolAt(tools, "things.update")
const echo = toolAt(tools, "echo")
expect(Tool.isDefinition(update)).toBe(true)
if (!Tool.isDefinition(update)) throw new Error("things.update was not generated")
expect(Tool.isTool(update)).toBe(true)
if (!Tool.isTool(update)) throw new Error("things.update was not generated")
expect(inputTypeScript(update)).toBe(
"{ path_id: string; query_id: string; path_id_2?: string; header_id: string; body_id: string }",
)
expect(Tool.isDefinition(echo)).toBe(true)
if (!Tool.isDefinition(echo)) throw new Error("echo was not generated")
expect(Tool.isTool(echo)).toBe(true)
if (!Tool.isTool(echo)) throw new Error("echo was not generated")
expect(inputTypeScript(echo)).toBe("{ body: string }")
const runtime = CodeMode.make({ tools })
@ -1584,13 +1581,13 @@ describe("OpenAPI.fromSpec", () => {
for (const name of ["optional", "dictionary", "composed", "nullable"]) {
const tool = toolAt(tools, `body.${name}`)
expect(Tool.isDefinition(tool)).toBe(true)
if (!Tool.isDefinition(tool)) throw new Error(`body.${name} was not generated`)
expect(Tool.isTool(tool)).toBe(true)
if (!Tool.isTool(tool)) throw new Error(`body.${name} was not generated`)
const input = isRecord(tool.input) ? tool.input : {}
expect(Object.keys(isRecord(input.properties) ? input.properties : {})).toStrictEqual(["body"])
}
const optional = toolAt(tools, "body.optional")
if (!Tool.isDefinition(optional)) throw new Error("body.optional was not generated")
if (!Tool.isTool(optional)) throw new Error("body.optional was not generated")
expect(inputTypeScript(optional)).toBe("{ body?: { name: string } }")
})
})

View file

@ -33,7 +33,7 @@ const echoTool = (trace: Trace) =>
description: "Echo an id immediately",
input: Schema.Struct({ id: Schema.Number }),
output: Schema.Number,
run: ({ id }) =>
execute: ({ id }) =>
Effect.sync(() => {
trace.starts.push(id)
trace.completed += 1
@ -46,7 +46,7 @@ const gatedTool = (trace: Trace, gate: (id: number) => Deferred.Deferred<void>)
description: "Echo an id once its gate opens",
input: Schema.Struct({ id: Schema.Number }),
output: Schema.Number,
run: ({ id }) =>
execute: ({ id }) =>
Effect.gen(function* () {
trace.starts.push(id)
trace.active += 1
@ -70,7 +70,7 @@ const openTool = (gate: (id: number) => Deferred.Deferred<void>) =>
description: "Open the gate for an id",
input: Schema.Struct({ id: Schema.Number }),
output: Schema.Boolean,
run: ({ id }) => Deferred.succeed(gate(id), undefined),
execute: ({ id }) => Deferred.succeed(gate(id), undefined),
})
const pendingTool = (trace: Trace) =>
@ -78,7 +78,7 @@ const pendingTool = (trace: Trace) =>
description: "Never settle",
input: Schema.Struct({ id: Schema.Number }),
output: Schema.Number,
run: ({ id }) =>
execute: ({ id }) =>
Effect.gen(function* () {
trace.starts.push(id)
trace.active += 1
@ -98,14 +98,14 @@ const failingTool = Tool.make({
description: "Always refuse",
input: Schema.Struct({}),
output: Schema.String,
run: () => Effect.fail(toolError("Lookup refused")),
execute: () => Effect.fail(toolError("Lookup refused")),
})
const interruptedTool = Tool.make({
description: "Interrupt this call",
input: Schema.Struct({}),
output: Schema.String,
run: () => Effect.interrupt,
execute: () => Effect.interrupt,
})
const completedTool = (trace: Trace) =>
@ -113,7 +113,7 @@ const completedTool = (trace: Trace) =>
description: "Return the number of completed calls",
input: Schema.Struct({}),
output: Schema.Number,
run: () => Effect.succeed(trace.completed),
execute: () => Effect.succeed(trace.completed),
})
/** Never settles, and holds interruption cleanup for `cleanupMs` so completion cleanup can outlast a timeout. */
@ -122,7 +122,7 @@ const stubbornTool = (trace: Trace) =>
description: "Never settle; clean up slowly when interrupted",
input: Schema.Struct({ cleanupMs: Schema.Number }),
output: Schema.Number,
run: ({ cleanupMs }) =>
execute: ({ cleanupMs }) =>
Effect.never.pipe(
Effect.onInterrupt(() =>
Effect.andThen(

View file

@ -18,7 +18,8 @@ const listIssues = Tool.make({
},
required: ["owner"],
},
run: () => Effect.succeed("[]"),
output: {},
execute: () => Effect.succeed("[]"),
})
// An Effect Schema tool whose field annotations must flow through the emitted JSON Schema.
@ -31,7 +32,7 @@ const lookupOrder = Tool.make({
output: Schema.Struct({
status: Schema.String.annotate({ description: "Current order status" }),
}),
run: () => Effect.succeed({ status: "open" }),
execute: () => Effect.succeed({ status: "open" }),
})
describe("pretty signature rendering", () => {
@ -261,7 +262,7 @@ describe("non-identifier property names render as quoted keys", () => {
properties: { "content-type": { type: "string" } },
required: ["content-type"],
} as const,
run: () => Effect.succeed({ "content-type": "text/plain" }),
execute: () => Effect.succeed({ "content-type": "text/plain" }),
})
expect(inputTypeScript(tool)).toContain('"foo-bar"?: string')
expect(outputTypeScript(tool)).toBe('{ "content-type": string }')
@ -272,7 +273,7 @@ describe("non-identifier property names render as quoted keys", () => {
const tool = Tool.make({
description: "Schema tool with awkward field names",
input: Schema.Struct({ "foo-bar": Schema.String, plain: Schema.optionalKey(Schema.Number) }),
run: () => Effect.succeed(null),
execute: () => Effect.succeed(null),
})
expect(inputTypeScript(tool)).toBe('{ "foo-bar": string; plain?: number }')
expect(inputTypeScript(tool, true)).toBe(["{", ' "foo-bar": string,', " plain?: number,", "}"].join("\n"))
@ -306,7 +307,7 @@ describe("union schemas render every alternative", () => {
},
} as const,
output: { anyOf: [{ type: "number" }, { type: "boolean" }] } as const,
run: () => Effect.succeed(1),
execute: () => Effect.succeed(1),
})
expect(inputTypeScript(tool)).toBe("{ value?: string | number }")
expect(outputTypeScript(tool)).toBe("number | boolean")
@ -417,7 +418,8 @@ describe("non-identifier tool paths", () => {
},
required: ["query", "libraryName"],
} as const,
run: () => Effect.succeed("/reactjs/react.dev"),
output: {},
execute: () => Effect.succeed("/reactjs/react.dev"),
})
const runtime = CodeMode.make({ tools: { context7: { "resolve-library-id": resolveLibrary } } })

View file

@ -329,7 +329,7 @@ describe("RegExp", () => {
description: "Decorate a string",
input: Schema.String,
output: Schema.String,
run: (input) => Effect.succeed(`[${input}]`),
execute: (input) => Effect.succeed(`[${input}]`),
})
const result = await Effect.runPromise(
CodeMode.execute({
@ -1028,7 +1028,7 @@ describe("CodeMode values at intra-CodeMode checkpoints", () => {
const capture = Tool.make({
description: "Capture the exact input the host receives",
input: { type: "object" },
run: (input) =>
execute: (input) =>
Effect.sync(() => {
observed.push(input)
return "ok"

View file

@ -7,7 +7,7 @@ const echo = (description: string, result: string) =>
description,
input: Schema.Struct({}),
output: Schema.String,
run: () => Effect.succeed(result),
execute: () => Effect.succeed(result),
})
const value = async (runtime: CodeMode.Runtime, code: string) => {
@ -88,7 +88,7 @@ describe("callable namespaces", () => {
expect(diagnostic.message).toContain("Unknown tool 'issues.missing'")
})
test("a namespace without its own definition stays non-callable", async () => {
test("a namespace without its own tool stays non-callable", async () => {
const nested = CodeMode.make({ tools: { "issues.list": echo("List issues", "list") } })
const diagnostic = await failure(nested, `return await tools.issues({})`)
expect(diagnostic.kind).toBe("UnknownTool")
@ -114,9 +114,9 @@ describe("blocked member names on tool paths", () => {
expect(await value(runtime, `return Object.keys(tools.issues)`)).toEqual(["constructor"])
})
test("a literal __proto__ key cannot poison a namespace into a fake definition", async () => {
test("a literal __proto__ key cannot poison a namespace into a fake tool", async () => {
const poisoned = CodeMode.make({
tools: { ns: { "__proto__": echo("Hidden", "hidden"), real: echo("Real tool", "real") } },
tools: { ns: { __proto__: echo("Hidden", "hidden"), real: echo("Real tool", "real") } },
})
expect(poisoned.catalog().map((tool) => tool.path)).toEqual(["ns.real"])
expect(await value(poisoned, `return await tools.ns.real({})`)).toBe("real")
@ -138,7 +138,7 @@ describe("empty segments", () => {
})
describe("canonical path collisions", () => {
test("the last definition supplied for a canonical path wins", async () => {
test("the last tool supplied for a canonical path wins", async () => {
const runtime = CodeMode.make({
tools: { "issues.list": echo("First", "first"), issues: { list: echo("Second", "second") } },
})

View file

@ -4,19 +4,17 @@ import { Context, Effect, Layer, Scope } from "effect"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { PermissionV2 } from "./permission"
import { ExecuteTool } from "./tool/execute"
import { permission, registrationEntries, type AnyTool } from "./tool/tool"
import { Tools } from "./tool/tools"
import type { Any, Registration } from "./tool/tool"
import { Wildcard } from "./util/wildcard"
export interface Materialization {
readonly tool?: AnyTool
readonly tool?: Any
readonly instructions?: string
}
export interface Interface {
readonly register: (
tools: Readonly<Record<string, AnyTool>>,
options?: Tools.RegisterOptions,
registrations: ReadonlyArray<Registration & { readonly key: string }>,
) => Effect.Effect<void, never, Scope.Scope>
readonly materialize: (permissions?: PermissionV2.Ruleset) => Effect.Effect<Materialization>
}
@ -26,29 +24,28 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/v2
const layer = Layer.effect(
Service,
Effect.gen(function* () {
const local = new Map<
string,
Array<{ readonly token: object; readonly registration: ExecuteTool.Registration }>
>()
const local = new Map<string, Array<{ readonly token: object; readonly registration: ExecuteTool.Registration }>>()
return Service.of({
register: Effect.fn("CodeMode.register")(function* (tools, options) {
const entries = registrationEntries(tools, options?.namespace)
if (entries.length === 0) return
register: Effect.fn("CodeMode.register")(function* (registrations) {
if (registrations.length === 0) return
yield* Effect.uninterruptible(
Effect.gen(function* () {
const token = {}
for (const entry of entries)
local.set(entry.key, [
...(local.get(entry.key) ?? []),
{ token, registration: { tool: entry.tool, name: entry.name, namespace: entry.namespace } },
for (const registration of registrations)
local.set(registration.key, [
...(local.get(registration.key) ?? []),
{
token,
registration,
},
])
yield* Effect.addFinalizer(() =>
Effect.sync(() => {
for (const entry of entries) {
const registrations = local.get(entry.key)?.filter((item) => item.token !== token) ?? []
if (registrations.length > 0) local.set(entry.key, registrations)
else local.delete(entry.key)
for (const registration of registrations) {
const remaining = local.get(registration.key)?.filter((item) => item.token !== token) ?? []
if (remaining.length > 0) local.set(registration.key, remaining)
else local.delete(registration.key)
}
}),
)
@ -61,7 +58,7 @@ const layer = Layer.effect(
for (const [name, entries] of local) {
const registration = entries.at(-1)?.registration
if (!registration) continue
const rule = rules.findLast((rule) => Wildcard.match(permission(registration.tool, name), rule.action))
const rule = rules.findLast((rule) => Wildcard.match(registration.permission, rule.action))
if (rule?.resource === "*" && rule.effect === "deny") continue
registrations.set(name, registration)
}

View file

@ -56,5 +56,6 @@ export const migrations = (
import("./migration/20260710025429_instruction_sync"),
import("./migration/20260716020354_kv"),
import("./migration/20260722011141_delete_tool_progress_events"),
import("./migration/20260722170000_canonical_tool_results"),
])
).map((module) => module.default) satisfies DatabaseMigration.Migration[]

View file

@ -0,0 +1,123 @@
import { sql } from "drizzle-orm"
import { Effect, Schema } from "effect"
import type { DatabaseMigration } from "../migration"
const decodeJson = Schema.decodeUnknownOption(Schema.UnknownFromJsonString)
const isObject = Schema.is(Schema.Record(Schema.String, Schema.Unknown))
const isJsonObject = Schema.is(Schema.Record(Schema.String, Schema.Json))
const object = (value: unknown): Record<string, unknown> => (isObject(value) ? value : {})
const stringify = (value: unknown) => {
try {
return JSON.stringify(value, null, 2) ?? String(value)
} catch {
return String(value)
}
}
const contentOf = (state: Record<string, unknown>) => (Array.isArray(state.content) ? state.content : [])
const resultOf = (state: Record<string, unknown>) =>
isObject(state.result) && "value" in state.result ? state.result.value : state.result
const metadataOf = (state: Record<string, unknown>) => {
if (isJsonObject(state.structured) && Object.keys(state.structured).length > 0)
return { metadata: state.structured }
return isJsonObject(state.metadata) ? { metadata: state.metadata } : {}
}
const completedContent = (state: Record<string, unknown>) => {
const preserved = contentOf(state)
if (preserved.length > 0) return preserved
return [{ type: "text", text: stringify(Object.keys(object(state.structured)).length ? state.structured : resultOf(state)) }]
}
/**
* One-time rewrite of projected tool rows into the canonical result shape:
* terminal states store model content plus optional metadata; the generic
* `structured` and `result` fields disappear. Provider-hosted result payloads
* move into provider-owned result state so hosted continuation survives.
* Pre-release durable event versions are intentionally left untouched.
*/
export default {
id: "20260722170000_canonical_tool_results",
up(tx) {
return Effect.gen(function* () {
// Keyset-paginated batches keep memory bounded: production databases hold
// gigabytes of assistant rows, and materializing them all at once was
// measured at a ~5GB RSS spike.
let cursor = ""
while (true) {
const messages = yield* tx.all<{ id: string; data: string }>(
sql`SELECT id, data FROM session_message WHERE type = 'assistant' AND id > ${cursor} ORDER BY id LIMIT 1000`,
)
if (messages.length === 0) break
cursor = messages[messages.length - 1].id
yield* rewrite(tx, messages)
}
})
},
} satisfies DatabaseMigration.Migration
function rewrite(tx: Parameters<DatabaseMigration.Migration["up"]>[0], messages: { id: string; data: string }[]) {
return Effect.gen(function* () {
for (const row of messages) {
// A row that never decoded is skipped rather than failing the whole
// migration on every startup; it was equally unreadable before.
const decoded = decodeJson(row.data)
if (decoded._tag === "None") {
yield* Effect.logWarning("skipping undecodable session_message row").pipe(Effect.annotateLogs({ id: row.id }))
continue
}
const data = object(decoded.value)
if (!Array.isArray(data.content)) continue
let changed = false
const content = data.content.map((part) => {
const tool = object(part)
if (tool.type !== "tool" || !isObject(tool.state)) return part
const state = tool.state
if (state.status !== "completed" && state.status !== "error" && state.status !== "running") return part
if (!("structured" in state) && !("result" in state)) return part
changed = true
if (state.status === "running")
return {
...tool,
state: {
status: "running",
input: object(state.input),
metadata: object(state.structured),
},
}
// Hosted payloads are irreducible provider replay state; keep them under
// the provider-owned result state instead of a generic result field.
const hosted =
tool.executed === true && isObject(state.result) && "value" in state.result
? { providerResultState: { ...object(tool.providerResultState), result: state.result.value } }
: {}
const preserved = contentOf(state)
if (state.status === "completed")
return {
...tool,
...hosted,
state: {
status: "completed",
input: object(state.input),
content: completedContent(state),
...metadataOf(state),
},
}
return {
...tool,
...hosted,
state: {
status: "error",
input: object(state.input),
error: state.error,
...(preserved.length > 0 ? { content: preserved } : {}),
...metadataOf(state),
},
}
})
if (!changed) continue
yield* tx.run(sql`UPDATE session_message SET data = ${JSON.stringify({ ...data, content })} WHERE id = ${row.id}`)
}
})
}

View file

@ -127,8 +127,7 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
),
},
model: {
get: (providerID, modelID) =>
catalog.model.get(ProviderV2.ID.make(providerID), ModelV2.ID.make(modelID)),
get: (providerID, modelID) => catalog.model.get(ProviderV2.ID.make(providerID), ModelV2.ID.make(modelID)),
list: () => response(catalog.model.available()),
default: () => response(catalog.model.default()),
},
@ -358,7 +357,7 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
Effect.gen(function* () {
const registrations: Array<{
readonly name: string
readonly tool: Tool.AnyTool
readonly tool: Tool.Any
readonly options?: Tool.RegisterOptions
}> = []
yield* Effect.sync(() =>
@ -395,25 +394,42 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
})
}
return toolHooks.hook.after((event) => {
const output = {
// JS plugin boundary: marshal the canonical outcome out, copy mutations back.
const output: Record<string, unknown> = {
tool: event.tool,
sessionID: event.sessionID,
agent: event.agent,
messageID: event.messageID,
callID: event.callID,
input: event.input,
result: event.result,
output: event.output,
status: event.status,
content: event.content,
metadata: event.metadata,
outputPaths: event.outputPaths,
...(event.status === "error" ? { error: event.error } : {}),
}
return Reflect.apply(callback, undefined, [output]).pipe(
Effect.tap(() =>
Effect.sync(() => {
event.result = output.result
event.output = output.output
event.outputPaths = output.outputPaths
}),
),
Effect.tap(() => {
const decoded = Schema.decodeUnknownOption(Tool.ExecuteAfterOutcome)(output)
if (decoded._tag === "None")
return Effect.logWarning("ignoring invalid execute.after tool outcome", { tool: event.tool })
if (decoded.value.status !== event.status)
return Effect.logWarning("ignoring execute.after tool status change", { tool: event.tool })
return Effect.sync(() => {
if (event.status === "completed" && decoded.value.status === "completed") {
if (output.content !== event.content) event.content = decoded.value.content
if (output.metadata !== event.metadata) event.metadata = decoded.value.metadata
if (output.outputPaths !== event.outputPaths) event.outputPaths = decoded.value.outputPaths
return
}
if (event.status === "error" && decoded.value.status === "error") {
if (output.error !== event.error) event.error = decoded.value.error
if (output.content !== event.content) event.content = decoded.value.content
if (output.metadata !== event.metadata) event.metadata = decoded.value.metadata
if (output.outputPaths !== event.outputPaths) event.outputPaths = decoded.value.outputPaths
}
})
}),
)
})
},

View file

@ -2,7 +2,7 @@ export * as PluginPromise from "./promise"
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
import type { Context, Plugin } from "@opencode-ai/plugin/v2/plugin"
import type { AnyTool } from "@opencode-ai/plugin/v2/tool"
import type { Any, RegisterOptions } from "@opencode-ai/plugin/v2/tool"
import { Agent } from "@opencode-ai/schema/agent"
import { Integration } from "@opencode-ai/schema/integration"
import { Location } from "@opencode-ai/schema/location"
@ -189,7 +189,8 @@ export function fromPromise(plugin: Plugin) {
register(
host.tool.transform((draft) =>
callback({
add: (tool: AnyTool) => draft.add(tool.name, fromPromiseTool(tool), tool.options),
add: (name: string, tool: Any, options?: RegisterOptions) =>
draft.add(name, fromPromiseTool(tool), options),
}),
),
),
@ -302,19 +303,8 @@ function wireEvent(value: unknown): unknown {
return wire(value)
}
function fromPromiseTool(tool: AnyTool) {
if ("jsonSchema" in tool)
return Tool.make({
...tool,
execute: (input, context) =>
Effect.promise(() =>
tool.execute(input, {
...context,
progress: (update) => Effect.runPromise(context.progress(update)),
}),
),
})
return Tool.make({
function fromPromiseTool(tool: Any): Tool.Any {
return {
...tool,
execute: (input, context) =>
Effect.promise(() =>
@ -323,5 +313,5 @@ function fromPromiseTool(tool: AnyTool) {
progress: (update) => Effect.runPromise(context.progress(update)),
}),
),
})
}
}

View file

@ -36,8 +36,8 @@ export const layer = Layer.effect(
const promptCacheKey = /^ses_[0-9a-f]{64}$/.test(selection.session.id)
? selection.session.id.slice(4)
: selection.session.id
const executableTools = yield* registry.materialize(selection.agent.info.permissions)
const toolDefinitions = executableTools.definitions
const toolSet = yield* registry.snapshot(selection.agent.info.permissions)
const toolDefinitions = toolSet.definitions
const toolsByName = new Map(toolDefinitions.map((tool) => [tool.name, tool]))
const contextEvent = yield* hooks.trigger("session", "context", {
sessionID: selection.session.id,
@ -52,7 +52,10 @@ export const layer = Layer.effect(
Message.user(input.prompt),
],
tools: Object.fromEntries(
toolDefinitions.map((tool) => [tool.name, { description: tool.description, input: { ...tool.inputSchema } }]),
toolDefinitions.map((tool) => [
tool.name,
{ description: tool.description, input: { ...tool.inputSchema } },
]),
),
})
const hookedTools = Object.entries(contextEvent.tools).flatMap(([name, tool]) => {

View file

@ -355,8 +355,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
SessionMessage.ToolStateRunning.make({
status: "running",
input: event.data.input,
structured: {},
content: [],
metadata: {},
}),
)
}
@ -366,11 +365,12 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
const match = latestTool(draft, event.data.callID)
if (match && match.state.status === "running") {
match.state.structured = event.data.structured
match.state.content = [...event.data.content]
match.state.metadata = event.data.metadata
}
})
},
// Terminal tool events are self-contained; projection is a direct copy and
// never reaches into ephemeral progress history.
"session.tool.success": (event) => {
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
const match = latestTool(draft, event.data.callID)
@ -382,9 +382,8 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
SessionMessage.ToolStateCompleted.make({
status: "completed",
input: match.state.input,
structured: event.data.structured,
content: [...event.data.content],
result: event.data.result,
content: event.data.content,
...(event.data.metadata === undefined ? {} : { metadata: event.data.metadata }),
}),
)
}
@ -402,9 +401,8 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
status: "error",
error: event.data.error,
input: typeof match.state.input === "string" ? {} : match.state.input,
structured: event.data.metadata ?? (match.state.status === "running" ? match.state.structured : {}),
content: event.data.content ?? (match.state.status === "running" ? match.state.content : []),
result: event.data.result,
...(event.data.content === undefined ? {} : { content: event.data.content }),
...(event.data.metadata === undefined ? {} : { metadata: event.data.metadata }),
}),
)
}

View file

@ -14,13 +14,15 @@ import { MAX_STEPS_PROMPT } from "./runner/max-steps"
import PROMPT_DEFAULT from "./runner/prompt/base.txt"
import { toLLMMessages } from "./runner/to-llm-message"
type ToolCallResolution =
| { readonly type: "reject"; readonly error: SessionError.Error }
| { readonly type: "settle"; readonly settle: ToolRegistry.Materialization["settle"] }
interface Prepared {
readonly request: LLMRequest
readonly resolveToolCall: (name: string) => ToolCallResolution
/**
* One request-scoped execution operation. Unknown, hook-removed, and
* step-limit-violating calls fail individually through the same seam.
*/
readonly executeTool: ToolRegistry.ToolSet["execute"]
/** True when this request is the final Step; violating calls are rejected and no continuation follows. */
readonly stepLimitReached: boolean
}
interface PrepareInput {
@ -94,14 +96,16 @@ export const layer = Layer.effect(
const model = resolved.model
const providerMetadataKey = model.route.providerMetadataKey ?? model.provider
const stepLimitReached = agent.info.steps !== undefined && input.step >= agent.info.steps
const executableTools = stepLimitReached ? undefined : yield* registry.materialize(agent.info.permissions)
// The final Step keeps definitions available to protocols with native "none",
// preserving their prompt cache prefix. Calls are still rejected at execution.
const toolSet = yield* registry.snapshot(agent.info.permissions)
const promptCacheKey = /^ses_[0-9a-f]{64}$/.test(session.id) ? session.id.slice(4) : session.id
const system = [agent.info.system ? agent.info.system : PROMPT_DEFAULT, input.context.initial]
.filter((part) => part.length > 0)
.map(SystemPart.make)
const history = toLLMMessages(input.context.messages, resolved.ref, providerMetadataKey)
const messages = stepLimitReached ? [...history, Message.assistant(MAX_STEPS_PROMPT)] : history
const toolDefinitions = executableTools?.definitions ?? []
const toolDefinitions = toolSet.definitions
const toolsByName = new Map(toolDefinitions.map((tool) => [tool.name, tool]))
// Hooks may reshape available definitions but cannot advertise tools omitted by permissions or the Step limit.
const contextEvent = yield* hooks.trigger("session", "context", {
@ -131,22 +135,23 @@ export const layer = Layer.effect(
tools: hookedTools,
toolChoice: stepLimitReached ? "none" : undefined,
})
const resolveToolCall = (name: string): ToolCallResolution => {
if (!executableTools)
return {
type: "reject",
const executeTool: ToolRegistry.ToolSet["execute"] = (executeInput) => {
if (stepLimitReached)
return Effect.succeed({
status: "error",
error: { type: "tool.execution", message: "Tools are disabled after the maximum agent steps" },
}
if (toolsByName.has(name) && !Object.hasOwn(contextEvent.tools, name))
return {
type: "reject",
error: { type: "tool.execution", message: `Tool is not available for this request: ${name}` },
}
return { type: "settle", settle: executableTools.settle }
})
if (toolsByName.has(executeInput.call.name) && !Object.hasOwn(contextEvent.tools, executeInput.call.name))
return Effect.succeed({
status: "error",
error: { type: "tool.unknown", message: `Tool is not available for this request: ${executeInput.call.name}` },
})
return toolSet.execute(executeInput)
}
return {
request,
resolveToolCall,
executeTool,
stepLimitReached,
}
})

View file

@ -144,21 +144,18 @@ const layer = Layer.effect(
}
yield* publish(event)
if (LLMEvent.is.toolInputError(event)) {
if (prepared.resolveToolCall(event.name).type === "settle") needsContinuation = true
if (!prepared.stepLimitReached) needsContinuation = true
return
}
if (event.type !== "tool-call" || event.providerExecuted) return
const tool = prepared.resolveToolCall(event.name)
if (tool.type === "reject") {
yield* serialized(publisher.failUnsettledTools(tool.error))
return
}
needsContinuation = true
// Unavailable calls fail individually through the same execution seam;
// continuation depends only on remaining Step allowance.
if (!prepared.stepLimitReached) needsContinuation = true
const assistantMessageID = yield* publisher.assistantMessageID(event.id)
ownedToolFibers.push(
yield* Effect.uninterruptibleMask((restore) =>
restore(
tool.settle({
prepared.executeTool({
sessionID: session.id,
agent: agent.id,
messageID: assistantMessageID,
@ -166,17 +163,7 @@ const layer = Layer.effect(
progress: (update) => serialized(publisher.progress(event.id, update)),
}),
).pipe(
Effect.flatMap((settlement) =>
publish(
LLMEvent.toolResult({
id: event.id,
name: event.name,
result: settlement.result,
output: settlement.output,
}),
settlement.error,
),
),
Effect.flatMap((execution) => serialized(publisher.toolExecution(event.id, event.name, execution))),
),
).pipe(FiberSet.run(toolFibers)),
)

View file

@ -1,5 +1,5 @@
import { ToolOutput, type LLMEvent, type ProviderMetadata, type ToolResultValue } from "@opencode-ai/ai"
import { Effect } from "effect"
import { type LLMEvent, type ProviderMetadata, type ToolContent, type ToolResultValue } from "@opencode-ai/ai"
import { Effect, Schema } from "effect"
import { EventV2 } from "../../event"
import { ModelV2 } from "../../model"
import { SessionEvent } from "../event"
@ -11,6 +11,8 @@ import { AgentV2 } from "../../agent"
import { Snapshot } from "../../snapshot"
import { RelativePath } from "../../schema"
import { SessionUsage } from "../usage"
import { Tool } from "../../tool/tool"
import { MAX_BYTES } from "../../tool-output-store"
import type { ToolRegistry } from "../../tool/registry"
type Input = {
@ -25,24 +27,11 @@ type Input = {
const record = (value: unknown): Record<string, unknown> =>
typeof value === "object" && value !== null && !Array.isArray(value) ? (value as Record<string, unknown>) : { value }
const message = (value: unknown) => {
if (typeof value === "string") return value
try {
return JSON.stringify(value) ?? String(value)
} catch {
return String(value)
}
}
type SettledOutput =
| { readonly structured: Record<string, unknown>; readonly content: ToolOutput["content"] }
| { readonly error: SessionError.Error }
const settledOutput = (value: ToolOutput | undefined, result: ToolResultValue): SettledOutput => {
if (result.type === "error") return { error: { type: "tool.execution", message: message(result.value) } }
const settled = value ?? ToolOutput.fromResultValue(result)
if (!settled) throw new Error(`Unsupported tool result: ${message(result)}`)
return { structured: record(settled.structured), content: settled.content }
/** Derives canonical model content from a provider-hosted tool result. */
const hostedContent = (result: ToolResultValue): readonly [ToolContent, ...ToolContent[]] => {
if (result.type === "content" && result.value.length > 0)
return result.value as unknown as readonly [ToolContent, ...ToolContent[]]
return [{ type: "text", text: Tool.stringify(result.value) }]
}
/** Persist one step without executing tools or starting a continuation step. */
@ -60,11 +49,8 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
>()
const failureSnapshot = (tool: { readonly progress?: ToolRegistry.Progress }) => {
if (!tool.progress) return {}
const first = tool.progress.content[0]
return {
...(first === undefined ? {} : { content: [first, ...tool.progress.content.slice(1)] as const }),
metadata: tool.progress.structured,
}
const metadata = Tool.jsonMetadata(tool.progress, MAX_BYTES)
return metadata === undefined ? {} : { metadata }
}
let assistantMessageID = input.assistantMessageID
let stepStarted = false
@ -254,11 +240,7 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
const failTools = Effect.fnUntraced(function* (error: SessionError.Error, mode: "all" | "hosted" | "uncalled") {
let failed = false
for (const [callID, tool] of tools) {
if (
tool.settled ||
(mode === "hosted" && !tool.providerExecuted) ||
(mode === "uncalled" && tool.called)
)
if (tool.settled || (mode === "hosted" && !tool.providerExecuted) || (mode === "uncalled" && tool.called))
continue
tool.settled = true
failed = true
@ -409,26 +391,27 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
return
}
case "tool-result": {
// Provider-hosted results only; local executions publish through `toolExecution`.
const tool = tools.get(event.id)
if (!tool?.called) return yield* Effect.die(new Error(`Tool result before call: ${event.id}`))
if (tool.name !== event.name)
return yield* Effect.die(new Error(`Tool result name changed for ${event.id}: ${tool.name} -> ${event.name}`))
if (tool.settled) {
// A late error result is a benign straggler (e.g. after an abort
// sweep); a late success would mean double execution, so it dies.
if (event.result.type === "error") return
return yield* Effect.die(new Error(`Duplicate tool result: ${event.id}`))
}
tool.settled = true
const result = error ? { error } : settledOutput(event.output, event.result)
const executed = event.providerExecuted === true || tool.providerExecuted
const resultState = providerState(event.providerMetadata)
if ("error" in result) {
if (error !== undefined || event.result.type === "error") {
yield* events.publish(SessionEvent.Tool.Failed, {
sessionID: input.sessionID,
assistantMessageID: tool.assistantMessageID,
callID: event.id,
error: result.error,
error: error ?? { type: "tool.execution", message: Tool.stringify(event.result.value) },
...failureSnapshot(tool),
result: event.result,
executed,
resultState,
})
@ -438,8 +421,7 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
sessionID: input.sessionID,
assistantMessageID: tool.assistantMessageID,
callID: event.id,
...result,
...(executed ? { result: event.result } : {}),
content: hostedContent(event.result),
executed,
resultState,
})
@ -489,19 +471,64 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
const tool = tools.get(callID)
if (!tool?.called || tool.settled)
return yield* Effect.die(new Error(`Tool progress outside running call: ${callID}`))
const current = { structured: { ...update.structured }, content: [...update.content] }
const current = { ...update }
tool.progress = current
yield* events.publish(SessionEvent.Tool.Progress, {
sessionID: input.sessionID,
assistantMessageID: tool.assistantMessageID,
callID,
...current,
metadata: current,
})
})
/** Publishes one canonical terminal event for a locally executed tool call. */
const toolExecution = Effect.fnUntraced(function* (
callID: string,
name: string,
execution: ToolRegistry.ToolOutcome,
) {
const tool = tools.get(callID)
if (!tool?.called) return yield* Effect.die(new Error(`Tool execution before call: ${callID}`))
if (tool.name !== name)
return yield* Effect.die(new Error(`Tool execution name changed for ${callID}: ${tool.name} -> ${name}`))
if (tool.settled) {
if (execution.status === "error") return
return yield* Effect.die(new Error(`Duplicate tool execution: ${callID}`))
}
tool.settled = true
if (execution.status === "completed") {
yield* events.publish(SessionEvent.Tool.Success, {
sessionID: input.sessionID,
assistantMessageID: tool.assistantMessageID,
callID,
content: execution.content,
...(execution.metadata === undefined ? {} : { metadata: execution.metadata }),
executed: tool.providerExecuted,
})
return
}
// An execution-provided snapshot wins; otherwise fall back to retained progress.
const snapshot =
execution.content !== undefined || execution.metadata !== undefined
? {
...(execution.content === undefined ? {} : { content: execution.content }),
...(execution.metadata === undefined ? {} : { metadata: execution.metadata }),
}
: failureSnapshot(tool)
yield* events.publish(SessionEvent.Tool.Failed, {
sessionID: input.sessionID,
assistantMessageID: tool.assistantMessageID,
callID,
error: execution.error,
...snapshot,
executed: tool.providerExecuted,
})
})
return {
publish,
progress,
toolExecution,
flush,
failAssistant,
publishStepFailure,

View file

@ -1,11 +1,4 @@
import {
Message,
ToolCallPart,
ToolOutput,
ToolResultPart,
type ContentPart,
type ProviderMetadata,
} from "@opencode-ai/ai"
import { Message, ToolCallPart, ToolResultPart, type ContentPart, type ProviderMetadata } from "@opencode-ai/ai"
import { Option, Schema } from "effect"
import type { ModelV2 } from "../../model"
import { SessionMessage } from "../message"
@ -90,15 +83,15 @@ const toolCall = (tool: SessionMessage.AssistantTool, providerMetadata: Provider
const toolResult = (tool: SessionMessage.AssistantTool, providerMetadata: ProviderMetadata | undefined) => {
if (tool.state.status === "completed") {
// TODO: Materialize remote and managed URIs before provider-history lowering.
// ToolOutput.toResultValue rejects unresolved URIs rather than treating them as media bytes.
const result =
tool.executed === true && tool.state.result !== undefined
? tool.state.result
: ToolOutput.toResultValue({ structured: tool.state.structured, content: tool.state.content })
const content = tool.state.content
const single = content.length === 1 ? content[0] : undefined
return ToolResultPart.make({
id: tool.id,
name: tool.name,
result,
result:
single?.type === "text"
? { type: "text" as const, value: single.text }
: { type: "content" as const, value: content },
providerExecuted: tool.executed,
providerMetadata,
})
@ -107,10 +100,7 @@ const toolResult = (tool: SessionMessage.AssistantTool, providerMetadata: Provid
return ToolResultPart.make({
id: tool.id,
name: tool.name,
result:
tool.executed === true && tool.state.result !== undefined
? tool.state.result
: { error: tool.state.error, content: tool.state.content, structured: tool.state.structured },
result: { error: tool.state.error, content: tool.state.content ?? [] },
resultType: "error",
providerExecuted: tool.executed,
providerMetadata,
@ -119,8 +109,8 @@ const toolResult = (tool: SessionMessage.AssistantTool, providerMetadata: Provid
}
const assistant = (message: SessionMessage.Assistant, model: ModelV2.Ref, providerMetadataKey: string) => {
const sameModel =
String(message.model.providerID) === String(model.providerID) && String(message.model.id) === String(model.id)
const sameProvider = String(message.model.providerID) === String(model.providerID)
const sameModel = sameProvider && String(message.model.id) === String(model.id)
const reuseProviderMetadata = sameModel && message.error === undefined
const content = message.content.flatMap((item): ContentPart[] => {
if (item.type === "text") return [{ type: "text", text: item.text }]
@ -138,19 +128,21 @@ const assistant = (message: SessionMessage.Assistant, model: ModelV2.Ref, provid
: []
const reuseToolProviderMetadata =
reuseProviderMetadata ||
(sameModel &&
item.executed === true &&
(item.state.status === "completed" || (item.state.status === "error" && item.state.result !== undefined)))
(sameModel && item.executed === true && (item.state.status === "completed" || item.state.status === "error"))
const call = toolCall(
item,
reuseToolProviderMetadata ? providerMetadata(providerMetadataKey, item.providerState) : undefined,
)
if (item.executed !== true) return [call]
// Hosted result payloads are provider-format state, not model state:
// replay must survive a model switch within the same provider.
const result = toolResult(
item,
reuseToolProviderMetadata
? providerMetadata(providerMetadataKey, item.providerResultState ?? item.providerState)
: undefined,
: sameProvider && item.executed === true && item.providerResultState !== undefined
? providerMetadata(providerMetadataKey, item.providerResultState)
: undefined,
)
return result ? [call, result] : [call]
})

View file

@ -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 }

View file

@ -8,7 +8,7 @@ import { Global } from "@opencode-ai/util/global"
import { makeGlobalNode, makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { SessionSchema } from "./session/schema"
import { Identifier } from "./util/identifier"
import type { ToolOutput } from "@opencode-ai/ai"
import type { ToolContent } from "@opencode-ai/ai"
export const MAX_LINES = 2_000
export const MAX_BYTES = 50 * 1024
@ -19,11 +19,11 @@ export const MANAGED_DIRECTORY = "tool-output"
export interface BoundInput {
readonly sessionID: SessionSchema.ID
readonly callID: string
readonly output: ToolOutput
readonly content: ReadonlyArray<ToolContent>
}
export interface BoundResult {
readonly output: ToolOutput
readonly content: ReadonlyArray<ToolContent>
readonly outputPaths: ReadonlyArray<string>
}
@ -137,21 +137,14 @@ const layer = Layer.effect(
const bound = Effect.fn("ToolOutputStore.bound")(function* (input: BoundInput) {
const outputLimits = yield* limits()
const media = input.output.content.filter((item) => item.type === "file")
const text = input.output.content.filter((item) => item.type === "text")
const contextual =
input.output.content.length === 0
? yield* Effect.try({
try: () => JSON.stringify(input.output.structured, null, 2) ?? String(input.output.structured),
catch: (cause) => new StorageError({ operation: "encode", cause }),
})
: text.map((item) => item.text).join("")
const media = input.content.filter((item) => item.type === "file")
const contextual = input.content.flatMap((item) => (item.type === "text" ? [item.text] : [])).join("")
if (
lineCount(contextual) <= outputLimits.maxLines &&
Buffer.byteLength(contextual, "utf-8") <= outputLimits.maxBytes
)
return {
output: input.output,
content: input.content,
outputPaths: [],
}
@ -159,16 +152,13 @@ const layer = Layer.effect(
const marker = `... output truncated; full content saved to ${outputPath} ...`
return {
output: {
structured: input.output.structured,
content: [
{
type: "text" as const,
text: boundedPreview(contextual, marker, outputLimits.maxLines, outputLimits.maxBytes),
},
...media,
],
},
content: [
{
type: "text" as const,
text: boundedPreview(contextual, marker, outputLimits.maxLines, outputLimits.maxBytes),
},
...media,
],
outputPaths: [outputPath],
}
})

View file

@ -1,26 +1,26 @@
# Core Tool Architecture
This folder owns Core's one local tool representation, process and Location registration, effective lookup, and settlement.
This folder owns Core's local tools, Location-scoped registrations, effective lookup, execution, and terminal outcomes.
## Representations
- `tool.ts` defines the structural canonical `Tool.make({ description, input, output, execute, toModelOutput })` declaration. Shipped built-ins and plugin tools use the same type.
- `tool.ts` defines the structural canonical `Tool.make({ description, input, output?, execute })` tool. Executors return model content and metadata alongside declared machine output. Shipped built-ins and plugin tools use the same type.
- `tools.ts` exposes the registration-only `Tools.Service` view used by Location producers.
- `registry.ts` stores only canonical Location registrations, derives definitions, invokes tools, and applies generic output bounding.
- `registry.ts` stores only canonical Location registrations, derives LLM definitions, executes tools, and applies generic output bounding.
Do not add a second executable entry type, registry-owned executor, authorization callback, output-path callback, or legacy normalization path.
## Construction
Tool schemas and projection use `input` and `output` terminology. A tool value carries its schemas, executor, projection, and optional catalog permission directly so separately loaded plugin package instances can exchange it structurally.
Tool schemas use `input` and `output` terminology. A tool carries schemas and executable behavior without public identity. A registration binds its name, namespace, CodeMode placement, and optional catalog permission action.
Location-scoped built-in layers acquire `PermissionV2.Service` and every other required Location service while the layer is constructed. The executor captures those services. Permission sources are always constructed from the canonical invocation context:
```ts
const source = {
type: "tool" as const,
messageID: context.assistantMessageID,
callID: context.toolCallID,
messageID: context.messageID,
callID: context.callID,
}
```
@ -42,13 +42,13 @@ Registrations are scoped:
## Permissions
The registry has no `PermissionV2.Service` dependency and performs no execution authorization. An internal built-in-only operation attaches a permission action solely to preserve whole-tool definition filtering; it is not part of public `Tool.make`. Most tools default to their registered name; `edit`, `write`, and `patch` declare the shared `edit` action.
The registry has no `PermissionV2.Service` dependency and performs no execution authorization. Registration options may attach a permission action solely to preserve whole-tool definition filtering. Most registrations default to their effective name; `edit`, `write`, and `patch` use the shared `edit` action.
Definition filtering is catalog visibility, not execution authorization. A call still executes the captured leaf policy if it reaches settlement.
Tool filtering is catalog visibility, not execution authorization. A call still executes the captured tool's leaf policy if it reaches execution.
## Output
Built-ins return complete validated domain output. `ToolRegistry.Materialization.settle` is the only execution and generic model-output bounding boundary and owns managed retention paths.
Built-ins return complete tool responses. `ToolRegistry.ToolSet.execute` is the only local execution and generic model-output bounding boundary and owns managed retention paths.
Producer capture limits are separate. For example, Bash keeps `AppProcess.maxOutputBytes` and accurately reports stdout/stderr capture loss, but it does not run model-output truncation or return a managed `outputPath`.

View file

@ -97,15 +97,11 @@ export const Plugin = {
.transform((draft) =>
draft.add(
name,
Tool.withPermission(
Tool.make({
Tool.make({
description:
"Replace exact text in one file. Relative paths resolve within the active Location. Absolute paths inside the Location are accepted. Explicit external absolute paths require external_directory approval before edit approval.",
input: Input,
output: Output,
toModelOutput: ({ input, output }) => [
{ type: "text", text: toModelOutput(output, input.oldString, input.newString) },
],
execute: (input, context) => {
const unableToEdit = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
effect.pipe(
@ -207,12 +203,16 @@ export const Plugin = {
],
replacements,
} satisfies Output
})
}).pipe(
Effect.map((output) => ({
output,
content: toModelOutput(output, input.oldString, input.newString),
metadata: { files: output.files },
})),
)
},
}),
"edit",
),
{ codemode: false },
{ codemode: false, permission: "edit" },
),
)
.pipe(Effect.orDie)

View file

@ -1,9 +1,10 @@
export * as ExecuteTool from "./execute"
export type { Registration } from "./tool"
import { CodeMode, Tool, toolError } from "@opencode-ai/codemode"
import { ToolOutput } from "@opencode-ai/ai"
import type { ToolContent } from "@opencode-ai/ai"
import { Effect, Ref, Schema } from "effect"
import { definition, make, settle, type AnyTool } from "./tool"
import { execute, make, toLLMDefinition, type Content, type Metadata, type Registration } from "./tool"
const ExecuteFile = Schema.Struct({
data: Schema.String,
@ -14,16 +15,11 @@ const ExecuteFile = Schema.Struct({
const ExecuteCall = Schema.Struct({
tool: Schema.String,
status: Schema.Literals(["running", "completed", "error"]),
input: Schema.optionalKey(Schema.Record(Schema.String, Schema.Unknown)),
input: Schema.optionalKey(Schema.Record(Schema.String, Schema.Json)),
})
type ExecuteCall = typeof ExecuteCall.Type
const ExecuteMetadata = Schema.Struct({
toolCalls: Schema.Array(ExecuteCall),
error: Schema.optionalKey(Schema.Literal(true)),
})
const ExecuteOutput = Schema.Struct({
output: Schema.String,
toolCalls: Schema.Array(ExecuteCall),
@ -36,12 +32,6 @@ type CollectedFiles = {
readonly files: Array<typeof ExecuteFile.Type>
}
export interface Registration {
readonly tool: AnyTool
readonly name: string
readonly namespace?: string
}
// Invariant model-facing guidance; the changing tool catalog is delivered through Instructions.
const description = [
"Run JavaScript in a confined Code Mode runtime through { code }.",
@ -55,20 +45,6 @@ export const create = (registrations: ReadonlyMap<string, Registration>) => {
description,
input: CodeMode.Input,
output: ExecuteOutput,
structured: ExecuteMetadata,
toStructuredOutput: ({ output }) => ({
toolCalls: output.toolCalls,
...(output.error ? { error: true as const } : {}),
}),
toModelOutput: ({ output }) => [
{ type: "text" as const, text: output.output },
...output.files.map((file) => ({
type: "file" as const,
data: file.data,
mime: file.mime,
...(file.name === undefined ? {} : { name: file.name }),
})),
],
execute: ({ code }, context) =>
Effect.gen(function* () {
const callIndex = yield* Ref.make(0)
@ -85,21 +61,17 @@ export const create = (registrations: ReadonlyMap<string, Registration>) => {
(name, registration, input) =>
Effect.gen(function* () {
const index = yield* Ref.getAndUpdate(callIndex, (index) => index + 1)
const output = yield* settle(
registration.tool,
{ type: "tool-call", id: context.callID, name, input },
{
sessionID: context.sessionID,
agent: context.agent,
messageID: context.messageID,
callID: context.callID,
progress: context.progress,
},
).pipe(Effect.mapError((failure) => toolError(failure.message, failure)))
const outputFileParts = outputFiles(output)
const executed = yield* execute(registration.tool, input, {
sessionID: context.sessionID,
agent: context.agent,
messageID: context.messageID,
callID: context.callID,
progress: context.progress,
}).pipe(Effect.mapError((failure) => toolError(failure.message, failure)))
const outputFileParts = outputFiles(executed.content)
if (outputFileParts.length > 0)
yield* Ref.update(files, (items) => [...items, { index, files: outputFileParts }])
return output.structured
return executed.output
}),
{
onToolCallStart: ({ index, name, input }) =>
@ -126,7 +98,30 @@ export const create = (registrations: ReadonlyMap<string, Registration>) => {
.toSorted((left, right) => left.index - right.index)
.flatMap((item) => item.files)
const output = formatResult(result)
return { output, toolCalls, files: collected, ...(result.ok ? {} : { error: true as const }) }
const value: typeof ExecuteOutput.Type = {
output,
toolCalls,
files: collected,
...(result.ok ? {} : { error: true }),
}
const content: [Content, ...Content[]] = [{ type: "text", text: value.output }]
content.push(
...value.files.map((file) => ({
type: "file" as const,
data: file.data,
mime: file.mime,
...(file.name === undefined ? {} : { name: file.name }),
})),
)
const metadata: Metadata = {
toolCalls: value.toolCalls,
...(value.error ? { error: true } : {}),
}
return {
output: value,
content,
metadata,
}
}),
})
}
@ -137,28 +132,30 @@ export const instructions = (registrations: ReadonlyMap<string, Registration>) =
function runtime(
registrations: ReadonlyMap<string, Registration>,
invoke: (name: string, registration: Registration, input: unknown) => Effect.Effect<unknown, unknown>,
executeTool: (name: string, registration: Registration, input: unknown) => Effect.Effect<unknown, unknown>,
hooks?: CodeMode.ToolCallHooks,
) {
const tools: Record<string, Tool.Definition<never>> = {}
const tools: Record<string, Tool.Tool<never>> = {}
for (const [name, registration] of registrations) {
const child = definition(name, registration.tool)
const path = registration.namespace === undefined ? registration.name : `${registration.namespace}.${registration.name}`
const child = toLLMDefinition(name, registration.tool)
const path =
registration.namespace === undefined ? registration.name : `${registration.namespace}.${registration.name}`
tools[path] = Tool.make({
description: child.description,
input: child.inputSchema,
output: child.outputSchema,
run: (input) => invoke(name, registration, input),
execute: (input) => executeTool(name, registration, input),
})
}
return CodeMode.make<typeof tools>({ tools, ...hooks })
}
function displayInput(input: unknown): Record<string, unknown> | undefined {
// Tool inputs arrive as parsed JSON, so the JSON value cast is a boundary fact.
function displayInput(input: unknown): Record<string, typeof Schema.Json.Type> | undefined {
if (input === null || input === undefined) return
if (typeof input !== "object" || Array.isArray(input)) return { input }
if (typeof input !== "object" || Array.isArray(input)) return { input: input as typeof Schema.Json.Type }
if (Object.keys(input).length === 0) return
return input as Record<string, unknown>
return input as Record<string, typeof Schema.Json.Type>
}
function formatResult(result: CodeMode.Result) {
@ -180,8 +177,8 @@ function formatValue(value: CodeMode.DataValue) {
return JSON.stringify(value, null, 2) ?? String(value)
}
function outputFiles(output: ToolOutput): Array<typeof ExecuteFile.Type> {
return output.content.flatMap((part) => {
function outputFiles(content: ReadonlyArray<ToolContent>): Array<typeof ExecuteFile.Type> {
return content.flatMap((part) => {
if (part.type !== "file") return []
const prefix = `data:${part.mime};base64,`
if (!part.uri.startsWith(prefix)) return []

View file

@ -8,7 +8,7 @@ import { FileSystem } from "../filesystem"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Location } from "../location"
import { Ripgrep } from "../ripgrep"
import { NonNegativeInt, RelativePath } from "../schema"
import { RelativePath } from "../schema"
import { PermissionV2 } from "../permission"
import { Tool } from "./tool"
@ -25,9 +25,6 @@ export const Input = Schema.Struct({
})
export const Output = Schema.Array(FileSystem.Entry)
const StructuredOutput = Schema.Struct({
count: NonNegativeInt,
})
type ModelOutput = typeof Output.Encoded
/** Format raw search results into the concise line-oriented output models expect. */
@ -54,16 +51,6 @@ export const Plugin = {
"Find files by glob pattern within the active Location. Returns concise relative file resources. Use a relative path to narrow the search and limit to bound the result count.",
input: Input,
output: Output,
structured: StructuredOutput,
toStructuredOutput: ({ output }) => ({ count: output.length }),
toModelOutput: ({ output }) => [
{
type: "text",
text: toModelOutput(
output.map((entry) => ({ ...entry, path: path.resolve(location.directory, entry.path) })),
),
},
],
execute: (input, context) =>
Effect.gen(function* () {
yield* permission.assert({
@ -104,6 +91,13 @@ export const Plugin = {
),
)
}).pipe(
Effect.map((output) => ({
output,
content: toModelOutput(
output.map((entry) => ({ ...entry, path: path.resolve(location.directory, entry.path) })),
),
metadata: { count: output.length },
})),
Effect.mapError((error) =>
error instanceof ToolFailure
? error

View file

@ -9,7 +9,7 @@ import { FSUtil } from "@opencode-ai/util/fs-util"
import { Location } from "../location"
import { PermissionV2 } from "../permission"
import { Ripgrep } from "../ripgrep"
import { NonNegativeInt, RelativePath } from "../schema"
import { RelativePath } from "../schema"
import { Tool } from "./tool"
export const name = "grep"
@ -30,9 +30,6 @@ export const Input = Schema.Struct({
})
export const Output = Schema.Array(FileSystem.Match)
const StructuredOutput = Schema.Struct({
matches: NonNegativeInt,
})
type ModelOutput = typeof Output.Encoded
/** Format raw search matches into the familiar concise model output. */
@ -68,19 +65,6 @@ export const Plugin = {
"Search file contents by regular expression within the active Location or an absolute managed tool-output file. Use a path to narrow the search, include to filter files by glob, and limit to bound the match count. Returns concise file resources, line numbers, and bounded line previews.",
input: Input,
output: Output,
structured: StructuredOutput,
toStructuredOutput: ({ output }) => ({ matches: output.length }),
toModelOutput: ({ output }) => [
{
type: "text",
text: toModelOutput(
output.map((match) => ({
...match,
entry: { ...match.entry, path: path.resolve(location.directory, match.entry.path) },
})),
),
},
],
execute: (input, context) =>
Effect.gen(function* () {
yield* permission.assert({
@ -135,6 +119,16 @@ export const Plugin = {
),
)
}).pipe(
Effect.map((output) => ({
output,
content: toModelOutput(
output.map((match) => ({
...match,
entry: { ...match.entry, path: path.resolve(location.directory, match.entry.path) },
})),
),
metadata: { matches: output.length },
})),
Effect.mapError((error) =>
error instanceof ToolFailure
? error

View file

@ -1,33 +1,14 @@
export * as ToolHooks from "./hooks"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { Agent } from "@opencode-ai/schema/agent"
import { Session } from "@opencode-ai/schema/session"
import { SessionMessage } from "../session/message"
import { State } from "../state"
import { Context, Effect, Layer, Scope } from "effect"
import type { ToolOutput, ToolResultValue } from "@opencode-ai/ai"
import type { Tool } from "./tool"
export interface BeforeEvent {
readonly tool: string
readonly sessionID: Session.ID
readonly agent: Agent.ID
readonly messageID: SessionMessage.ID
readonly callID: string
input: unknown
}
export type BeforeEvent = Tool.ToolExecuteBeforeEvent
export interface AfterEvent {
readonly tool: string
readonly sessionID: Session.ID
readonly agent: Agent.ID
readonly messageID: SessionMessage.ID
readonly callID: string
readonly input: unknown
result: ToolResultValue
output?: ToolOutput
outputPaths?: ReadonlyArray<string>
}
/** The canonical execution outcome. Hooks never observe the raw domain output. */
export type AfterEvent = Tool.ToolExecuteAfterEvent
export interface Interface {
readonly hook: {

View file

@ -32,86 +32,90 @@ export const layer = Layer.effectDiscard(
// registry never has a gap where MCP tools disappear mid-swap.
const reconcile = lock.withPermit(
Effect.gen(function* () {
const groups = new Map<string, { tools: Record<string, Tool.AnyTool>; codemode: boolean }>()
const groups = new Map<
string,
{
tools: Record<string, Tool.Any>
codemode: boolean
}
>()
for (const tool of yield* mcp.tools()) {
const group = groups.get(tool.server) ?? { tools: {}, codemode: tool.codemode !== false }
const schema = (tool.inputSchema ?? {}) as JsonSchema.JsonSchema
group.tools[tool.name] = Tool.withPermission(
Tool.make({
description: tool.description ?? "",
jsonSchema: {
...schema,
type: "object",
properties: schema.properties ?? {},
additionalProperties: false,
},
outputSchema: tool.outputSchema as JsonSchema.JsonSchema | undefined,
execute: (input, context) =>
Effect.gen(function* () {
yield* permission.assert({
action: name(tool.server, tool.name),
resources: ["*"],
save: ["*"],
metadata: {},
sessionID: context.sessionID,
agent: context.agent,
source: {
type: "tool",
messageID: context.messageID,
callID: context.callID,
},
group.tools[tool.name] = Tool.make({
description: tool.description ?? "",
input: {
...schema,
type: "object",
properties: schema.properties ?? {},
additionalProperties: false,
},
output: (tool.outputSchema ?? {}) as JsonSchema.JsonSchema,
execute: (input, context) =>
Effect.gen(function* () {
yield* permission.assert({
action: name(tool.server, tool.name),
resources: ["*"],
save: ["*"],
metadata: {},
sessionID: context.sessionID,
agent: context.agent,
source: {
type: "tool",
messageID: context.messageID,
callID: context.callID,
},
})
const result = yield* mcp
.callTool({
server: tool.server,
name: tool.name,
args: (input ?? {}) as Record<string, unknown>,
})
const result = yield* mcp
.callTool({
server: tool.server,
name: tool.name,
args: (input ?? {}) as Record<string, unknown>,
})
.pipe(
Effect.catchTags({
"MCP.NotFoundError": (error) =>
new ToolFailure({ message: `MCP server "${error.server}" is not available` }),
"MCP.ToolCallError": (error) => new ToolFailure({ message: error.message }),
}),
)
if (result.isError)
return yield* new ToolFailure({
message:
result.content
.flatMap((part) => (part.type === "text" ? [part.text] : []))
.join("\n")
.trim() || "MCP tool returned an error",
})
const content = result.content.map((part) =>
part.type === "text"
? { type: "text" as const, text: part.text }
: { type: "file" as const, data: part.data, mime: part.mimeType },
.pipe(
Effect.catchTags({
"MCP.NotFoundError": (error) =>
new ToolFailure({ message: `MCP server "${error.server}" is not available` }),
"MCP.ToolCallError": (error) => new ToolFailure({ message: error.message }),
}),
)
const text = content.flatMap((part) => (part.type === "text" ? [part.text] : [])).join("\n")
return {
structured: result.structured ?? (text === "" ? null : text),
content,
}
}).pipe(
Effect.mapError((error) =>
error instanceof ToolFailure
? error
: new ToolFailure({ message: `Unable to execute ${name(tool.server, tool.name)}` }),
),
if (result.isError)
return yield* new ToolFailure({
message:
result.content
.flatMap((part) => (part.type === "text" ? [part.text] : []))
.join("\n")
.trim() || "MCP tool returned an error",
})
const content = result.content.map((part) =>
part.type === "text"
? { type: "text" as const, text: part.text }
: { type: "file" as const, data: part.data, mime: part.mimeType },
)
const text = content.flatMap((part) => (part.type === "text" ? [part.text] : [])).join("\n")
return {
output: result.structured ?? (text === "" ? null : text),
...(content.length === 0 ? {} : { content: content as [Tool.Content, ...Tool.Content[]] }),
}
}).pipe(
Effect.mapError((error) =>
error instanceof ToolFailure
? error
: new ToolFailure({ message: `Unable to execute ${name(tool.server, tool.name)}` }),
),
}),
name(tool.server, tool.name),
)
),
})
groups.set(tool.server, group)
}
const next = yield* Scope.fork(scope)
yield* Effect.forEach(
groups,
([server, group]) => tools.register(group.tools, { namespace: namespace(server), codemode: group.codemode }),
{
discard: true,
},
).pipe(Scope.provide(next), Effect.orDie)
yield* tools
.registerBatch(
Array.from(groups, ([server, group]) => ({
tools: group.tools,
options: { namespace: namespace(server), codemode: group.codemode },
})),
)
.pipe(Scope.provide(next), Effect.orDie)
if (current) yield* Scope.close(current, Exit.void)
current = next
}),

View file

@ -75,12 +75,10 @@ export const Plugin = {
.transform((draft) =>
draft.add(
name,
Tool.withPermission(
Tool.make({
Tool.make({
description: DESCRIPTION,
input: Input,
output: Output,
toModelOutput: ({ output }) => [{ type: "text", text: toModelOutput(output) }],
execute: (input, context) => {
const applied: Array<typeof Applied.Type> = []
const fail = (path: string, error?: unknown) => {
@ -278,12 +276,17 @@ export const Plugin = {
{ discard: true },
)
return { applied, files: patchFiles }
}).pipe(Effect.mapError((error) => (error instanceof ToolFailure ? error : fail("patch", error))))
}).pipe(
Effect.map((output) => ({
output,
content: toModelOutput(output),
metadata: { files: output.files },
})),
Effect.mapError((error) => (error instanceof ToolFailure ? error : fail("patch", error))),
)
},
}),
"edit",
),
{ codemode: false },
{ codemode: false, permission: "edit" },
),
)
.pipe(Effect.orDie)

View file

@ -63,9 +63,6 @@ export const Plugin = {
description,
input: Input,
output: Output,
toModelOutput: ({ input, output }) => [
{ type: "text", text: toModelOutput(input.questions, output.answers) },
],
execute: (input, context) =>
permission
.assert({
@ -95,13 +92,18 @@ export const Plugin = {
),
Effect.flatMap((state) => {
if (state.status === "cancelled") return Effect.die(new CancelledError())
return Effect.succeed({
const output = {
answers: input.questions.map((_, index): QuestionV2.Answer => {
const value = state.answer[`q${index}`]
if (value === undefined) return []
if (typeof value === "object") return Array.from(value)
return [String(value)]
}),
}
return Effect.succeed({
output,
content: toModelOutput(input.questions, output.answers),
metadata: { answers: output.answers },
})
}),
),

View file

@ -48,20 +48,6 @@ export const Plugin = {
"Read a text file or supported image, page through a large UTF-8 text file by line offset, or list a directory page. Relative paths resolve from the current location; absolute paths inside it are accepted, while external absolute paths require external_directory approval.",
input: Input,
output: Output,
structured: Schema.toEncoded(Output),
// Image base64 reaches the model through content items (normalized generically
// at tool settlement); persisting a second copy in structured would store the
// original unresized bytes in the message row.
toStructuredOutput: ({ output }) =>
"encoding" in output && output.encoding === "base64" ? { ...output, content: "" } : output,
toModelOutput: ({ input, output }) => {
if (!("encoding" in output) || output.encoding !== "base64" || !SUPPORTED_IMAGE_MIMES.has(output.mime))
return []
return [
{ type: "text", text: "Image read successfully" },
{ type: "file", data: output.content, mime: output.mime, name: input.path },
]
},
execute: (input, context) => {
return Effect.gen(function* () {
const source = {
@ -125,6 +111,20 @@ export const Plugin = {
return yield* Effect.fail(new ReadToolFileSystem.BinaryFileError({ resource }))
return content
}).pipe(
Effect.map((output) => {
// Image base64 reaches the model through content items; avoid a second
// unresized copy in model text.
const content =
"encoding" in output && output.encoding === "base64"
? SUPPORTED_IMAGE_MIMES.has(output.mime)
? ([
{ type: "text", text: "Image read successfully" },
{ type: "file", data: output.content, mime: output.mime, name: input.path },
] as const)
: JSON.stringify({ ...output, content: "" }, null, 2)
: JSON.stringify(output, null, 2)
return { output, content }
}),
Effect.mapError((error) => {
const message =
error instanceof ReadToolFileSystem.BinaryFileError ||

View file

@ -1,7 +1,7 @@
export * as ToolRegistry from "./registry"
import { ToolOutput, type ToolCall, type ToolDefinition, type ToolResultValue } from "@opencode-ai/ai"
import { Context, Effect, Layer, Scope, Semaphore } from "effect"
import { type ToolCall, type ToolContent, type ToolDefinition } from "@opencode-ai/ai"
import { Context, Effect, Layer, Schema, Scope, Semaphore } from "effect"
import type { AgentV2 } from "../agent"
import { Image } from "../image"
import { PermissionV2 } from "../permission"
@ -10,19 +10,10 @@ import { SessionSchema } from "../session/schema"
import { ToolOutputStore } from "../tool-output-store"
import { Wildcard } from "../util/wildcard"
import { CodeMode } from "../codemode"
import {
definition,
permission,
registrationEntries,
RegistrationError,
settle,
validateNamespace,
type AnyTool,
} from "./tool"
import { Tool, nonEmpty, registrationEntries, toLLMDefinition, validateName, validateNamespace } from "./tool"
import { Tools } from "./tools"
import { ToolHooks } from "./hooks"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { SessionError } from "@opencode-ai/schema/session-error"
import { toSessionError } from "../session/to-session-error"
export type ExecuteInput = {
@ -33,38 +24,42 @@ export type ExecuteInput = {
readonly progress?: (update: Progress) => Effect.Effect<void>
}
export interface Progress {
readonly structured: Readonly<Record<string, unknown>>
readonly content: ToolOutput["content"]
}
/** Live replacement metadata for a running tool. */
export type Progress = Tool.Metadata
export interface Interface {
readonly materialize: (permissions?: PermissionV2.Ruleset) => Effect.Effect<Materialization>
readonly snapshot: (permissions?: PermissionV2.Ruleset) => Effect.Effect<ToolSet>
/** Internal registration capability exposed publicly only through Tools.Service. */
readonly register: (
tools: Readonly<Record<string, AnyTool>>,
tools: Readonly<Record<string, Tool.Any>>,
options?: Tools.RegisterOptions,
) => Effect.Effect<void, RegistrationError, Scope.Scope>
) => Effect.Effect<void, Tool.RegistrationError, Scope.Scope>
/** Internal atomic registration capability used by plugin transforms. */
readonly registerBatch: (
registrations: ReadonlyArray<{
readonly tools: Readonly<Record<string, AnyTool>>
readonly tools: Readonly<Record<string, Tool.Any>>
readonly options?: Tools.RegisterOptions
}>,
) => Effect.Effect<void, RegistrationError, Scope.Scope>
) => Effect.Effect<void, Tool.RegistrationError, Scope.Scope>
}
export interface Materialization {
/**
* One request-scoped snapshot pairing advertised definitions with captured
* tools. A model request executes exactly the tool values it advertised
* even if registration changes while the request is in flight.
*/
export interface ToolSet {
readonly definitions: ReadonlyArray<ToolDefinition>
readonly settle: (input: ExecuteInput) => Effect.Effect<Settlement, ToolOutputStore.Error>
readonly execute: (input: ExecuteInput) => Effect.Effect<ToolOutcome, ToolOutputStore.Error>
}
export interface Settlement {
readonly result: ToolResultValue
readonly output?: ToolOutput
readonly outputPaths?: ReadonlyArray<string>
readonly error?: SessionError.Error
}
/**
* The canonical outcome of one local tool execution. `output` is the validated
* machine value for Code Mode and remains ephemeral; durable publication drops it.
*/
export type ToolOutcome =
| (Extract<Tool.Outcome, { readonly status: "completed" }> & { readonly output?: unknown })
| Extract<Tool.Outcome, { readonly status: "error" }>
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/ToolRegistry") {}
@ -76,26 +71,24 @@ const registryLayer = Layer.effect(
const image = yield* Image.Service
const codeMode = yield* CodeMode.Service
type NormalizedItem = ToolOutput["content"][number] | "decode" | "size"
const normalizeImages = Effect.fn("ToolRegistry.normalizeImages")(function* (content: ToolOutput["content"]) {
type NormalizedItem = ToolContent | "decode" | "size"
const normalizeImages = Effect.fn("ToolRegistry.normalizeImages")(function* (content: ReadonlyArray<ToolContent>) {
const normalized = yield* Effect.forEach(content, (item): Effect.Effect<NormalizedItem> => {
if (item.type !== "file" || !item.mime.startsWith("image/")) return Effect.succeed(item)
// RFC 2397 permits parameters between the mime and ";base64".
const base64 = /^data:[^,]*;base64,(.*)$/s.exec(item.uri)?.[1]
if (base64 === undefined) return Effect.succeed(item)
const resource = item.name ?? `${item.mime} tool output`
return image
.normalize(resource, { uri: resource, content: base64, encoding: "base64", mime: item.mime })
.pipe(
Effect.map((result) => ({
...item,
uri: `data:${result.mime};base64,${result.content}`,
mime: result.mime,
})),
Effect.catchTag("Image.ResizerUnavailableError", () => Effect.succeed(item)),
Effect.catchTag("Image.DecodeError", () => Effect.succeed("decode" as const)),
Effect.catchTag("Image.SizeError", () => Effect.succeed("size" as const)),
)
return image.normalize(resource, { uri: resource, content: base64, encoding: "base64", mime: item.mime }).pipe(
Effect.map((result) => ({
...item,
uri: `data:${result.mime};base64,${result.content}`,
mime: result.mime,
})),
Effect.catchTag("Image.ResizerUnavailableError", () => Effect.succeed(item)),
Effect.catchTag("Image.DecodeError", () => Effect.succeed("decode" as const)),
Effect.catchTag("Image.SizeError", () => Effect.succeed("size" as const)),
)
})
const note = (reason: "decode" | "size", text: string) => {
const count = normalized.filter((item) => item === reason).length
@ -108,16 +101,24 @@ const registryLayer = Layer.effect(
...note("size", "could not be resized below the image size limit."),
]
})
type Registration = {
readonly tool: AnyTool
readonly name: string
readonly namespace?: string
}
// Invalid or oversized metadata is dropped with a warning; it never fails a
// successful side-effecting tool.
const validMetadata = Effect.fnUntraced(function* (tool: string, metadata: Tool.Metadata | undefined) {
if (metadata === undefined) return undefined
const limits = yield* resources.limits()
const valid = Tool.jsonMetadata(metadata, limits.maxBytes)
if (valid === undefined)
yield* Effect.logWarning("dropping invalid or oversized tool metadata").pipe(Effect.annotateLogs({ tool }))
return valid
})
type Registration = Tool.Registration
const local = new Map<string, Array<{ readonly token: object; readonly registration: Registration }>>()
const registrationLock = Semaphore.makeUnsafe(1)
const settleTool = Effect.fn("ToolRegistry.settleTool")(function* (input: ExecuteInput, tool: AnyTool) {
// Hooks fire only for hosted/local tools; provider-executed calls never reach settleTool.
const executeTool = Effect.fn("ToolRegistry.executeTool")(function* (input: ExecuteInput, tool: Tool.Any) {
// Hooks fire only for hosted/local tools; provider-executed calls never reach executeTool.
const beforeEvent: ToolHooks.BeforeEvent = {
tool: input.call.name,
sessionID: input.sessionID,
@ -127,76 +128,100 @@ const registryLayer = Layer.effect(
input: input.call.input,
}
yield* toolHooks.runBefore(beforeEvent)
const pending = yield* settle(
tool,
{ ...input.call, input: beforeEvent.input },
{
sessionID: input.sessionID,
agent: input.agent,
messageID: input.messageID,
callID: input.call.id,
progress: (update) => {
const progress = input.progress
if (!progress) return Effect.void
return normalizeImages(
(update.content ?? []).map((part) =>
part.type === "text"
? { type: "text" as const, text: part.text }
: {
type: "file" as const,
uri: `data:${part.mime};base64,${part.data}`,
mime: part.mime,
name: part.name,
},
),
).pipe(Effect.flatMap((content) => progress({ structured: update.structured, content })))
},
const execution = yield* Tool.execute(tool, beforeEvent.input, {
sessionID: input.sessionID,
agent: input.agent,
messageID: input.messageID,
callID: input.call.id,
progress: (metadata) => {
const progress = input.progress
if (!progress) return Effect.void
return validMetadata(input.call.name, metadata).pipe(
Effect.flatMap((valid) => (valid === undefined ? Effect.void : progress(valid))),
)
},
).pipe(
Effect.map((output) => ({ output })),
Effect.catchTag("LLM.ToolFailure", (failure) =>
Effect.succeed({
result: { type: "error" as const, value: failure.message },
error: toSessionError(failure),
}),
),
}).pipe(
Effect.map((value) => ({ value })),
Effect.catchTag("LLM.ToolFailure", (failure) => Effect.succeed({ failure: toSessionError(failure) })),
)
let settlement: Settlement
if ("result" in pending) {
settlement = pending
} else {
const outcome: ToolOutcome = yield* Effect.gen(function* () {
if ("failure" in execution) return { status: "error" as const, error: execution.failure }
const bounded = yield* resources.bound({
sessionID: input.sessionID,
callID: input.call.id,
output: { structured: pending.output.structured, content: yield* normalizeImages(pending.output.content) },
content: yield* normalizeImages(execution.value.content),
})
const result = ToolOutput.toResultValue(bounded.output)
settlement =
result.type === "error"
? bounded.outputPaths.length > 0
? { result, outputPaths: bounded.outputPaths }
: { result }
: bounded.outputPaths.length > 0
? { result, output: bounded.output, outputPaths: bounded.outputPaths }
: { result, output: bounded.output }
}
const afterEvent: ToolHooks.AfterEvent = {
const metadata = yield* validMetadata(input.call.name, execution.value.metadata)
return {
status: "completed" as const,
...(execution.value.output === undefined ? {} : { output: execution.value.output }),
content: nonEmpty(bounded.content) ?? execution.value.content,
...(metadata === undefined ? {} : { metadata }),
...(bounded.outputPaths.length > 0 ? { outputPaths: bounded.outputPaths } : {}),
}
})
const base = {
tool: input.call.name,
sessionID: input.sessionID,
agent: input.agent,
messageID: input.messageID,
callID: input.call.id,
input: beforeEvent.input,
result: settlement.result,
output: settlement.output,
outputPaths: settlement.outputPaths,
}
const afterEvent: ToolHooks.AfterEvent =
outcome.status === "completed"
? {
...base,
status: "completed",
content: outcome.content,
...(outcome.metadata === undefined ? {} : { metadata: outcome.metadata }),
...(outcome.outputPaths === undefined ? {} : { outputPaths: outcome.outputPaths }),
}
: {
...base,
status: "error",
error: outcome.error,
...(outcome.content === undefined ? {} : { content: outcome.content }),
...(outcome.metadata === undefined ? {} : { metadata: outcome.metadata }),
...(outcome.outputPaths === undefined ? {} : { outputPaths: outcome.outputPaths }),
}
yield* toolHooks.runAfter(afterEvent)
const afterMetadata = yield* validMetadata(input.call.name, afterEvent.metadata)
const afterContent = yield* Effect.gen(function* () {
if (
afterEvent.content === undefined ||
(outcome.status === "completed" && afterEvent.content === outcome.content)
)
return { content: afterEvent.content, outputPaths: afterEvent.outputPaths }
const bounded = yield* resources.bound({
sessionID: input.sessionID,
callID: input.call.id,
content: yield* normalizeImages(afterEvent.content),
})
return {
content: nonEmpty(bounded.content),
outputPaths:
bounded.outputPaths.length === 0
? afterEvent.outputPaths
: Array.from(new Set([...(afterEvent.outputPaths ?? []), ...bounded.outputPaths])),
}
})
if (afterEvent.status === "completed")
return {
status: "completed" as const,
...(outcome.status === "completed" && outcome.output !== undefined ? { output: outcome.output } : {}),
content: afterContent.content ?? afterEvent.content,
...(afterMetadata === undefined ? {} : { metadata: afterMetadata }),
...(afterContent.outputPaths === undefined ? {} : { outputPaths: afterContent.outputPaths }),
}
return {
result: afterEvent.result,
...(afterEvent.output !== undefined ? { output: afterEvent.output } : {}),
...(afterEvent.outputPaths !== undefined ? { outputPaths: afterEvent.outputPaths } : {}),
...(settlement.error !== undefined ? { error: settlement.error } : {}),
status: "error" as const,
error: afterEvent.error,
...(afterContent.content === undefined ? {} : { content: afterContent.content }),
...(afterMetadata === undefined ? {} : { metadata: afterMetadata }),
...(afterContent.outputPaths === undefined ? {} : { outputPaths: afterContent.outputPaths }),
}
})
@ -205,12 +230,26 @@ const registryLayer = Layer.effect(
const planned = yield* Effect.forEach(registrations, ({ tools, options }) =>
Effect.gen(function* () {
if (options?.namespace !== undefined) yield* validateNamespace(options.namespace)
const entries = registrationEntries(tools, options?.namespace)
const entries = registrationEntries(tools, options)
yield* Effect.forEach(entries, (entry) => validateName(entry.name), { discard: true })
const collision = entries.find(
(entry, index) => entries.findIndex((candidate) => candidate.key === entry.key) !== index,
)
if (collision)
return yield* Effect.fail(
new Tool.RegistrationError({
name: collision.key,
message: `Duplicate normalized tool name: ${collision.key}`,
}),
)
const codemode = options?.codemode ?? true
const reserved = codemode ? undefined : entries.find((entry) => entry.key === "execute")
if (reserved)
return yield* Effect.fail(
new RegistrationError({ name: reserved.key, message: 'Tool name "execute" is reserved for CodeMode' }),
new Tool.RegistrationError({
name: reserved.key,
message: 'Tool name "execute" is reserved for CodeMode',
}),
)
return { tools, options, entries, codemode }
}),
@ -218,7 +257,7 @@ const registryLayer = Layer.effect(
// CodeMode registrations live in the CodeMode service; the registry keeps only direct tools.
yield* Effect.forEach(
planned.filter((plan) => plan.codemode && plan.entries.length > 0),
(plan) => codeMode.register(plan.tools, plan.options),
(plan) => codeMode.register(plan.entries),
{ discard: true },
)
const direct = planned.filter((plan) => !plan.codemode)
@ -237,6 +276,7 @@ const registryLayer = Layer.effect(
tool: entry.tool,
name: entry.name,
namespace: entry.namespace,
permission: entry.permission,
},
},
])
@ -269,7 +309,7 @@ const registryLayer = Layer.effect(
]),
),
registerBatch,
materialize: Effect.fn("ToolRegistry.materialize")((permissions) =>
snapshot: Effect.fn("ToolRegistry.snapshot")((permissions) =>
registrationLock.withPermit(
Effect.gen(function* () {
const direct = new Map<string, Registration>()
@ -277,21 +317,21 @@ const registryLayer = Layer.effect(
for (const [name, entries] of local) {
const registration = entries.at(-1)?.registration
if (!registration) continue
if (whollyDisabled(permission(registration.tool, name), rules)) continue
if (whollyDisabled(registration.permission, rules)) continue
direct.set(name, registration)
}
const execute = (yield* codeMode.materialize(permissions)).tool
const codemodeTool = (yield* codeMode.materialize(permissions)).tool
return {
definitions: [
...Array.from(direct, ([name, registration]) => definition(name, registration.tool)),
...(execute ? [definition("execute", execute)] : []),
...Array.from(direct, ([name, registration]) => toLLMDefinition(name, registration.tool)),
...(codemodeTool ? [toLLMDefinition("execute", codemodeTool)] : []),
],
settle: (input: ExecuteInput) => {
if (input.call.name === "execute" && execute) return settleTool(input, execute)
execute: (input: ExecuteInput) => {
if (input.call.name === "execute" && codemodeTool) return executeTool(input, codemodeTool)
const registration = direct.get(input.call.name)
if (registration) return settleTool(input, registration.tool)
return Effect.succeed({
result: { type: "error", value: `Unknown tool: ${input.call.name}` },
if (registration) return executeTool(input, registration.tool)
return Effect.succeed<ToolOutcome>({
status: "error",
error: { type: "tool.unknown", message: `Unknown tool: ${input.call.name}` },
})
},

View file

@ -3,7 +3,7 @@ export * as ShellTool from "./shell"
import path from "path"
import { ToolFailure } from "@opencode-ai/ai"
import type { Context as PluginContext } from "@opencode-ai/plugin/v2/effect/plugin"
import { Effect, Fiber, Schedule, Schema, Scope } from "effect"
import { Deferred, Effect, Schema, Scope } from "effect"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { LocationMutation } from "../location-mutation"
import { PermissionV2 } from "../permission"
@ -147,19 +147,6 @@ export const Plugin = {
description: `Execute one shell command string with the host user's filesystem, process, and network authority. The active Location is the default working directory. Relative workdir values resolve from that Location. External workdir values require external_directory approval; best-effort command-argument path warnings are advisory only. An optional timeout may be provided in milliseconds (zero: unlimited; foreground default: ${DEFAULT_TIMEOUT_MS}; maximum: ${MAX_TIMEOUT_MS}). Background commands default to unlimited. Uses the configured shell when set; otherwise uses /bin/sh on POSIX and COMSPEC or cmd.exe on Windows. Background mode (background=true) launches the command asynchronously and returns immediately; you are notified when it finishes.`,
input: Input,
output: Output,
structured: StructuredOutput,
toStructuredOutput: ({ output }) => ({
truncated: output.truncated,
...(output.exit === undefined ? {} : { exit: output.exit }),
...(output.shellID === undefined ? {} : { shellID: output.shellID }),
...(output.timeout === undefined ? {} : { timeout: output.timeout }),
}),
toModelOutput: ({ output }) => {
const parts: Content[] = [{ type: "text", text: output.output }]
const model = modelOutput(output)
if (model) parts.push({ type: "text", text: model })
return parts
},
execute: (input, context) =>
Effect.gen(function* () {
const source = {
@ -199,6 +186,7 @@ export const Plugin = {
timeout,
metadata: { sessionID: context.sessionID },
})
yield* context.progress({ shellID: info.id })
const captureShell = Effect.fn("ShellTool.captureShell")(function* () {
const page = yield* shell.output(info.id, { limit: MAX_CAPTURE_BYTES })
@ -232,7 +220,9 @@ export const Plugin = {
}
})
const settled = yield* Deferred.make<Output>()
const run = settleShell().pipe(
Effect.tap((output) => Deferred.succeed(settled, output)),
Effect.map((output) => output.output),
Effect.onInterrupt(() => shell.remove(info.id).pipe(Effect.ignore)),
)
@ -256,32 +246,8 @@ export const Plugin = {
}
}
let previousProgress: { readonly output: string; readonly truncated: boolean } | undefined
const progress = yield* Effect.sleep("1 second").pipe(
Effect.andThen(
captureShell().pipe(
Effect.flatMap((capture) =>
Effect.gen(function* () {
if (
previousProgress?.output === capture.output &&
previousProgress.truncated === capture.truncated
)
return
previousProgress = capture
yield* context.progress({
structured: { truncated: capture.truncated },
content: [{ type: "text", text: capture.output }],
})
}),
),
),
),
Effect.repeat(Schedule.forever),
Effect.forkIn(scope, { startImmediately: true }),
)
const result = yield* runtime.job.block({ id: job.id, sessionID: context.sessionID }).pipe(
Effect.onInterrupt(() => runtime.job.cancel(job.id).pipe(Effect.ignore)),
Effect.ensuring(Fiber.interrupt(progress)),
)
if (result?.type === "backgrounded") {
yield* shell.timeout(info.id, 0)
@ -298,11 +264,23 @@ export const Plugin = {
return yield* Effect.fail(new Error(result.info.error ?? "Command failed"))
if (result?.info.status === "cancelled") return yield* Effect.fail(new Error("Command cancelled"))
return {
...(yield* settleShell()),
...(warnings.length ? { warnings } : {}),
}
return { ...(yield* Deferred.await(settled)), ...(warnings.length ? { warnings } : {}) }
}).pipe(
Effect.map((output) => {
const content: [Content, ...Content[]] = [{ type: "text", text: output.output }]
const model = modelOutput(output)
if (model) content.push({ type: "text", text: model })
return {
output,
content,
metadata: {
truncated: output.truncated,
...("exit" in output && output.exit !== undefined ? { exit: output.exit } : {}),
...("shellID" in output && output.shellID !== undefined ? { shellID: output.shellID } : {}),
...("timeout" in output && output.timeout !== undefined ? { timeout: output.timeout } : {}),
},
}
}),
Effect.mapError(
(error) => new ToolFailure({ message: `Unable to execute command: ${input.command}`, error }),
),

View file

@ -21,11 +21,6 @@ export const Output = Schema.Struct({
directory: Schema.String,
output: Schema.String,
})
const StructuredOutput = Schema.Struct({
name: Output.fields.name,
directory: Output.fields.directory,
})
export const description = [
"Load a specialized skill when the task at hand matches one of the available skills in the instructions.",
"",
@ -70,9 +65,6 @@ export const Plugin = {
description,
input: Input,
output: Output,
structured: StructuredOutput,
toStructuredOutput: ({ output }) => ({ name: output.name, directory: output.directory }),
toModelOutput: ({ output }) => [{ type: "text", text: output.output }],
execute: (input, context) =>
Effect.gen(function* () {
const current = yield* skills.list()
@ -101,7 +93,13 @@ export const Plugin = {
output: toModelOutput(skill, files),
}
}).pipe(Effect.mapError((error) => unableToLoad(input.id, error)))
}),
}).pipe(
Effect.map((output) => ({
output,
content: output.output,
metadata: { name: output.name, directory: output.directory },
})),
),
}),
{ codemode: false },
),

View file

@ -31,11 +31,6 @@ export const Output = Schema.Struct({
status: Schema.Literals(["completed", "running"]),
output: Schema.String,
})
const StructuredOutput = Schema.Struct({
sessionID: Output.fields.sessionID,
status: Output.fields.status,
})
export const description = [
"Spawn a subagent: a child session running a configured agent with fresh context.",
"Foreground (default) runs the subagent to completion and returns its final response.",
@ -119,9 +114,6 @@ export const Plugin = {
description,
input: Input,
output: Output,
structured: StructuredOutput,
toStructuredOutput: ({ output }) => ({ sessionID: output.sessionID, status: output.status }),
toModelOutput: ({ output }) => [{ type: "text", text: output.output }],
execute: (input, context) =>
Effect.gen(function* () {
const parent = yield* runtime.session
@ -186,7 +178,7 @@ export const Plugin = {
const background = input.background === true
yield* context.progress({
structured: { sessionID: child.id, status: "running" },
metadata: { sessionID: child.id, status: "running" },
})
const run = Effect.gen(function* () {
@ -238,7 +230,13 @@ export const Plugin = {
if (result?.info.status === "cancelled")
return yield* new ToolFailure({ message: "Subagent cancelled" })
return { sessionID: child.id, status: "completed" as const, output: result?.info.output ?? NO_TEXT }
}),
}).pipe(
Effect.map((output) => ({
output,
content: output.output,
metadata: { sessionID: output.sessionID, status: output.status },
})),
),
}),
{ codemode: false },
),

View file

@ -1,2 +1,90 @@
export * as Tool from "@opencode-ai/plugin/v2/effect/tool"
export * as Tool from "./tool"
export * from "@opencode-ai/plugin/v2/effect/tool"
import type { ToolContent } from "@opencode-ai/ai"
import {
decodeInput,
encodeOutput,
type Any,
type Content,
type Context,
Failure,
type Metadata,
} from "@opencode-ai/plugin/v2/effect/tool"
import { Effect, Schema } from "effect"
/** Non-empty canonical model content. */
export type NonEmptyContent = readonly [ToolContent, ...ToolContent[]]
/**
* The execution-local result of one tool call: the machine output for
* Code Mode, canonical model content, and optional UI metadata. The typed
* domain output never leaves this function.
*/
export type Execution = {
readonly output?: unknown
readonly content: NonEmptyContent
readonly metadata?: Metadata
}
export const execute = (tool: Any, input: unknown, context: Context): Effect.Effect<Execution, Failure> =>
Effect.gen(function* () {
const decoded = yield* decodeInput(tool.input, input)
const result = yield* tool.execute(decoded, context)
if (tool.output === undefined) {
if ("output" in result) return yield* Effect.die("Tool result declared output without an output schema")
return {
content: contentFrom(result.content),
...(result.metadata === undefined ? {} : { metadata: result.metadata }),
}
}
if (!("output" in result))
return yield* Effect.fail(new Failure({ message: "Tool did not return its declared output" }))
const encoded = yield* encodeOutput(tool.output, result.output)
return {
output: encoded,
content: contentFrom(result.content, encoded),
...(result.metadata === undefined ? {} : { metadata: result.metadata }),
}
})
/** Model content from the tool's projection, falling back to the stringified encoded output. */
const contentFrom = (projected: string | ReadonlyArray<Content> | undefined, encoded?: unknown): NonEmptyContent => {
if (typeof projected === "string") return [textContent(projected)]
if (projected !== undefined) {
const mapped = nonEmpty(projected.map(toModelContent))
if (mapped !== undefined) return mapped
}
return [textContent(stringify(encoded))]
}
export const toModelContent = (part: Content): ToolContent =>
part.type === "text"
? { type: "text", text: part.text }
: { type: "file", uri: `data:${part.mime};base64,${part.data}`, mime: part.mime, name: part.name }
export const nonEmpty = (content: ReadonlyArray<ToolContent>): NonEmptyContent | undefined =>
content.length > 0 ? (content as NonEmptyContent) : undefined
const textContent = (text: string): ToolContent => ({ type: "text", text })
/** Human-readable text for an arbitrary value; strings pass through unchanged. */
export const stringify = (value: unknown) => {
if (typeof value === "string") return value
try {
return JSON.stringify(value) ?? String(value)
} catch {
return String(value)
}
}
const MetadataSchema = Schema.Record(Schema.String, Schema.Json)
/** Defensive boundary: non-JSON or oversized metadata is dropped, never failing the producing call. */
export const jsonMetadata = (value: unknown, maxBytes?: number): Metadata | undefined => {
if (value === undefined) return undefined
const decoded = Schema.decodeUnknownOption(MetadataSchema)(value)
if (decoded._tag === "None") return undefined
if (maxBytes !== undefined && Buffer.byteLength(JSON.stringify(decoded.value), "utf-8") > maxBytes) return undefined
return decoded.value
}

View file

@ -7,13 +7,13 @@ export type RegisterOptions = Tool.RegisterOptions
export interface Interface {
readonly register: (
tools: Readonly<Record<string, Tool.AnyTool>>,
tools: Readonly<Record<string, Tool.Any>>,
options?: Tool.RegisterOptions,
) => Effect.Effect<void, Tool.RegistrationError, Scope.Scope>
/** Internal atomic registration capability used by plugin transforms. */
readonly registerBatch: (
registrations: ReadonlyArray<{
readonly tools: Readonly<Record<string, Tool.AnyTool>>
readonly tools: Readonly<Record<string, Tool.Any>>
readonly options?: Tool.RegisterOptions
}>,
) => Effect.Effect<void, Tool.RegistrationError, Scope.Scope>

View file

@ -37,10 +37,6 @@ const Output = Schema.Struct({
format: Input.fields.format,
output: Schema.String,
})
const StructuredOutput = Schema.Struct({
contentType: Output.fields.contentType,
})
type Format = (typeof Input.Type)["format"]
const acceptHeader = (format: Format) => {
@ -129,9 +125,6 @@ export const Plugin = {
description,
input: Input,
output: Output,
structured: StructuredOutput,
toStructuredOutput: ({ output }) => ({ contentType: output.contentType }),
toModelOutput: ({ output }) => [{ type: "text", text: output.output }],
execute: (input, context) =>
Effect.gen(function* () {
yield* Effect.try({
@ -171,12 +164,13 @@ export const Plugin = {
try: () => convert(content, contentType, input.format),
catch: (error) => error,
})
return {
const result = {
url: input.url,
contentType,
format: input.format,
output,
}
return { output: result, content: result.output, metadata: { contentType: result.contentType } }
}).pipe(Effect.mapError((error) => new ToolFailure({ message: `Unable to fetch ${input.url}`, error }))),
}),
{ codemode: false },

View file

@ -190,10 +190,6 @@ const Output = Schema.Struct({
provider: Provider,
text: Schema.String,
})
const StructuredOutput = Schema.Struct({
provider: Output.fields.provider,
})
export const Plugin = {
id: "opencode.tool.websearch",
effect: Effect.fn("WebSearchTool.Plugin")(function* (ctx: PluginContext) {
@ -209,9 +205,6 @@ export const Plugin = {
description,
input: Input,
output: Output,
structured: StructuredOutput,
toStructuredOutput: ({ output }) => ({ provider: output.provider }),
toModelOutput: ({ output }) => [{ type: "text", text: output.text }],
execute: (input, context) => {
const provider = selectProvider(context.sessionID, config, config.provider)
return Effect.gen(function* () {
@ -250,10 +243,11 @@ export const Plugin = {
...(config.parallelApiKey ? { Authorization: `Bearer ${config.parallelApiKey}` } : {}),
},
)
return {
const output = {
provider,
text: text ?? NO_RESULTS,
}
return { output, content: output.text, metadata: { provider: output.provider } }
}).pipe(
Effect.mapError(
(error) => new ToolFailure({ message: `Unable to search the web for ${input.query}`, error }),

View file

@ -53,13 +53,11 @@ export const Plugin = {
.transform((draft) =>
draft.add(
name,
Tool.withPermission(
Tool.make({
Tool.make({
description:
"Write content to one file. Relative paths resolve within the active Location. Absolute paths inside the Location are accepted. Explicit external absolute paths require external_directory approval before edit approval.",
input: Input,
output: Output,
toModelOutput: ({ output }) => [{ type: "text", text: toModelOutput(output) }],
execute: (input, context) =>
Effect.gen(function* () {
const source = {
@ -86,12 +84,11 @@ export const Plugin = {
})
return yield* files.writeTextPreservingBom({ target, content: input.content })
}).pipe(
Effect.map((output) => ({ output, content: toModelOutput(output) })),
Effect.mapError((error) => new ToolFailure({ message: `Unable to write ${input.path}`, error })),
),
}),
"edit",
),
{ codemode: false },
{ codemode: false, permission: "edit" },
),
)
.pipe(Effect.orDie)

View file

@ -9,14 +9,16 @@ describe("CodeMode", () => {
it.effect("owns registrations, execute, and catalog materialization", () =>
Effect.gen(function* () {
const codeMode = yield* CodeMode.Service
yield* codeMode.register({
echo: Tool.make({
description: "Echo text",
input: Schema.Struct({ text: Schema.String }),
output: Schema.String,
execute: ({ text }) => Effect.succeed(text),
yield* codeMode.register(
Tool.registrationEntries({
echo: Tool.make({
description: "Echo text",
input: Schema.Struct({ text: Schema.String }),
output: Schema.String,
execute: ({ text }) => Effect.succeed({ output: text }),
}),
}),
})
)
const materialized = yield* codeMode.materialize()
expect(materialized.tool).toBeDefined()

View file

@ -25,6 +25,7 @@ import addSessionForkMigration from "@opencode-ai/core/database/migration/202607
import timeSuspendedMigration from "@opencode-ai/core/database/migration/20260709163752_time_suspended"
import instructionSyncMigration from "@opencode-ai/core/database/migration/20260710025429_instruction_sync"
import deleteToolProgressEventsMigration from "@opencode-ai/core/database/migration/20260722011141_delete_tool_progress_events"
import canonicalToolResultsMigration from "@opencode-ai/core/database/migration/20260722170000_canonical_tool_results"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { EventV2 } from "@opencode-ai/core/event"
@ -583,6 +584,208 @@ describe("DatabaseMigration", () => {
)
})
test("rewrites projected tool rows into the canonical result shape", async () => {
await run(
Effect.gen(function* () {
const db = yield* makeDb
yield* db.run(
sql`CREATE TABLE session_message (id text PRIMARY KEY, session_id text NOT NULL, type text NOT NULL, seq integer NOT NULL, time_created integer NOT NULL, time_updated integer NOT NULL, data text NOT NULL)`,
)
yield* db.run(sql`CREATE TABLE event (id text PRIMARY KEY, type text NOT NULL, data text NOT NULL)`)
const assistant = {
agent: "build",
model: { id: "model", providerID: "provider" },
content: [
{ type: "text", text: "before" },
{
type: "tool",
id: "call_content",
name: "grep",
state: {
status: "completed",
input: { pattern: "TODO" },
content: [{ type: "text", text: "src/a.ts:1: TODO" }],
structured: { value: [{ file: "src/a.ts", line: 1 }] },
},
time: { created: 1, completed: 2 },
},
{
type: "tool",
id: "call_structured_only",
name: "read",
state: {
status: "completed",
input: { path: "README.md" },
content: [],
structured: { text: "hello" },
},
time: { created: 1, completed: 2 },
},
{
type: "tool",
id: "call_hosted",
name: "web_search",
executed: true,
providerResultState: { blockType: "web_search_tool_result" },
state: {
status: "completed",
input: { query: "effect" },
content: [],
structured: {},
result: { type: "json", value: [{ url: "https://example.com" }] },
},
time: { created: 1, completed: 2 },
},
{
type: "tool",
id: "call_failed",
name: "shell",
state: {
status: "error",
input: { command: "sleep 99" },
error: { type: "tool.execution", message: "timed out" },
content: [{ type: "text", text: "partial output" }],
structured: { truncated: false },
result: { type: "error", value: "timed out" },
},
time: { created: 1, completed: 2 },
},
{
type: "tool",
id: "call_running",
name: "shell",
state: {
status: "running",
input: { command: "sleep 1" },
structured: { truncated: false },
content: [{ type: "text", text: "tick" }],
},
time: { created: 1, ran: 2 },
},
],
time: { created: 1 },
}
yield* db.run(
sql`INSERT INTO session_message VALUES ('msg_tools', 'ses_test', 'assistant', 1, 10, 11, ${JSON.stringify(assistant)})`,
)
yield* db.run(
sql`INSERT INTO session_message VALUES ('msg_user', 'ses_test', 'user', 2, 12, 13, '{"text":"hi","time":{"created":1}}')`,
)
// A row that never decoded must be skipped, not fail the migration.
yield* db.run(
sql`INSERT INTO session_message VALUES ('msg_corrupt', 'ses_test', 'assistant', 3, 14, 15, 'not json')`,
)
yield* db.run(
sql`INSERT INTO event VALUES ('evt_success', 'session.tool.success.1', ${JSON.stringify({
sessionID: "ses_test",
assistantMessageID: "msg_tools",
callID: "call_hosted",
structured: {},
content: [],
result: { type: "json", value: [{ url: "https://example.com" }] },
executed: true,
})})`,
)
yield* db.run(
sql`INSERT INTO event VALUES ('evt_failed', 'session.tool.failed.1', ${JSON.stringify({
sessionID: "ses_test",
assistantMessageID: "msg_tools",
callID: "call_failed",
error: { type: "tool.execution", message: "timed out" },
metadata: { truncated: false },
executed: false,
})})`,
)
yield* DatabaseMigration.applyOnly(db, [canonicalToolResultsMigration])
const row = yield* db.get<{ data: string }>(sql`SELECT data FROM session_message WHERE id = 'msg_tools'`)
const migrated = JSON.parse(row!.data)
// Every migrated row must decode with the current schema; reload hard-fails otherwise.
Schema.decodeUnknownSync(SessionMessage.Info)({ ...migrated, id: "msg_tools", type: "assistant" })
const states = new Map(
migrated.content.flatMap((part: { type: string; id?: string }) =>
part.type === "tool" ? [[part.id, part]] : [],
),
)
expect(states.get("call_content")).toMatchObject({
state: {
status: "completed",
input: { pattern: "TODO" },
content: [{ type: "text", text: "src/a.ts:1: TODO" }],
// Old generic structured payloads survive as canonical metadata.
metadata: { value: [{ file: "src/a.ts", line: 1 }] },
},
})
expect((states.get("call_content") as { state: Record<string, unknown> }).state).not.toHaveProperty(
"structured",
)
expect(states.get("call_structured_only")).toMatchObject({
state: {
status: "completed",
content: [{ type: "text", text: JSON.stringify({ text: "hello" }, null, 2) }],
metadata: { text: "hello" },
},
})
expect(states.get("call_hosted")).toMatchObject({
executed: true,
providerResultState: {
blockType: "web_search_tool_result",
result: [{ url: "https://example.com" }],
},
state: {
status: "completed",
content: [{ type: "text", text: JSON.stringify([{ url: "https://example.com" }], null, 2) }],
},
})
expect(states.get("call_failed")).toMatchObject({
state: {
status: "error",
error: { type: "tool.execution", message: "timed out" },
content: [{ type: "text", text: "partial output" }],
metadata: { truncated: false },
},
})
const failedState = (states.get("call_failed") as { state: Record<string, unknown> }).state
expect(failedState).not.toHaveProperty("result")
expect(failedState).not.toHaveProperty("structured")
expect(states.get("call_running")).toMatchObject({
state: {
status: "running",
metadata: { truncated: false },
},
})
const event = yield* db.get<{ type: string; data: string }>(sql`SELECT type, data FROM event WHERE id = 'evt_success'`)
expect(event!.type).toBe("session.tool.success.1")
expect(JSON.parse(event!.data)).toEqual({
sessionID: "ses_test",
assistantMessageID: "msg_tools",
callID: "call_hosted",
structured: {},
content: [],
result: { type: "json", value: [{ url: "https://example.com" }] },
executed: true,
})
const failedEvent = yield* db.get<{ type: string; data: string }>(sql`SELECT type, data FROM event WHERE id = 'evt_failed'`)
expect(failedEvent!.type).toBe("session.tool.failed.1")
expect(JSON.parse(failedEvent!.data)).toEqual({
sessionID: "ses_test",
assistantMessageID: "msg_tools",
callID: "call_failed",
error: { type: "tool.execution", message: "timed out" },
metadata: { truncated: false },
executed: false,
})
expect(yield* db.get(sql`SELECT data FROM session_message WHERE id = 'msg_user'`)).toEqual({
data: '{"text":"hi","time":{"created":1}}',
})
expect(yield* db.get(sql`SELECT data FROM session_message WHERE id = 'msg_corrupt'`)).toEqual({
data: "not json",
})
}),
)
})
test("records the authoritative parent sequence on existing forks", async () => {
await run(
Effect.gen(function* () {

View file

@ -14,7 +14,7 @@ export const toolIdentity = {
}
export const toolDefinitions = (registry: ToolRegistry.Interface, permissions?: PermissionV2.Ruleset) =>
registry.materialize(permissions).pipe(Effect.map((materialized) => materialized.definitions))
registry.snapshot(permissions).pipe(Effect.map((toolSet) => toolSet.definitions))
export function waitForTool(
registry: ToolRegistry.Interface,
@ -35,7 +35,7 @@ export function waitForTool(
/**
* Registers a core tool plugin's tools against the real registry without booting the
* full plugin host. Only the tool domain is live; focused tool tests exercise
* registration, materialization, and settlement through the same path production uses.
* registration, snapshots, and execution through the same path production uses.
*/
export const registerToolPlugin = <R>(plugin: {
readonly id: string
@ -52,7 +52,7 @@ export const registerToolPlugin = <R>(plugin: {
Effect.gen(function* () {
const registrations: Array<{
readonly name: string
readonly tool: Tool.AnyTool
readonly tool: Tool.Any
readonly options?: Tool.RegisterOptions
}> = []
callback({
@ -73,8 +73,5 @@ export const registerToolPlugin = <R>(plugin: {
yield* plugin.effect(context)
})
export const settleTool = (registry: ToolRegistry.Interface, input: ToolRegistry.ExecuteInput) =>
registry.materialize().pipe(Effect.flatMap((materialized) => materialized.settle(input)))
export const executeTool = (registry: ToolRegistry.Interface, input: ToolRegistry.ExecuteInput) =>
settleTool(registry, input).pipe(Effect.map((settlement) => settlement.result))
registry.snapshot().pipe(Effect.flatMap((toolSet) => toolSet.execute(input)))

View file

@ -33,7 +33,7 @@ import { Image } from "@opencode-ai/core/image"
import { testEffect } from "./lib/effect"
import { imagePassthrough } from "./lib/image"
import { location } from "./fixture/location"
import { settleTool, toolDefinitions, toolIdentity, waitForTool } from "./lib/tool"
import { executeTool, toolDefinitions, toolIdentity, waitForTool } from "./lib/tool"
let assertion: Deferred.Deferred<PermissionV2.AssertInput> | undefined
let decision: Effect.Effect<void, PermissionV2.Error> = Effect.void
@ -241,10 +241,41 @@ const mcp = Layer.mock(MCP.Service, {
description: "Lookup",
inputSchema: { type: "object", properties: {} },
}),
new MCP.Tool({
server: MCP.ServerName.make("direct"),
name: "fail",
codemode: false,
description: "Always fails",
inputSchema: { type: "object", properties: {} },
}),
new MCP.Tool({
server: MCP.ServerName.make("direct"),
name: "media",
codemode: false,
description: "Returns text and an image",
inputSchema: { type: "object", properties: {} },
}),
]),
callTool: (input) =>
Effect.sync(() => {
calls += 1
if (input.name === "fail")
return new MCP.ToolResult({
server: MCP.ServerName.make(input.server),
tool: input.name,
isError: true,
content: [{ type: "text", text: "search index unavailable" }],
})
if (input.name === "media")
return new MCP.ToolResult({
server: MCP.ServerName.make(input.server),
tool: input.name,
isError: false,
content: [
{ type: "text", text: "rendered chart" },
{ type: "media", data: "aGVsbG8=", mimeType: "image/png" },
],
})
return new MCP.ToolResult({
server: MCP.ServerName.make(input.server),
tool: input.name,
@ -647,9 +678,7 @@ test("loads and reads MCP resources", async () => {
})
expect(server.clientVersion()).toMatchObject({ name: "sdk", version: "1.2.3" })
}).pipe(
Effect.provide(
resourceMcpLayer(server.url, undefined, { clientInfo: { name: "sdk", version: "1.2.3" } }),
),
Effect.provide(resourceMcpLayer(server.url, undefined, { clientInfo: { name: "sdk", version: "1.2.3" } })),
)
}),
),
@ -774,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")
}),
@ -793,6 +822,50 @@ it.effect("advertises MCP tools directly when Code Mode is disabled for the serv
}),
)
// Baseline (PLAN.md step 1): MCP isError must become one failed tool call, not a
// success whose text happens to describe an error.
it.effect("fails the call when MCP reports isError", () =>
Effect.gen(function* () {
assertion = yield* Deferred.make<PermissionV2.AssertInput>()
decision = Effect.void
const registry = yield* ToolRegistry.Service
yield* waitForTool(registry, "direct_fail")
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(execution).toMatchObject({ status: "error", error: { message: "search index unavailable" } })
expect(execution.content).toBeUndefined()
}),
)
// Baseline (PLAN.md step 1): mixed MCP text and media content must reach the model intact.
it.effect("preserves MCP text and media content for the model", () =>
Effect.gen(function* () {
assertion = yield* Deferred.make<PermissionV2.AssertInput>()
decision = Effect.void
const registry = yield* ToolRegistry.Service
yield* waitForTool(registry, "direct_media")
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(execution.status).toBe("completed")
if (execution.status !== "completed") return
expect(execution.output).toBe("rendered chart")
expect(execution.content).toMatchObject([
{ type: "text", text: "rendered chart" },
{ type: "file", mime: "image/png" },
])
}),
)
it.effect("waits for permission before calling an MCP tool", () =>
Effect.gen(function* () {
calls = 0
@ -802,7 +875,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: {
@ -841,7 +914,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: {
@ -851,8 +924,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,
})

View file

@ -258,7 +258,7 @@ describe("PluginV2", () => {
description: "Plugin tool",
input: Schema.Struct({}),
output: Schema.Struct({ ok: Schema.Boolean }),
execute: () => Effect.succeed({ ok: true }),
execute: () => Effect.succeed({ output: { ok: true } }),
}),
{ codemode: false },
),
@ -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")
}),
)
@ -283,7 +283,7 @@ describe("PluginV2", () => {
description,
input: Schema.Struct({}),
output: Schema.Struct({ ok: Schema.Boolean }),
execute: () => Effect.succeed({ ok: true }),
execute: () => Effect.succeed({ output: { ok: true } }),
})
const plugin = EffectPlugin.define({
id: "grouped-tools",
@ -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({
@ -329,7 +329,8 @@ describe("PluginV2", () => {
description: "Echo",
input: Schema.Struct({ text: Schema.String }),
output: Schema.Struct({ text: Schema.String }),
execute: ({ text }) => Effect.sync(() => executed.push({ text })).pipe(Effect.as({ text })),
execute: ({ text }) =>
Effect.sync(() => executed.push({ text })).pipe(Effect.as({ output: { text } })),
}),
{ codemode: false },
),
@ -348,9 +349,23 @@ 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)
yield* ctx.tool
.hook("execute.after", (event) =>
Effect.sync(() => {
if (event.status === "completed") event.content = [] as never
}),
)
.pipe(Effect.asVoid)
@ -359,8 +374,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 +386,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: [] })
}),
)
})

View file

@ -14,6 +14,7 @@ import { SessionPending } from "@opencode-ai/core/session/pending"
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { Plugin } from "@opencode-ai/plugin/v2"
import { Tool } from "@opencode-ai/plugin/v2/tool"
import type { SessionHooks } from "@opencode-ai/plugin/v2/effect/session"
import { Model } from "@opencode-ai/schema/model"
import { Provider } from "@opencode-ai/schema/provider"
@ -270,7 +271,7 @@ describe("fromPromise", () => {
}),
)
it.effect("constructs plain Promise tool declarations in the host", () =>
it.effect("constructs plain Promise tool definitions in the host", () =>
Effect.gen(function* () {
const plugins = yield* PluginV2.Service
const registry = yield* ToolRegistry.Service
@ -280,35 +281,41 @@ describe("fromPromise", () => {
id: "promise-tool",
setup: async (ctx) => {
await ctx.tool.transform((tools) => {
tools.add({
name: "hello",
options: { codemode: false },
description: "Hello",
input: Schema.Struct({ name: Schema.String }),
output: Schema.String,
execute: async ({ name }, context) => {
await context.progress({ structured: { phase: "greeting" } })
return `Hello, ${name}!`
},
})
tools.add(
"hello",
Tool.make({
description: "Hello",
input: Schema.Struct({ name: Schema.String }),
output: Schema.String,
execute: async ({ name }, context) => {
await context.progress({ phase: "greeting" })
return { output: `Hello, ${name}!` }
},
}),
{ codemode: false },
)
})
},
})
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([{ phase: "greeting" }])
}),
)
})

View file

@ -95,10 +95,10 @@ const references = Layer.mock(ReferenceInstructions.Service, { load: () => Effec
const mcp = Layer.mock(McpInstructions.Service, { load: () => Effect.succeed(Instructions.empty) })
const plugins = Layer.mock(PluginSupervisor.Service, { flush: Effect.void })
const tools = Layer.mock(ToolRegistry.Service, {
materialize: () =>
snapshot: () =>
Effect.succeed({
definitions: [ToolDefinition.make({ name: "lookup", description: "Lookup", inputSchema: { type: "object" } })],
settle: () => Effect.die(new Error("unused")),
execute: () => Effect.die(new Error("unused")),
}),
register: () => Effect.die(new Error("unused")),
registerBatch: () => Effect.die(new Error("unused")),

View file

@ -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)
}),

View file

@ -367,8 +367,7 @@ Recent work
state: SessionMessage.ToolStateRunning.make({
status: "running",
input: { path: "README.md" },
content: [],
structured: { type: "media", mime: "image/png" },
metadata: { type: "media", mime: "image/png" },
}),
time: { created },
}),
@ -388,7 +387,6 @@ Recent work
name: "hello.png",
},
],
structured: {},
}),
time: { created, completed: created },
}),
@ -403,7 +401,6 @@ Recent work
status: "completed",
input: { query: "Effect" },
content: [{ type: "text", text: "Found it" }],
structured: {},
}),
time: { created, completed: created },
}),
@ -416,8 +413,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 +468,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 +570,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 +585,6 @@ Recent work
status: "error",
input: { query: "Effect" },
error: { type: "unknown", message: "Step interrupted" },
content: [],
structured: {},
}),
time: { created, completed: created },
}),
@ -620,8 +611,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 +623,7 @@ Recent work
name: "web_search",
input: { query: "Effect" },
providerExecuted: true,
providerMetadata: undefined,
providerMetadata: { provider: { itemId: "call_failed" } },
},
{
type: "tool-result",
@ -641,18 +634,17 @@ Recent work
value: {
error: { type: "unknown", message: "Step interrupted" },
content: [],
structured: {},
},
},
providerExecuted: true,
cache: undefined,
metadata: undefined,
providerMetadata: undefined,
providerMetadata: { provider: { itemId: "result_failed" } },
},
])
})
test("drops provider-native continuation metadata after a model switch", () => {
test("drops model-scoped continuation metadata after a model switch but keeps hosted result payloads", () => {
const messages = toLLMMessages(
[
SessionMessage.Assistant.make({
@ -676,9 +668,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 +682,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,11 +707,13 @@ 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,
providerMetadata: undefined,
// Hosted result payloads are provider-format state and must survive a
// model switch within the same provider for replay to stay valid.
providerMetadata: { provider: { itemId: "hosted-old-model" } },
},
{
type: "tool-call",
@ -738,7 +729,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,

View file

@ -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,29 +91,41 @@ 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 () => {
test("interrupted progress metadata remains in the terminal failure snapshot", async () => {
const { published, publisher } = capture("anthropic", { interruptProgress: true })
await Effect.runPromise(publisher.publish(call))
const exit = await Effect.runPromiseExit(
publisher.progress(call.id, {
structured: { phase: "visible" },
content: [{ type: "text", text: "visible" }],
}),
publisher.progress(call.id, { phase: "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 +134,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 +207,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 +216,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 () => {

View file

@ -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: [] },
),
)
},
@ -63,24 +64,20 @@ const call = (name: string, id = `call-${name}`): ToolRegistry.ExecuteInput => (
call: { type: "tool-call", id, name, input: { text: name } },
})
const make = (permission?: string) => {
const tool = Tool.make({
const make = () =>
Tool.make({
description: "Echo text",
input: Schema.Struct({ text: Schema.String }),
output: Schema.Struct({ text: Schema.String }),
execute: ({ text }) => Effect.succeed({ text }),
toModelOutput: ({ output }) => [{ type: "text", text: output.text }],
execute: ({ text }) => Effect.succeed({ output: { text }, content: text }),
})
return permission ? Tool.withPermission(tool, permission) : tool
}
const constant = (text: string) =>
Tool.make({
description: "Return text",
input: Schema.Struct({ text: Schema.String }),
output: Schema.Struct({ text: Schema.String }),
execute: () => Effect.succeed({ text }),
toModelOutput: ({ output }) => [{ type: "text" as const, text: output.text }],
execute: () => Effect.succeed({ output: { text }, content: text }),
})
describe("ToolRegistry", () => {
@ -91,7 +88,21 @@ 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([])
}),
)
it.effect("rejects invalid and colliding normalized names", () =>
Effect.gen(function* () {
const service = yield* ToolRegistry.Service
const invalid = yield* service.register({ "123": make() }, { codemode: false }).pipe(Effect.flip)
expect(invalid.message).toBe("Invalid tool name: 123")
const collision = yield* service
.register({ "echo.tool": make(), echo_tool: make() }, { codemode: false })
.pipe(Effect.flip)
expect(collision.message).toBe("Duplicate normalized tool name: echo_tool")
expect((yield* service.snapshot()).definitions).toEqual([])
}),
)
@ -106,19 +117,15 @@ 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() }, { codemode: false })
yield* service.register({ edit: make(), write: make() }, { codemode: false, permission: "edit" })
const names = (permissions: PermissionV2.Ruleset) =>
toolDefinitions(service, permissions).pipe(Effect.map((definitions) => definitions.map((tool) => tool.name)))
@ -139,18 +146,15 @@ describe("ToolRegistry", () => {
}),
)
it.effect("keeps permission decoration isolated between registrations", () =>
it.effect("keeps permission options isolated between registrations", () =>
Effect.gen(function* () {
const service = yield* ToolRegistry.Service
const shared = make()
yield* service.register({ first: shared }, { codemode: false })
yield* service.register({ second: Tool.withPermission(shared, "edit") }, { codemode: false })
Tool.withPermission(shared, "question")
yield* service.register({ second: shared }, { codemode: false, permission: "edit" })
expect(
(yield* toolDefinitions(service, [{ action: "edit", resource: "*", effect: "deny" }])).map(
(definition) => definition.name,
),
(yield* toolDefinitions(service, [{ action: "edit", resource: "*", effect: "deny" }])).map((tool) => tool.name),
).toEqual(["first"])
}),
)
@ -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,79 +260,88 @@ 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")
}),
)
it.effect("passes complete invocation identity to the canonical handler", () =>
it.effect("passes complete call identity to tool execution", () =>
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({ output: { 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({
output: { text },
content: [
{ 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 },
],
}),
}),
},
{ 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.]" },
@ -331,44 +350,31 @@ describe("ToolRegistry", () => {
}),
)
it.effect("normalizes image progress content before it is published", () =>
it.effect("publishes progress metadata unchanged", () =>
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({ stage: "capture" }).pipe(Effect.as({ output: { text } })),
}),
},
{ codemode: false },
)
const updates: ToolRegistry.Progress[] = []
yield* settleTool(service, {
yield* executeTool(service, {
...call("progressive"),
progress: (update) =>
Effect.sync(() => {
updates.push(update)
}),
})
expect(updates).toEqual([
{
structured: { stage: "capture" },
content: [
{ type: "file", uri: "data:image/jpeg;base64,bm9ybWFsaXplZA==", mime: "image/jpeg", name: "frame.png" },
{ type: "text", text: "[1 image omitted: could not be resized below the image size limit.]" },
],
},
])
expect(updates).toEqual([{ stage: "capture" }])
}),
)
@ -382,23 +388,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({ output: { value }, content: String(value) })),
}),
},
{ codemode: false },
)
// Canonical content 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 +420,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({ output: { 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 +466,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 +482,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" }])
}),
)
@ -476,12 +499,13 @@ describe("ToolRegistry", () => {
description: "Echo text",
input: Schema.Struct({ text: Schema.String }),
output: Schema.Struct({ text: Schema.String }),
execute: ({ text }) => Effect.sync(() => executed.push(`old:${text}`)).pipe(Effect.as({ text })),
execute: ({ text }) =>
Effect.sync(() => executed.push(`old:${text}`)).pipe(Effect.as({ output: { text } })),
}),
})
.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)
@ -490,11 +514,11 @@ describe("ToolRegistry", () => {
description: "Echo text",
input: Schema.Struct({ text: Schema.String }),
output: Schema.Struct({ text: Schema.String }),
execute: ({ text }) => Effect.sync(() => executed.push(`new:${text}`)).pipe(Effect.as({ text })),
execute: ({ text }) => Effect.sync(() => executed.push(`new:${text}`)).pipe(Effect.as({ output: { text } })),
}),
})
const settlement = yield* materialized.settle({
const execution = yield* toolSet.execute({
...call("execute"),
call: {
type: "tool-call",
@ -504,7 +528,7 @@ describe("ToolRegistry", () => {
},
})
expect(settlement.result).toMatchObject({ type: "text" })
expect(execution).toMatchObject({ status: "completed", content: [{ type: "text" }] })
expect(executed).toEqual(["old:request"])
}),
)

View file

@ -52,6 +52,7 @@ import { AgentV2 } from "@opencode-ai/core/agent"
import { Config } from "@opencode-ai/core/config"
import { ConfigCompaction } from "@opencode-ai/core/config/compaction"
import { Tool } from "@opencode-ai/core/tool/tool"
import { ToolHooks } from "@opencode-ai/core/tool/hooks"
import {
InstructionStateTable,
SessionPendingTable,
@ -238,43 +239,45 @@ 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 }),
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 { output: { text }, content: 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({ output: {} }),
}),
},
{ 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(
@ -422,6 +434,7 @@ const it = testEffect(
Catalog.node,
ToolRegistry.node,
ToolRegistry.toolsNode,
ToolHooks.node,
PluginHooks.node,
echoNode,
SessionRunnerModel.node,
@ -449,7 +462,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 +599,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 +840,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 +868,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({ phase: "reading" })
return { output: { 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 +906,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 +915,7 @@ describe("SessionRunnerLLM", () => {
{
type: "tool",
id: "call-location",
state: { status: "completed", structured: { answer: "HELLO" } },
state: { status: "completed", content: [{ type: "text", text: '{"answer":"HELLO"}' }] },
},
],
},
@ -893,25 +923,29 @@ describe("SessionRunnerLLM", () => {
}),
)
it.effect("persists the latest partial snapshot when a tool fails", () =>
it.effect("prefers failure outcome metadata over retained progress", () =>
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 })
const hooks = yield* ToolHooks.Service
yield* hooks.hook.after((event) => {
if (event.status === "error") event.metadata = { phase: "failed" }
})
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({ phase: "running" })
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,8 +961,7 @@ describe("SessionRunnerLLM", () => {
id: "call-failing-progress",
state: {
status: "error",
structured: { phase: "running" },
content: [{ type: "text", text: "before failure" }],
metadata: { phase: "failed" },
error: { message: "failed after progress" },
},
},
@ -946,14 +979,20 @@ 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({ output: { value: "advertised" } }),
),
}),
},
{ codemode: false },
)
.pipe(Scope.provide(scope))
yield* admit(session, "Use the reloaded tool")
responses = [
@ -971,14 +1010,20 @@ 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({ output: { value: "replacement" } }),
),
}),
},
{ codemode: false },
)
yield* Deferred.succeed(streamGate, undefined)
yield* Fiber.join(run)
@ -991,7 +1036,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 +2422,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 +2461,6 @@ describe("SessionRunnerLLM", () => {
state: {
status: "completed",
input: { text: "hello" },
structured: { text: "hello" },
content: [{ type: "text", text: "hello" }],
},
},
@ -2429,7 +2472,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 +2624,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 +2711,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 +2721,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 +2737,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 +2747,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 +3440,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 +3450,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 +3488,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 +3528,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 +3585,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 +3639,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 +3703,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 +3715,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 +3759,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 +3860,8 @@ describe("SessionRunnerLLM", () => {
expect(requests).toHaveLength(2)
expect(requests[0]?.toolChoice).toBeUndefined()
expect(requests[1]?.toolChoice).toMatchObject({ type: "none" })
expect(requests[1]?.tools).toEqual([])
// Protocols with native "none" keep these definitions for 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 +4006,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 +4199,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 +4251,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 +4346,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 +4575,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 +4607,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 +4639,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 +4663,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 +4700,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 +4738,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 +4775,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 +4802,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(

View file

@ -84,30 +84,29 @@ describe("Tool.Progress", () => {
yield* start("call-success")
expect((yield* readAssistant).content[0]).toMatchObject({
state: { status: "running", structured: {}, content: [] },
state: { status: "running", metadata: {} },
})
const progress = yield* service.publish(SessionEvent.Tool.Progress, {
sessionID,
assistantMessageID,
callID: "call-success",
structured: { phase: "checkpoint" },
content: content("saved"),
metadata: { phase: "checkpoint" },
})
expect((yield* readAssistant).content[0]).toMatchObject({
state: { status: "running", structured: {}, content: [] },
state: { status: "running", metadata: {} },
})
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,8 +114,7 @@ describe("Tool.Progress", () => {
sessionID,
assistantMessageID,
callID: "call-failed",
structured: { phase: "checkpoint" },
content: content("before failure"),
metadata: { phase: "checkpoint" },
})
const failed = yield* service.publish(SessionEvent.Tool.Failed, {
sessionID,
@ -130,7 +128,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 +145,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))
}),
)
})

View file

@ -18,7 +18,7 @@ import { location } from "./fixture/location"
import { tmpdir } from "./fixture/tmpdir"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { testEffect } from "./lib/effect"
import { toolIdentity, executeTool, registerToolPlugin, settleTool, toolDefinitions } from "./lib/tool"
import { toolIdentity, executeTool, registerToolPlugin, toolDefinitions } from "./lib/tool"
const editToolNode = makeLocationNode({
name: "test/edit-tool-plugin",
@ -141,15 +141,23 @@ 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.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```",
},
])
// Compact UI metadata carries the file diffs the TUI renders.
expect(settled.metadata).toMatchObject({
files: [{ file: "hello.txt", status: "modified", additions: 1, deletions: 1 }],
})
expect(settled.output?.structured).toEqual({
expect(settled.output).toEqual({
replacements: 1,
files: [
{
@ -187,7 +195,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 +225,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 +255,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 +284,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 +298,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 +333,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 +363,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 +417,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 +470,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([])

View file

@ -6,6 +6,69 @@ import { Session } from "@opencode-ai/schema/session"
import { SessionMessage } from "@opencode-ai/schema/session-message"
import { Effect, Schema } from "effect"
const context = {
sessionID: Session.ID.make("ses_execute"),
agent: Agent.ID.make("build"),
messageID: SessionMessage.ID.make("msg_execute"),
callID: "call_execute",
progress: () => Effect.void,
}
test("canonical execution distinguishes declared, model-only, and raw schema outputs", async () => {
const declared = Tool.make({
description: "Declared",
input: Schema.Struct({ value: Schema.String }),
output: Schema.Struct({ value: Schema.String }),
execute: ({ value }) => Effect.succeed({ output: { value } }),
})
const modelOnly = Tool.make({
description: "Model only",
input: Schema.Struct({}),
execute: () => Effect.succeed({ content: "visible only", metadata: { kind: "model" } }),
})
const raw = Tool.make({
description: "Raw",
input: {},
output: {},
execute: (input) => Effect.succeed({ output: input, content: "raw" }),
})
expect(await Effect.runPromise(Tool.execute(declared, { value: "encoded" }, context))).toEqual({
output: { value: "encoded" },
content: [{ type: "text", text: '{"value":"encoded"}' }],
})
expect(await Effect.runPromise(Tool.execute(modelOnly, {}, context))).toEqual({
content: [{ type: "text", text: "visible only" }],
metadata: { kind: "model" },
})
expect(await Effect.runPromise(Tool.execute(raw, { unchecked: true }, context))).toEqual({
output: { unchecked: true },
content: [{ type: "text", text: "raw" }],
})
})
test("declared outputs cannot bypass validation and raw outputs stay JSON-compatible", async () => {
const missing: Tool.Any = {
description: "Missing output",
input: Schema.Struct({}),
output: Schema.String,
execute: () => Effect.succeed({ content: "not an output" }),
}
const invalid: Tool.Any = {
description: "Invalid raw output",
input: {},
output: {},
execute: () => Effect.succeed({ output: 1n, content: "not JSON" }),
}
expect((await Effect.runPromiseExit(Tool.execute(missing, {}, context))).toString()).toContain(
"Tool did not return its declared output",
)
expect((await Effect.runPromiseExit(Tool.execute(invalid, {}, context))).toString()).toContain(
"Tool returned a non-JSON value",
)
})
test("execute preserves successful results with visible unhandled rejections", async () => {
const child = Tool.make({
description: "Always fail",
@ -13,27 +76,10 @@ test("execute preserves successful results with visible unhandled rejections", a
output: Schema.String,
execute: () => Effect.fail(new Tool.Failure({ message: "Lookup refused" })),
})
const execute = ExecuteTool.create(new Map([["fail", { tool: child, name: "fail" }]]))
const result = await Effect.runPromise(
Tool.settle(
execute,
{
type: "tool-call",
id: "call_execute",
name: "execute",
input: { code: `tools.fail({}); return "done"` },
},
{
sessionID: Session.ID.make("ses_execute"),
agent: Agent.ID.make("build"),
messageID: SessionMessage.ID.make("msg_execute"),
callID: "call_execute",
progress: () => Effect.void,
},
),
)
const execute = ExecuteTool.create(new Map([["fail", { tool: child, name: "fail", permission: "fail" }]]))
const result = await Effect.runPromise(Tool.execute(execute, { code: `tools.fail({}); return "done"` }, context))
expect(result.structured).toEqual({ toolCalls: [{ tool: "fail", status: "error" }] })
expect(result.metadata).toEqual({ toolCalls: [{ tool: "fail", status: "error" }] })
expect(result.content).toEqual([
{
type: "text",
@ -52,40 +98,32 @@ test("execute supports callable namespace tools", async () => {
description: "Administer Slack",
input: Schema.Struct({}),
output: Schema.String,
execute: () => Effect.succeed("admin"),
execute: () => Effect.succeed({ output: "admin" }),
})
const child = Tool.make({
description: "Create a Slack resource",
input: Schema.Struct({}),
output: Schema.String,
execute: () => Effect.succeed("created"),
execute: () => Effect.succeed({ output: "created" }),
})
const execute = ExecuteTool.create(
new Map([
["slack_admin", { tool: callable, name: "admin", namespace: "slack" }],
["slack_admin_create", { tool: child, name: "create", namespace: "slack.admin" }],
["slack_admin", { tool: callable, name: "admin", namespace: "slack", permission: "slack_admin" }],
[
"slack_admin_create",
{ tool: child, name: "create", namespace: "slack.admin", permission: "slack_admin_create" },
],
]),
)
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({})]" },
},
{
sessionID: Session.ID.make("ses_execute"),
agent: Agent.ID.make("build"),
messageID: SessionMessage.ID.make("msg_execute"),
callID: "call_execute",
progress: () => Effect.void,
},
{ code: "return [await tools.slack.admin({}), await tools.slack.admin.create({})]" },
context,
),
)
expect(result.structured).toEqual({
expect(result.metadata).toEqual({
toolCalls: [
{ tool: "slack.admin", status: "completed" },
{ tool: "slack.admin.create", status: "completed" },

View file

@ -53,52 +53,31 @@ describe("ToolOutputStore", () => {
const result = yield* store.bound({
sessionID,
callID: "call-aggregate",
output: {
structured: { kind: "report" },
content: [
{ type: "text", text: first },
{ type: "text", text: second },
],
},
content: [
{ type: "text", text: first },
{ type: "text", text: second },
],
})
expect(result.output.structured).toEqual({ kind: "report" })
expect(result.outputPaths).toHaveLength(1)
expect(yield* fs.readFileString(result.outputPaths[0])).toBe(first + second)
if (result.output.content[0]?.type !== "text") throw new Error("expected text preview")
expect(Buffer.byteLength(result.output.content[0].text)).toBeLessThanOrEqual(ToolOutputStore.MAX_BYTES)
if (result.content[0]?.type !== "text") throw new Error("expected text preview")
expect(Buffer.byteLength(result.content[0].text)).toBeLessThanOrEqual(ToolOutputStore.MAX_BYTES)
}),
),
)
it.live("uses bounded text for oversized structured-only output", () =>
withStore(({ store, fs }) =>
Effect.gen(function* () {
const structured = { text: "x".repeat(ToolOutputStore.MAX_BYTES) }
const result = yield* store.bound({ sessionID, callID: "call-json", output: { structured, content: [] } })
expect(result.output.structured).toEqual(structured)
expect(result.outputPaths).toHaveLength(1)
expect(JSON.parse(yield* fs.readFileString(result.outputPaths[0]))).toEqual(structured)
expect(result.output.content).toHaveLength(1)
}),
),
)
it.live("preserves native media and structured metadata without applying a settlement media limit", () =>
it.live("preserves native media without applying an execution media limit", () =>
withStore(({ store }) =>
Effect.gen(function* () {
const data = "a".repeat(6 * 1024 * 1024)
const result = yield* store.bound({
sessionID,
callID: "call-file",
output: {
structured: { caption: "pixel" },
content: [{ type: "file", uri: `data:image/png;base64,${data}`, mime: "image/png", name: "pixel.png" }],
},
content: [{ type: "file", uri: `data:image/png;base64,${data}`, mime: "image/png", name: "pixel.png" }],
})
expect(result.outputPaths).toEqual([])
expect(result.output.structured).toEqual({ caption: "pixel" })
expect(result.output.content).toHaveLength(1)
expect(result.output.content[0]).toEqual({
expect(result.content).toHaveLength(1)
expect(result.content[0]).toEqual({
type: "file",
uri: `data:image/png;base64,${data}`,
mime: "image/png",
@ -108,7 +87,7 @@ describe("ToolOutputStore", () => {
),
)
it.live("preserves structured metadata and native media when bounding text", () =>
it.live("preserves native media when bounding text", () =>
withStore(({ store, fs }) =>
Effect.gen(function* () {
const text = "x".repeat(ToolOutputStore.MAX_BYTES + 1)
@ -121,30 +100,29 @@ describe("ToolOutputStore", () => {
const result = yield* store.bound({
sessionID,
callID: "call-text-and-media",
output: { structured: { caption: "pixel" }, content: [{ type: "text", text }, media] },
content: [{ type: "text", text }, media],
})
expect(result.output.structured).toEqual({ caption: "pixel" })
expect(result.output.content[1]).toEqual(media)
expect(result.content[1]).toEqual(media)
expect(yield* fs.readFileString(result.outputPaths[0])).toBe(text)
}),
),
)
it.live("does not double-count structured data duplicated in projected text", () =>
it.live("returns content within the limits unchanged", () =>
withStore(({ store }) =>
Effect.gen(function* () {
const text = "x".repeat(30_000)
const output = { structured: { output: text }, content: [{ type: "text" as const, text }] }
expect(yield* store.bound({ sessionID, callID: "call-duplicated", output })).toEqual({
output,
const content = [{ type: "text" as const, text }]
expect(yield* store.bound({ sessionID, callID: "call-duplicated", content })).toEqual({
content,
outputPaths: [],
})
}),
),
)
it.live("fails oversized settlement when complete retention cannot be written", () =>
it.live("fails oversized execution when complete retention cannot be written", () =>
withStore(({ root, store, fs }) =>
Effect.gen(function* () {
yield* fs.writeFileString(path.join(root, "tool-output"), "not a directory")
@ -152,7 +130,7 @@ describe("ToolOutputStore", () => {
.bound({
sessionID,
callID: "call-lossy",
output: { structured: {}, content: [{ type: "text", text: "x".repeat(ToolOutputStore.MAX_BYTES + 1) }] },
content: [{ type: "text", text: "x".repeat(ToolOutputStore.MAX_BYTES + 1) }],
})
.pipe(Effect.exit)
expect(Exit.isFailure(exit)).toBe(true)
@ -162,18 +140,6 @@ describe("ToolOutputStore", () => {
),
)
it.live("does not encode ignored structured metadata when projected content exists", () =>
withStore(({ store }) =>
Effect.gen(function* () {
const output = { structured: { value: 1n }, content: [{ type: "text" as const, text: "readable text" }] }
expect(yield* store.bound({ sessionID, callID: "call-unencodable", output })).toEqual({
output,
outputPaths: [],
})
}),
),
)
it.live("preserves interruption while retaining complete output", () =>
Effect.gen(function* () {
const root = yield* Effect.promise(() => tmpdir())
@ -198,7 +164,7 @@ describe("ToolOutputStore", () => {
.bound({
sessionID,
callID: "call-interrupted",
output: { structured: {}, content: [{ type: "text", text: "x".repeat(ToolOutputStore.MAX_BYTES + 1) }] },
content: [{ type: "text", text: "x".repeat(ToolOutputStore.MAX_BYTES + 1) }],
})
.pipe(Effect.forkChild)
yield* Fiber.interrupt(fiber)
@ -217,7 +183,7 @@ describe("ToolOutputStore", () => {
const result = yield* store.bound({
sessionID,
callID: "call-config",
output: { structured: {}, content: [{ type: "text", text: "one\ntwo\nthree" }] },
content: [{ type: "text", text: "one\ntwo\nthree" }],
})
expect(result.outputPaths).toHaveLength(1)
}),

View file

@ -16,7 +16,7 @@ import { location } from "./fixture/location"
import { tmpdir } from "./fixture/tmpdir"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { testEffect } from "./lib/effect"
import { toolIdentity, executeTool, registerToolPlugin, settleTool, toolDefinitions } from "./lib/tool"
import { toolIdentity, executeTool, registerToolPlugin, toolDefinitions } from "./lib/tool"
const patchToolNode = makeLocationNode({
name: "test/patch-tool-plugin",
@ -96,29 +96,19 @@ const withTool = <A, E, R>(
const activeLocation = Layer.succeed(
Location.Service,
Location.Service.of(
location(
{ directory: AbsolutePath.make(directory) },
{ projectDirectory: AbsolutePath.make(projectDirectory) },
),
location({ directory: AbsolutePath.make(directory) }, { projectDirectory: AbsolutePath.make(projectDirectory) }),
),
)
return Effect.gen(function* () {
return yield* body(yield* ToolRegistry.Service)
}).pipe(
Effect.provide(
AppNodeBuilder.build(
LayerNode.group([
ToolRegistry.node,
ToolRegistry.toolsNode,
patchToolNode,
]),
[
[FSUtil.node, filesystem],
[Location.node, activeLocation],
[PermissionV2.node, permission],
[ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig],
],
),
AppNodeBuilder.build(LayerNode.group([ToolRegistry.node, ToolRegistry.toolsNode, patchToolNode]), [
[FSUtil.node, filesystem],
[Location.node, activeLocation],
[PermissionV2.node, permission],
[ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig],
]),
),
)
}
@ -162,18 +152,23 @@ describe("PatchTool", () => {
withTool(tmp.path, (registry) =>
Effect.gen(function* () {
expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["patch"])
const settled = yield* settleTool(
const settled = yield* executeTool(
registry,
call(
"*** Begin Patch\n*** Add File: nested/new.txt\n+created\n*** Update File: update.txt\n@@\n-before\n+after\n*** Delete File: remove.txt\n*** End Patch",
),
)
expect(settled.result).toEqual({
type: "text",
value: "Success. Updated the following files:\nA nested/new.txt\nM update.txt\nD remove.txt",
})
if (process.platform === "win32") expect(settled.result.value).not.toContain("\\")
expect(settled.output?.structured).toMatchObject({
expect(settled.status).toBe("completed")
if (settled.status !== "completed") return
expect(settled.content).toEqual([
{
type: "text",
text: "Success. Updated the following files:\nA nested/new.txt\nM update.txt\nD remove.txt",
},
])
const modelText = settled.content[0]?.type === "text" ? settled.content[0].text : ""
if (process.platform === "win32") expect(modelText).not.toContain("\\")
expect(settled.output).toMatchObject({
applied: [
{ type: "add", resource: "nested/new.txt" },
{ type: "update", resource: "update.txt" },
@ -248,9 +243,11 @@ describe("PatchTool", () => {
"*** Begin Patch\n*** Add File: created.txt\n+created\n*** Update File: old.txt\n*** Move to: moved.txt\n@@\n-before\n+after\n*** End Patch",
),
),
).toEqual({
type: "text",
value: "Success. Updated the following files:\nA created.txt\nM moved.txt",
).toMatchObject({
status: "completed",
content: [
{ type: "text", text: "Success. Updated the following files:\nA created.txt\nM moved.txt" },
],
})
expect(yield* exists(source)).toBe(false)
expect(yield* Effect.promise(() => fs.readFile(path.join(tmp.path, "moved.txt"), "utf8"))).toBe(
@ -278,7 +275,9 @@ describe("PatchTool", () => {
return Effect.promise(() =>
Promise.all([
fs.writeFile(source, "before\n"),
fs.mkdir(path.dirname(destination), { recursive: true }).then(() => fs.writeFile(destination, "existing\n")),
fs
.mkdir(path.dirname(destination), { recursive: true })
.then(() => fs.writeFile(destination, "existing\n")),
]),
).pipe(
Effect.andThen(
@ -291,7 +290,7 @@ describe("PatchTool", () => {
"*** Begin Patch\n*** Update File: old.txt\n*** Move to: nested/moved.txt\n@@\n-before\n+after\n*** End Patch",
),
),
).toMatchObject({ type: "text" })
).toMatchObject({ status: "completed" })
expect(yield* exists(source)).toBe(false)
expect(yield* Effect.promise(() => fs.readFile(destination, "utf8"))).toBe("after\n")
}),
@ -325,19 +324,21 @@ describe("PatchTool", () => {
),
)
it.live("includes move file info in structured output", () =>
it.live("includes move file info in output and metadata", () =>
withTempTool((directory, registry) =>
Effect.gen(function* () {
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]()),
),
)
})

View file

@ -12,7 +12,7 @@ import { Image } from "@opencode-ai/core/image"
import { testEffect } from "./lib/effect"
import { imagePassthrough } from "./lib/image"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { toolIdentity, executeTool, registerToolPlugin, settleTool, toolDefinitions } from "./lib/tool"
import { toolIdentity, executeTool, registerToolPlugin, toolDefinitions } from "./lib/tool"
const sessionID = SessionV2.ID.make("ses_question_tool_test")
const assertions: PermissionV2.AssertInput[] = []
@ -99,13 +99,13 @@ describe("QuestionTool", () => {
expect(yield* toolDefinitions(registry, [{ action: "question", resource: "*", effect: "deny" }])).toEqual([])
expect(
yield* settleTool(registry, {
yield* executeTool(registry, {
sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-question-denied", name: "question", input: questionInput },
}),
).toEqual({
result: { type: "error", value: "Permission denied: question" },
status: "error",
error: {
type: "permission.rejected",
message: "Permission denied: question",
@ -144,26 +144,21 @@ 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.',
},
],
metadata: { answers: [["Build"], ["Dev"], []] },
})
expect(assertions).toMatchObject([{ sessionID, action: "question", resources: ["*"] }])
expect(capturedInput()).toEqual({

View file

@ -22,7 +22,7 @@ import { ReadToolFileSystem } from "@opencode-ai/core/tool/read-filesystem"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { SessionInstructions } from "@opencode-ai/core/session/instructions"
import { testEffect } from "./lib/effect"
import { toolIdentity, executeTool, registerToolPlugin, settleTool, toolDefinitions } from "./lib/tool"
import { toolIdentity, executeTool, registerToolPlugin, toolDefinitions } from "./lib/tool"
const readToolNode = makeLocationNode({
name: "test/read-tool-plugin",
@ -199,21 +199,19 @@ describe("ReadTool", () => {
expect(yield* toolDefinitions(registry)).toMatchObject([{ name: "read" }])
expect(yield* toolDefinitions(registry, [{ action: "read", resource: "*", effect: "deny" }])).toEqual([])
expect(
yield* executeTool(registry, {
sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-read", name: "read", input: { path: "README.md" } },
}),
).toEqual({
type: "json",
value: {
uri: "file:///README.md",
name: "README.md",
content: "hello",
encoding: "utf8",
mime: "text/plain",
},
const execution = yield* executeTool(registry, {
sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-read", name: "read", input: { path: "README.md" } },
})
expect(execution.status).toBe("completed")
if (execution.status !== "completed") return
expect(execution.output).toEqual({
uri: "file:///README.md",
name: "README.md",
content: "hello",
encoding: "utf8",
mime: "text/plain",
})
expect(assertions).toMatchObject([{ sessionID, action: "read", resources: ["README.md"], save: ["*"] }])
expect(readCalls).toEqual([
@ -236,7 +234,7 @@ describe("ReadTool", () => {
...toolIdentity,
call: { type: "tool-call", id: "call-external-read", name: "read", input: { path: external } },
}),
).toMatchObject({ type: "json" })
).toMatchObject({ status: "completed" })
expect(assertions).toMatchObject([
{
sessionID,
@ -261,19 +259,17 @@ describe("ReadTool", () => {
}
const registry = yield* ToolRegistry.Service
expect(
yield* executeTool(registry, {
sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-image", name: "read", input: { path: "pixel.png" } },
}),
).toEqual({
type: "content",
value: [
{ type: "text", text: "Image read successfully" },
{ type: "file", uri: `data:image/png;base64,${png}`, mime: "image/png", name: "pixel.png" },
],
const execution = yield* executeTool(registry, {
sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-image", name: "read", input: { path: "pixel.png" } },
})
expect(execution.status).toBe("completed")
if (execution.status !== "completed") return
expect(execution.content).toEqual([
{ type: "text", text: "Image read successfully" },
{ type: "file", uri: `data:image/png;base64,${png}`, mime: "image/png", name: "pixel.png" },
])
expect(readCalls).toEqual([
{
input: AbsolutePath.make(path.join(process.cwd(), "pixel.png")),
@ -281,21 +277,17 @@ describe("ReadTool", () => {
},
])
const settled = yield* settleTool(registry, {
const settled = yield* executeTool(registry, {
sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-image-settle", name: "read", input: { path: "pixel.png" } },
})
expect(settled.output?.structured).toMatchObject({
uri: "file:///pixel.png",
name: "pixel.png",
mime: "image/png",
encoding: "base64",
// Image base64 is carried by the content file item only; structured is slimmed
// so the original bytes are never persisted twice.
content: "",
})
expect(settled.output?.content).toMatchObject([
expect(settled.status).toBe("completed")
if (settled.status !== "completed") return
// Image base64 is carried by the content file item only; read produces no
// metadata, so the original bytes are never persisted twice.
expect(settled.metadata).toBeUndefined()
expect(settled.content).toMatchObject([
{ type: "text", text: "Image read successfully" },
{ type: "file", mime: "image/png", uri: `data:image/png;base64,${png}` },
])
@ -319,26 +311,25 @@ describe("ReadTool", () => {
}
const registry = yield* ToolRegistry.Service
const settled = yield* settleTool(registry, {
const settled = yield* executeTool(registry, {
sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-large-image", name: "read", input: { path: "large.png" } },
})
expect(settled.outputPaths).toBeUndefined()
expect(settled.output?.structured).toMatchObject({
expect(settled.status).toBe("completed")
if (settled.status !== "completed") return
expect(settled.output).toMatchObject({
uri: "file:///large.png",
name: "large.png",
mime: "image/png",
encoding: "base64",
})
expect(settled.result).toEqual({
type: "content",
value: [
{ type: "text", text: "Image read successfully" },
{ type: "file", uri: `data:image/png;base64,${png}`, mime: "image/png", name: "large.png" },
],
})
expect(settled.content).toEqual([
{ type: "text", text: "Image read successfully" },
{ type: "file", uri: `data:image/png;base64,${png}`, mime: "image/png", name: "large.png" },
])
}),
)
@ -361,13 +352,13 @@ 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" }],
})
}),
)
it.effect("drops undecodable image data at settlement", () =>
it.effect("drops undecodable image data from the outcome", () =>
Effect.gen(function* () {
readResult = {
uri: "file:///truncated.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.]" },
],
@ -394,7 +385,7 @@ describe("ReadTool", () => {
}),
)
it.effect("drops oversized images at settlement when resizing is disabled", () =>
it.effect("drops oversized images from the outcome when resizing is disabled", () =>
Effect.gen(function* () {
const photon = yield* Effect.promise(() => import("@silvia-odwyer/photon-node"))
const source = new photon.PhotonImage(new Uint8Array(Array.from({ length: 16 * 4 }, () => 255)), 16, 1)
@ -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" } })
}),
)
})

View file

@ -19,7 +19,7 @@ import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
import { location } from "./fixture/location"
import { tmpdir } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect"
import { executeTool, registerToolPlugin, settleTool, toolIdentity } from "./lib/tool"
import { executeTool, registerToolPlugin, toolIdentity } from "./lib/tool"
const globToolNode = makeLocationNode({
name: "test/glob-tool-plugin",
@ -83,15 +83,17 @@ describe("search tools", () => {
)
yield* withTools(tmp.path, (registry) =>
Effect.gen(function* () {
const glob = yield* settleTool(registry, call("glob", { pattern: "*" }))
const grep = yield* settleTool(registry, call("grep", { pattern: "needle" }))
const glob = yield* executeTool(registry, call("glob", { pattern: "*" }))
const grep = yield* executeTool(registry, call("grep", { pattern: "needle" }))
expect(glob.output?.structured).toEqual({ count: FileSystem.DEFAULT_SEARCH_LIMIT })
expect(grep.output?.structured).toEqual({ matches: FileSystem.DEFAULT_SEARCH_LIMIT })
expect(glob.output?.content).toEqual([{ type: "text", text: String(glob.result.value) }])
expect(grep.output?.content).toEqual([{ type: "text", text: String(grep.result.value) }])
expect(String(glob.result.value).split("\n")).toHaveLength(FileSystem.DEFAULT_SEARCH_LIMIT)
expect(grep.result.value).toStartWith(`Found ${FileSystem.DEFAULT_SEARCH_LIMIT} matches\n`)
expect(glob.metadata).toEqual({ count: FileSystem.DEFAULT_SEARCH_LIMIT })
expect(grep.metadata).toEqual({ matches: FileSystem.DEFAULT_SEARCH_LIMIT })
expect(glob.content).toHaveLength(1)
expect(grep.content).toHaveLength(1)
const globText = glob.content?.[0]?.type === "text" ? glob.content[0].text : ""
const grepText = grep.content?.[0]?.type === "text" ? grep.content[0].text : ""
expect(globText.split("\n")).toHaveLength(FileSystem.DEFAULT_SEARCH_LIMIT)
expect(grepText).toStartWith(`Found ${FileSystem.DEFAULT_SEARCH_LIMIT} matches\n`)
}),
)
}),
@ -110,7 +112,10 @@ describe("search tools", () => {
registry,
call(name, { path: "missing", pattern: name === "glob" ? "*" : "needle" }),
)
expect(result).toEqual({ type: "error", value: "Search path does not exist: missing" })
expect(result).toEqual({
status: "error",
error: { type: "tool.execution", message: "Search path does not exist: missing" },
})
}),
),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),

View file

@ -33,7 +33,7 @@ import { ToolRegistry } from "@opencode-ai/core/tool/registry"
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
import { tmpdir } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect"
import { toolIdentity, executeTool, settleTool, toolDefinitions, waitForTool } from "./lib/tool"
import { toolIdentity, executeTool, toolDefinitions, waitForTool } from "./lib/tool"
const sessionID = SessionV2.ID.make("ses_shell_tool_test")
const sessionModel = ModelV2.Ref.make({ id: ModelV2.ID.make("test"), providerID: ProviderV2.ID.make("test") })
@ -204,17 +204,19 @@ describe("ShellTool", () => {
const definitions = yield* toolDefinitions(registry)
const shell = definitions.find((tool) => tool.name === "shell")
expect(shell).toBeDefined()
expect(shell?.outputSchema).not.toHaveProperty("properties.output")
// Code Mode receives the declared output schema, including the command output text.
expect(shell?.outputSchema).toHaveProperty("properties.output")
expect(
(yield* toolDefinitions(registry, [{ action: "shell", resource: "*", effect: "deny" }])).map(
(tool) => tool.name,
),
).not.toContain("shell")
const settled = yield* settleTool(registry, call({ command: helloCommand }))
expect(settled.output?.structured).toMatchObject({ exit: 0, truncated: false })
expect(settled.output?.content[0]).toEqual({ type: "text", text: "hello" })
expect(settled.output?.content[1]).toMatchObject({
const settled = yield* executeTool(registry, call({ command: helloCommand }))
expect(settled.status).toBe("completed")
expect(settled.metadata).toMatchObject({ exit: 0, truncated: false })
expect(settled.content?.[0]).toEqual({ type: "text", text: "hello" })
expect(settled.content?.[1]).toMatchObject({
type: "text",
text: expect.stringContaining("Command exited with code 0."),
})
@ -233,11 +235,11 @@ describe("ShellTool", () => {
reset()
return Effect.promise(() => fs.mkdir(path.join(tmp.path, "src"))).pipe(
Effect.andThen(
withSession(tmp.path, (registry) => settleTool(registry, call({ command: cwdCommand, workdir: "src" }))),
withSession(tmp.path, (registry) => executeTool(registry, call({ command: cwdCommand, workdir: "src" }))),
),
Effect.andThen((settled) =>
Effect.sync(() =>
expect(settled.output?.content[0]).toMatchObject({
expect(settled.content?.[0]).toMatchObject({
type: "text",
text: expect.stringContaining(realpathSync(path.join(tmp.path, "src"))),
}),
@ -256,13 +258,13 @@ describe("ShellTool", () => {
reset()
return withSession(tmp.path, (registry) =>
Effect.gen(function* () {
const stderr = yield* settleTool(registry, call({ command: stderrCommand }, "call-stderr"))
expect(stderr.output?.structured).toMatchObject({ exit: 0, truncated: false })
expect(stderr.output?.content[0]).toEqual({ type: "text", text: "stderr only" })
const stderr = yield* executeTool(registry, call({ command: stderrCommand }, "call-stderr"))
expect(stderr.metadata).toMatchObject({ exit: 0, truncated: false })
expect(stderr.content?.[0]).toEqual({ type: "text", text: "stderr only" })
const mixed = yield* settleTool(registry, call({ command: mixedOutputCommand }, "call-mixed"))
expect(mixed.output?.structured).toMatchObject({ exit: 0, truncated: false })
const output = mixed.output?.content[0]?.type === "text" ? mixed.output.content[0].text : ""
const mixed = yield* executeTool(registry, call({ command: mixedOutputCommand }, "call-mixed"))
expect(mixed.metadata).toMatchObject({ exit: 0, truncated: false })
const output = mixed.content?.[0]?.type === "text" ? mixed.content[0].text : ""
expect(output).toContain("stdout")
expect(output).toContain("stderr")
}),
@ -352,12 +354,12 @@ describe("ShellTool", () => {
reset()
denyAction = "external_directory"
const target = path.join(outside.path, "secret.txt")
return withSession(active.path, (registry) => settleTool(registry, call({ command: `cat ${target}` }))).pipe(
return withSession(active.path, (registry) => executeTool(registry, call({ command: `cat ${target}` }))).pipe(
Effect.andThen((settled) =>
Effect.sync(() => {
expect(assertions.map((item) => item.action)).toEqual(["shell"])
expect(settled.output?.structured).not.toHaveProperty("warnings")
expect(settled.output?.content[1]).toMatchObject({
expect(settled.metadata).not.toHaveProperty("warnings")
expect(settled.content?.[1]).toMatchObject({
type: "text",
text: expect.stringContaining("Warnings:"),
})
@ -378,13 +380,14 @@ describe("ShellTool", () => {
(tmp) => {
reset()
return withSession(tmp.path, (registry) =>
settleTool(registry, call({ command: bodyExitCommand }, "call-nonzero")),
executeTool(registry, call({ command: bodyExitCommand }, "call-nonzero")),
).pipe(
Effect.andThen((settled) =>
Effect.sync(() => {
expect(settled.output?.structured).toMatchObject({ exit: 7, truncated: false })
expect(settled.output?.content[0]).toEqual({ type: "text", text: "body" })
expect(settled.output?.content[1]).toMatchObject({
expect(settled.status).toBe("completed")
expect(settled.metadata).toMatchObject({ exit: 7, truncated: false })
expect(settled.content?.[0]).toEqual({ type: "text", text: "body" })
expect(settled.content?.[1]).toMatchObject({
type: "text",
text: expect.stringContaining("Command exited with code 7"),
})
@ -403,12 +406,12 @@ describe("ShellTool", () => {
reset()
const bytes = ShellTool.MAX_CAPTURE_BYTES + 1024
return withSession(tmp.path, (registry) =>
settleTool(registry, call({ command: overflowCommand(bytes) }, "call-overflow")),
executeTool(registry, call({ command: overflowCommand(bytes) }, "call-overflow")),
).pipe(
Effect.andThen((settled) =>
Effect.sync(() => {
expect(settled.output?.structured).toMatchObject({ exit: 0, truncated: true })
expect(settled.output?.content[0]).toMatchObject({
expect(settled.metadata).toMatchObject({ exit: 0, truncated: true })
expect(settled.content?.[0]).toMatchObject({
type: "text",
text: expect.stringContaining("output truncated; full output saved to:"),
})
@ -421,7 +424,7 @@ describe("ShellTool", () => {
)
it.live(
"reports bounded output progress for a running command",
"reports the shell ID for a running command",
() =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
@ -431,32 +434,21 @@ describe("ShellTool", () => {
const releasePath = path.join(tmp.path, release)
return withSession(tmp.path, (registry) =>
Effect.gen(function* () {
const observed = yield* Deferred.make<ToolRegistry.Progress>()
yield* settleTool(registry, {
const observed = yield* Deferred.make<string>()
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
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)
return
yield* Deferred.succeed(observed, update)
if (typeof update.shellID !== "string") return
yield* Deferred.succeed(observed, update.shellID)
yield* Effect.promise(() => fs.writeFile(releasePath, ""))
}),
})
const progress = yield* Deferred.await(observed)
expect(progress.structured).toEqual({ truncated: true })
const content = progress.content[0]
expect(content?.type).toBe("text")
if (content?.type !== "text") return
expect(content.text.indexOf("\n\n[output truncated; full output saved to:")).toBe(
ShellTool.MAX_CAPTURE_BYTES,
)
expect(yield* Deferred.await(observed)).toMatch(/^sh_/)
}).pipe(Effect.ensuring(Effect.promise(() => fs.writeFile(releasePath, "")).pipe(Effect.ignore))),
)
},
@ -466,7 +458,7 @@ describe("ShellTool", () => {
)
it.live(
"does not repeat unchanged shell progress",
"does not repeat shell ID progress",
() =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
@ -475,16 +467,12 @@ 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 },
content: [{ type: "text", text: "steady" }],
},
])
expect(updates).toHaveLength(1)
expect(updates[0]?.shellID).toMatch(/^sh_/)
}),
)
},
@ -493,18 +481,18 @@ describe("ShellTool", () => {
{ timeout: 10_000 },
)
it.live("returns a useful timeout settlement", () =>
it.live("returns a useful timeout outcome", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(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 +517,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 +549,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 +588,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 +603,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"),
})

View file

@ -17,7 +17,7 @@ import { it } from "./lib/effect"
import { imagePassthrough } from "./lib/image"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { toolIdentity, executeTool, registerToolPlugin, settleTool, toolDefinitions } from "./lib/tool"
import { toolIdentity, executeTool, registerToolPlugin, toolDefinitions } from "./lib/tool"
const skillToolNode = makeLocationNode({
name: "test/skill-tool-plugin",
@ -108,23 +108,22 @@ describe("SkillTool", () => {
...toolIdentity,
call: { type: "tool-call", id: "call-skill", name: "skill", input: { id: "effect" } },
}),
).toEqual({
type: "text",
value: SkillTool.toModelOutput(info, [reference]),
).toMatchObject({
status: "completed",
content: [{ type: "text", text: SkillTool.toModelOutput(info, [reference]) }],
})
expect(SkillTool.toModelOutput(info, [reference])).toContain(`Base directory for this skill: ${directory}`)
expect(
yield* settleTool(registry, {
yield* executeTool(registry, {
sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-skill-overflow", name: "skill", input: { id: "effect" } },
}),
).toEqual({
result: { type: "text", value: SkillTool.toModelOutput(info, [reference]) },
output: {
structured: { name: "Effect", directory },
content: [{ type: "text", text: SkillTool.toModelOutput(info, [reference]) }],
},
status: "completed",
output: { name: "Effect", directory, output: SkillTool.toModelOutput(info, [reference]) },
content: [{ type: "text", text: SkillTool.toModelOutput(info, [reference]) }],
metadata: { name: "Effect", directory },
})
expect(assertions).toMatchObject([
{ sessionID, action: "skill", resources: ["effect"], save: ["effect"] },
@ -136,7 +135,10 @@ describe("SkillTool", () => {
...toolIdentity,
call: { type: "tool-call", id: "call-missing-skill", name: "skill", input: { id: "missing" } },
}),
).toEqual({ type: "error", value: "Unable to load skill missing" })
).toEqual({
status: "error",
error: { type: "tool.execution", message: "Unable to load skill missing" },
})
deny = true
expect(
yield* executeTool(registry, {
@ -144,7 +146,10 @@ describe("SkillTool", () => {
...toolIdentity,
call: { type: "tool-call", id: "call-denied-skill", name: "skill", input: { id: "effect" } },
}),
).toEqual({ type: "error", value: "Unable to load skill effect" })
).toEqual({
status: "error",
error: { type: "permission.rejected", message: "Permission denied: skill" },
})
deny = false
const flat = SkillV2.Info.make({
id: SkillV2.ID.make("public"),
@ -166,7 +171,10 @@ describe("SkillTool", () => {
...toolIdentity,
call: { type: "tool-call", id: "call-flat-skill", name: "skill", input: { id: "public" } },
}),
).toEqual({ type: "text", value: SkillTool.toModelOutput(flat, []) })
).toMatchObject({
status: "completed",
content: [{ type: "text", text: SkillTool.toModelOutput(flat, []) }],
})
}).pipe(Effect.provide(skillToolLayer))
}),
),

View file

@ -28,7 +28,7 @@ import { ToolRegistry } from "@opencode-ai/core/tool/registry"
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
import { tmpdir } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect"
import { executeTool, settleTool, toolIdentity, waitForTool } from "./lib/tool"
import { executeTool, toolIdentity, waitForTool } from "./lib/tool"
const childText = "child final response"
const childModel = ModelV2.Ref.make({ id: ModelV2.ID.make("child"), providerID: ProviderV2.ID.make("test") })
@ -148,7 +148,7 @@ describe("SubagentTool", () => {
const locations = yield* LocationServiceMap.Service
const registry = yield* ToolRegistry.Service.pipe(Effect.provide(locations.get(parent.location)))
yield* waitForTool(registry, SubagentTool.name)
expect((yield* registry.materialize()).definitions.map((tool) => tool.name)).toContain(SubagentTool.name)
expect((yield* registry.snapshot()).definitions.map((tool) => tool.name)).toContain(SubagentTool.name)
expect(
yield* executeTool(registry, {
sessionID: parent.id,
@ -160,7 +160,10 @@ describe("SubagentTool", () => {
input: { agent: "primary", description: "primary", prompt: "should fail" },
},
}),
).toEqual({ type: "error", value: "Agent primary cannot run as a subagent" })
).toEqual({
status: "error",
error: { type: "tool.execution", message: "Agent primary cannot run as a subagent" },
})
}),
),
),
@ -193,7 +196,13 @@ describe("SubagentTool", () => {
input: { agent: "reviewer", description: "nested", prompt: "should fail" },
},
}),
).toEqual({ type: "error", value: expect.stringContaining("Subagent depth limit reached (1)") })
).toEqual({
status: "error",
error: {
type: "tool.execution",
message: expect.stringContaining("Subagent depth limit reached (1)"),
},
})
expect((yield* sessions.list({ parentID: parent.id })).data).toHaveLength(0)
}),
),
@ -219,7 +228,7 @@ describe("SubagentTool", () => {
const registry = yield* ToolRegistry.Service.pipe(Effect.provide(locations.get(parent.location)))
yield* waitForTool(registry, SubagentTool.name)
const settled = yield* settleTool(registry, {
const settled = yield* executeTool(registry, {
sessionID: parent.id,
...toolIdentity,
call: {
@ -231,17 +240,15 @@ describe("SubagentTool", () => {
})
expect(settled).toMatchObject({
result: { type: "text", value: childText },
output: {
structured: { status: "completed" },
content: [{ type: "text", text: childText }],
},
status: "completed",
metadata: { status: "completed" },
content: [{ type: "text", text: childText }],
})
expect(settled.output?.structured).toEqual({
sessionID: outputSessionID(settled.output?.structured),
expect(settled.metadata).toEqual({
sessionID: outputSessionID(settled.metadata),
status: "completed",
})
expect((yield* sessions.get(outputSessionID(settled.output?.structured))).parentID).toBe(parent.id)
expect((yield* sessions.get(outputSessionID(settled.metadata))).parentID).toBe(parent.id)
}),
),
),
@ -263,7 +270,7 @@ describe("SubagentTool", () => {
yield* waitForTool(registry, SubagentTool.name)
const progress: ToolRegistry.Progress[] = []
const settled = yield* settleTool(registry, {
const settled = yield* executeTool(registry, {
sessionID: parent.id,
...toolIdentity,
progress: (update) => Effect.sync(() => progress.push(update)),
@ -276,15 +283,13 @@ describe("SubagentTool", () => {
})
expect(settled).toMatchObject({
result: { type: "text", value: childText },
output: {
structured: { status: "completed" },
content: [{ type: "text", text: childText }],
},
status: "completed",
metadata: { status: "completed" },
content: [{ type: "text", text: childText }],
})
const child = yield* sessions.get(outputSessionID(settled.output?.structured))
expect(settled.output?.structured).toEqual({ sessionID: child.id, status: "completed" })
expect(progress[0]?.structured).toEqual({ sessionID: child.id, status: "running" })
const child = yield* sessions.get(outputSessionID(settled.metadata))
expect(settled.metadata).toEqual({ sessionID: child.id, status: "completed" })
expect(progress[0]?.metadata).toEqual({ sessionID: child.id, status: "running" })
expect(child).toMatchObject({
parentID: parent.id,
location: parent.location,
@ -295,7 +300,7 @@ describe("SubagentTool", () => {
"You are a subagent spawned by another session.\nreview this",
)
const fallback = yield* settleTool(registry, {
const fallback = yield* executeTool(registry, {
sessionID: parent.id,
...toolIdentity,
call: {
@ -305,7 +310,7 @@ describe("SubagentTool", () => {
input: { agent: "fallback", description: "fallback", prompt: "fallback" },
},
})
const fallbackChild = yield* sessions.get(outputSessionID(fallback.output?.structured))
const fallbackChild = yield* sessions.get(outputSessionID(fallback.metadata))
expect(fallbackChild).toMatchObject({ parentID: parent.id, model: parentModel })
}),
),
@ -338,7 +343,13 @@ describe("SubagentTool", () => {
input: { agent: "reviewer", description: "fail review", prompt: "please fail" },
},
}),
).toEqual({ type: "error", value: expect.stringContaining("No model is available for session") })
).toEqual({
status: "error",
error: {
type: "tool.execution",
message: expect.stringContaining("No model is available for session"),
},
})
}),
),
),
@ -366,7 +377,7 @@ describe("SubagentTool", () => {
Effect.forkScoped({ startImmediately: true }),
)
const settled = yield* settleTool(registry, {
const settled = yield* executeTool(registry, {
sessionID: parent.id,
...toolIdentity,
call: {
@ -376,13 +387,12 @@ describe("SubagentTool", () => {
input: { agent: "reviewer", description: "background review", prompt: "review", background: true },
},
})
const childID = outputSessionID(settled.output?.structured)
expect(settled.output?.structured).toMatchObject({
const childID = outputSessionID(settled.metadata)
expect(settled.metadata).toMatchObject({
status: "running",
})
expect(settled.output?.structured).toEqual({ sessionID: childID, status: "running" })
expect(settled.result).toEqual({ type: "text", value: expect.stringContaining(`id: ${childID}`) })
expect(settled.output?.content).toEqual([{ type: "text", text: expect.stringContaining(`id: ${childID}`) }])
expect(settled.metadata).toEqual({ sessionID: childID, status: "running" })
expect(settled.content).toEqual([{ type: "text", text: expect.stringContaining(`id: ${childID}`) }])
const admission = Array.from(yield* Fiber.join(admitted))[0]
expect(admission?.data.input.data.text).toContain(`<subagent id="${childID}" state="completed"`)

View file

@ -14,7 +14,7 @@ import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { Image } from "@opencode-ai/core/image"
import { testEffect } from "./lib/effect"
import { imagePassthrough } from "./lib/image"
import { toolIdentity, executeTool, registerToolPlugin, settleTool, toolDefinitions } from "./lib/tool"
import { toolIdentity, executeTool, registerToolPlugin, toolDefinitions } from "./lib/tool"
const webFetchToolNode = makeLocationNode({
name: "test/webfetch-tool-plugin",
@ -93,12 +93,11 @@ describe("WebFetchTool registration", () => {
const url = "http://example.com/public"
expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["webfetch"])
expect(yield* settleTool(registry, call({ url, format: "text", timeout: 4 }))).toEqual({
result: { type: "text", value: "hello" },
output: {
structured: { contentType: "text/plain" },
content: [{ type: "text", text: "hello" }],
},
expect(yield* executeTool(registry, call({ url, format: "text", timeout: 4 }))).toEqual({
status: "completed",
output: { url, contentType: "text/plain", format: "text", output: "hello" },
content: [{ type: "text", text: "hello" }],
metadata: { contentType: "text/plain" },
})
expect(assertions).toMatchObject([
{ sessionID, action: "webfetch", resources: [url], save: ["*"], metadata: { url, format: "text", timeout: 4 } },
@ -113,9 +112,9 @@ describe("WebFetchTool registration", () => {
const registry = yield* ToolRegistry.Service
const url = "http://localhost/private"
expect(yield* executeTool(registry, call({ url, format: "text" }))).toEqual({
type: "text",
value: "hello",
expect(yield* executeTool(registry, call({ url, format: "text" }))).toMatchObject({
status: "completed",
content: [{ type: "text", text: "hello" }],
})
expect(assertions).toMatchObject([
{ sessionID, action: "webfetch", resources: [url], save: ["*"], metadata: { url, format: "text" } },
@ -141,9 +140,9 @@ describe("WebFetchTool registration", () => {
const registry = yield* ToolRegistry.Service
const url = new URL("/redirect", server.url).toString()
expect(yield* executeTool(registry, call({ url, format: "text" }))).toEqual({
type: "text",
value: "redirected",
expect(yield* executeTool(registry, call({ url, format: "text" }))).toMatchObject({
status: "completed",
content: [{ type: "text", text: "redirected" }],
})
expect(assertions).toMatchObject([
{ sessionID, action: "webfetch", resources: [url], save: ["*"], metadata: { url, format: "text" } },
@ -158,9 +157,10 @@ describe("WebFetchTool registration", () => {
reset()
const registry = yield* ToolRegistry.Service
// toSessionError unwraps the "Unable to fetch <url>" ToolFailure to its cause message.
expect(yield* executeTool(registry, call({ url: "file:///etc/passwd", format: "text" }))).toEqual({
type: "error",
value: "Unable to fetch file:///etc/passwd",
status: "error",
error: { type: "unknown", message: "URL must use http:// or https://" },
})
expect(assertions).toEqual([])
expect(requests).toEqual([])
@ -178,13 +178,13 @@ describe("WebFetchTool registration", () => {
)
const registry = yield* ToolRegistry.Service
expect(yield* executeTool(registry, call({ url: "https://1.1.1.1", format: "markdown" }))).toEqual({
type: "text",
value: "# Hello\n\nworld",
expect(yield* executeTool(registry, call({ url: "https://1.1.1.1", format: "markdown" }))).toMatchObject({
status: "completed",
content: [{ type: "text", text: "# Hello\n\nworld" }],
})
expect(yield* executeTool(registry, call({ url: "https://1.1.1.1", format: "text" }))).toEqual({
type: "text",
value: "Helloworld",
expect(yield* executeTool(registry, call({ url: "https://1.1.1.1", format: "text" }))).toMatchObject({
status: "completed",
content: [{ type: "text", text: "Helloworld" }],
})
}),
)
@ -201,9 +201,9 @@ describe("WebFetchTool registration", () => {
const registry = yield* ToolRegistry.Service
const url = "https://1.1.1.1/deep-html"
expect(yield* executeTool(registry, call({ url, format: "markdown" }))).toEqual({
type: "error",
value: `Unable to fetch ${url}`,
expect(yield* executeTool(registry, call({ url, format: "markdown" }))).toMatchObject({
status: "error",
error: { type: "unknown" },
})
}),
)
@ -219,8 +219,11 @@ describe("WebFetchTool registration", () => {
}),
)
expect(yield* executeTool(registry, call({ url: "https://1.1.1.1/declared", format: "text" }))).toEqual({
type: "error",
value: "Unable to fetch https://1.1.1.1/declared",
status: "error",
error: {
type: "unknown",
message: `Response too large (exceeds ${WebFetchTool.MAX_RESPONSE_BYTES} byte limit)`,
},
})
respond = () =>
@ -228,26 +231,29 @@ 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)`,
},
})
}),
)
it.effect("keeps images and files unsupported until typed settlement can carry attachments", () =>
it.effect("keeps images and files unsupported until typed outcomes can carry attachments", () =>
Effect.gen(function* () {
reset()
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" },
})
}),
)
})

View file

@ -13,7 +13,7 @@ import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { Image } from "@opencode-ai/core/image"
import { testEffect } from "./lib/effect"
import { imagePassthrough } from "./lib/image"
import { toolIdentity, executeTool, registerToolPlugin, settleTool, toolDefinitions } from "./lib/tool"
import { toolIdentity, executeTool, registerToolPlugin, toolDefinitions } from "./lib/tool"
const webSearchToolNode = makeLocationNode({
name: "test/websearch-tool-plugin",
@ -172,7 +172,10 @@ describe("WebSearchTool registration", () => {
},
},
}),
).toEqual({ type: "text", value: "exa results" })
).toMatchObject({
status: "completed",
content: [{ type: "text", text: "exa results" }],
})
expect(assertions).toMatchObject([
{
sessionID,
@ -221,7 +224,7 @@ describe("WebSearchTool registration", () => {
config = { provider: "parallel", enableExa: false, enableParallel: false, parallelApiKey: "parallel-secret" }
const registry = yield* ToolRegistry.Service
const settled = yield* settleTool(registry, {
const settled = yield* executeTool(registry, {
sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-parallel", name: "websearch", input: { query: "effect layers" } },
@ -242,11 +245,10 @@ describe("WebSearchTool registration", () => {
})
expect(requests[0]?.body).not.toHaveProperty("params.arguments.model_name")
expect(settled).toEqual({
result: { type: "text", value: "parallel results" },
output: {
structured: { provider: "parallel" },
content: [{ type: "text", text: "parallel results" }],
},
status: "completed",
output: { provider: "parallel", text: "parallel results" },
content: [{ type: "text", text: "parallel results" }],
metadata: { provider: "parallel" },
})
expect(JSON.stringify(settled)).not.toContain("parallel-secret")
}),
@ -260,7 +262,7 @@ describe("WebSearchTool registration", () => {
config = { provider: "exa", enableExa: false, enableParallel: false, exaApiKey: "exa secret" }
const registry = yield* ToolRegistry.Service
const settled = yield* settleTool(registry, {
const settled = yield* executeTool(registry, {
sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-exa-key", name: "websearch", input: { query: "effect schema" } },
@ -285,7 +287,10 @@ describe("WebSearchTool registration", () => {
...toolIdentity,
call: { type: "tool-call", id: "call-empty", name: "websearch", input: { query: "nothing" } },
}),
).toEqual({ type: "text", value: WebSearchTool.NO_RESULTS })
).toMatchObject({
status: "completed",
content: [{ type: "text", text: WebSearchTool.NO_RESULTS }],
})
}),
)
@ -318,7 +323,12 @@ describe("WebSearchTool registration", () => {
...toolIdentity,
call: { type: "tool-call", id: "call-large-response", name: "websearch", input: { query: "too much" } },
}),
).toEqual({ type: "error", value: "Unable to search the web for too much" })
// toSessionError unwraps the "Unable to search the web for <query>" ToolFailure
// to its byte-limit cause message.
).toEqual({
status: "error",
error: { type: "unknown", message: expect.stringContaining("response exceeded") },
})
expect(chunksRead).toBeLessThan(10)
expect(cancelled).toBe(true)
}),

View file

@ -18,7 +18,7 @@ import { location } from "./fixture/location"
import { tmpdir } from "./fixture/tmpdir"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { testEffect } from "./lib/effect"
import { toolIdentity, executeTool, registerToolPlugin, settleTool, toolDefinitions } from "./lib/tool"
import { toolIdentity, executeTool, registerToolPlugin, toolDefinitions } from "./lib/tool"
const writeToolNode = makeLocationNode({
name: "test/write-tool-plugin",
@ -119,18 +119,16 @@ describe("WriteTool", () => {
return withTool(tmp.path, (registry) =>
Effect.gen(function* () {
expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["write"])
const settled = yield* settleTool(registry, call({ path: "src/new.txt", content: "created" }))
const settled = yield* executeTool(registry, call({ path: "src/new.txt", content: "created" }))
expect(settled).toEqual({
result: { type: "text", value: "Created file successfully: src/new.txt" },
status: "completed",
output: {
structured: {
operation: "write",
target: path.join(yield* Effect.promise(() => fs.realpath(tmp.path)), "src", "new.txt"),
resource: "src/new.txt",
existed: false,
},
content: [{ type: "text", text: "Created file successfully: src/new.txt" }],
operation: "write",
target: path.join(yield* Effect.promise(() => fs.realpath(tmp.path)), "src", "new.txt"),
resource: "src/new.txt",
existed: false,
},
content: [{ type: "text", text: "Created file successfully: src/new.txt" }],
})
expect(yield* Effect.promise(() => fs.readFile(path.join(tmp.path, "src", "new.txt"), "utf8"))).toBe(
"created",
@ -151,12 +149,14 @@ describe("WriteTool", () => {
reset()
return Effect.promise(() => fs.writeFile(path.join(tmp.path, "existing.txt"), "before")).pipe(
Effect.andThen(
withTool(tmp.path, (registry) => settleTool(registry, call({ path: "existing.txt", content: "after" }))),
withTool(tmp.path, (registry) => executeTool(registry, call({ path: "existing.txt", content: "after" }))),
),
Effect.andThen((settled) =>
Effect.gen(function* () {
expect(settled.result).toEqual({ type: "text", value: "Wrote file successfully: existing.txt" })
expect(settled.output?.structured).toMatchObject({ resource: "existing.txt", existed: true })
expect(settled.status).toBe("completed")
if (settled.status !== "completed") return
expect(settled.content).toEqual([{ type: "text", text: "Wrote file successfully: existing.txt" }])
expect(settled.output).toMatchObject({ resource: "existing.txt", existed: true })
expect(yield* Effect.promise(() => fs.readFile(path.join(tmp.path, "existing.txt"), "utf8"))).toBe(
"after",
)
@ -182,8 +182,8 @@ describe("WriteTool", () => {
Effect.andThen(
withTool(tmp.path, (registry) =>
Effect.gen(function* () {
yield* settleTool(registry, call({ path: "preserved.txt", content: "after" }, "call-preserved"))
yield* settleTool(
yield* executeTool(registry, call({ path: "preserved.txt", content: "after" }, "call-preserved"))
yield* executeTool(
registry,
call({ path: "deduplicated.txt", content: "\uFEFFafter" }, "call-deduplicated"),
)
@ -208,7 +208,10 @@ describe("WriteTool", () => {
return withTool(tmp.path, (registry) => executeTool(registry, call({ path: target, content: "inside" }))).pipe(
Effect.andThen((result) =>
Effect.gen(function* () {
expect(result).toEqual({ type: "text", value: "Created file successfully: absolute.txt" })
expect(result).toMatchObject({
status: "completed",
content: [{ type: "text", text: "Created file successfully: absolute.txt" }],
})
expect(assertions.map((input) => input.action)).toEqual(["edit"])
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("inside")
}),
@ -236,7 +239,7 @@ describe("WriteTool", () => {
),
Effect.andThen((result) =>
Effect.sync(() => {
expect(result.type).toBe("text")
expect(result.status).toBe("completed")
expect(assertions.map((input) => input.action)).toEqual(["edit"])
expect(assertions[0]?.resources).toEqual(["link.txt"])
}),
@ -259,7 +262,7 @@ describe("WriteTool", () => {
reset()
const target = path.join(outside.path, "external.txt")
return withTool(active.path, (registry) =>
settleTool(registry, call({ path: target, content: "external" })),
executeTool(registry, call({ path: target, content: "external" })),
).pipe(
Effect.andThen((settled) =>
Effect.gen(function* () {
@ -271,10 +274,13 @@ describe("WriteTool", () => {
],
})
expect(assertions[1]).toMatchObject({ resources: [canonicalTarget.replaceAll("\\", "/")], save: ["*"] })
expect(settled.output?.structured).toMatchObject({
target: canonicalTarget,
resource: canonicalTarget.replaceAll("\\", "/"),
existed: false,
expect(settled).toMatchObject({
status: "completed",
output: {
target: canonicalTarget,
resource: canonicalTarget.replaceAll("\\", "/"),
existed: false,
},
})
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("external")
expect(writes).toEqual([canonicalTarget])
@ -336,8 +342,8 @@ describe("WriteTool", () => {
executeTool(registry, call({ path: external, content: "blocked" })),
),
).toEqual({
type: "error",
value: `Unable to write ${external}`,
status: "error",
error: { type: "permission.rejected", message: "Permission denied: external_directory" },
})
expect(assertions.map((input) => input.action)).toEqual(["external_directory"])
expect(writes).toEqual([])
@ -349,8 +355,8 @@ describe("WriteTool", () => {
executeTool(registry, call({ path: "denied.txt", content: "blocked" })),
),
).toEqual({
type: "error",
value: "Unable to write denied.txt",
status: "error",
error: { type: "permission.rejected", message: "Permission denied: edit" },
})
expect(assertions.map((input) => input.action)).toEqual(["edit"])
expect(writes).toEqual([])

View file

@ -248,7 +248,7 @@ mutable fields:
| `ctx.aisdk.hook("language", callback)` | `language`, after inspecting `model`, `sdk`, and `options` |
| `ctx.session.hook("request", callback)` | `system`, `messages`, and the `tools` record immediately before model dispatch |
| `ctx.tool.hook("execute.before", callback)` | `input`, before the selected tool executes |
| `ctx.tool.hook("execute.after", callback)` | `result`, `output`, and `outputPaths`, after execution settles |
| `ctx.tool.hook("execute.after", callback)` | Terminal `content`, `metadata`, and `outputPaths`; `error` on failure |
For example, remove a tool from selected model requests and normalize another
tool's input:
@ -278,52 +278,66 @@ handle expected errors inside the callback.
### Add a tool
Pass a tool declaration to `tools.add`. Define its input with JSON Schema and
use an async executor:
Create an executable tool with `Tool.make`, then register it with a name
and registration options. Define its input with JSON Schema and use an async
executor:
```js title=".opencode/plugins/greeting.js"
import { Plugin } from "@opencode-ai/plugin/v2"
import { Tool } from "@opencode-ai/plugin/v2/tool"
export default Plugin.define({
id: "acme.greeting",
setup: async (ctx) => {
await ctx.tool.transform((tools) => {
tools.add({
name: "greeting",
description: "Create a greeting",
jsonSchema: {
type: "object",
properties: {
name: { type: "string" },
tools.add(
"greeting",
Tool.make({
description: "Create a greeting",
input: {
type: "object",
properties: {
name: { type: "string" },
},
required: ["name"],
additionalProperties: false,
},
required: ["name"],
additionalProperties: false,
},
execute: async ({ name }) => {
const text = `Hello, ${name}!`
return {
structured: { greeting: text },
content: [{ type: "text", text }],
}
},
})
output: {
type: "object",
properties: { greeting: { type: "string" } },
required: ["greeting"],
additionalProperties: false,
},
execute: async ({ name }) => {
const text = `Hello, ${name}!`
return {
output: { greeting: text },
content: text,
}
},
}),
)
})
},
})
```
Unsupported characters in tool and group names are normalized to underscores.
The resulting exposed key must begin with a letter and contain at most 64
letters, digits, underscores, or hyphens. Set `options` on the declaration to
configure registration with `{ group, codemode }`:
Unsupported characters in tool names are normalized to underscores. Namespace
segments must begin with a letter, contain at most 64 letters, digits,
underscores, or hyphens, and are joined with dots. Pass the optional third
argument to `tools.add` to configure the registration with
`{ namespace, codemode }`:
- `group` prefixes and groups the exposed tool name.
- `namespace` prefixes and groups the exposed tool name.
- `codemode` defaults to `true` and makes the tool available through the
`execute` CodeMode tool. Set `codemode: false` to expose it directly to the
provider.
The executor receives a second context argument containing `sessionID`,
`agent`, `assistantMessageID`, and `toolCallID`.
`agent`, `messageID`, `callID`, and `progress`. A tool with `output`
must return `output`; Effect and Standard Schema codecs validate it, while raw
JSON Schema definitions enforce JSON compatibility only. A tool
without `output` returns model-visible `content` instead.
### Add a command
@ -426,6 +440,7 @@ fibers, and registrations are released when the plugin reloads or unloads.
OpenCode does not expose its private Core services to the plugin; use the
capabilities on `ctx`.
Typed tools can use `Schema` from `effect` and the contracts exported from
`@opencode-ai/plugin/v2/effect/tool`. Their executors return an Effect and may
fail with the typed tool failure channel.
Typed tools can use `Schema` from `effect` and `Tool.make` from
`@opencode-ai/plugin/v2/effect/tool`. Effect and Promise plugins use the same
`tools.add(name, tool, options?)` registration shape. Effect executors
return an Effect and may fail with the typed tool failure channel.

View file

@ -0,0 +1,315 @@
import { Agent } from "@opencode-ai/schema/agent"
import { LLM } from "@opencode-ai/schema/llm"
import { Session } from "@opencode-ai/schema/session"
import { SessionError } from "@opencode-ai/schema/session-error"
import { SessionMessage } from "@opencode-ai/schema/session-message"
import type { StandardJSONSchemaV1, StandardSchemaV1 } from "@standard-schema/spec"
import { Effect, JsonSchema, Schema } from "effect"
import type { Hooks, Transform } from "../registration.js"
// Tools
/** A JSON-compatible value. Tool metadata and encoded outputs must be JSON. */
export type JsonValue = typeof Schema.Json.Type
/** Compact JSON metadata for tool-specific UI and client behavior. */
export type Metadata = Readonly<Record<string, JsonValue>>
export interface Context {
readonly sessionID: Session.ID
readonly agent: Agent.ID
readonly messageID: SessionMessage.ID
readonly callID: string
readonly progress: (update: Progress) => Effect.Effect<void>
}
/** Live replacement metadata for a running tool. */
export type Progress = Metadata
export type StandardSchemaType<Input = unknown, Output = Input> = StandardSchemaV1<Input, Output> &
StandardJSONSchemaV1<Input, Output>
export type SchemaType<A = unknown> = Schema.Codec<A, any> | StandardSchemaType<any, A> | JsonSchema.JsonSchema
type IsAny<A> = 0 extends 1 & A ? true : false
export type InputValue<S> =
IsAny<S> extends true
? any
: S extends Schema.Codec<infer A, any>
? A
: S extends StandardSchemaV1<any, infer A>
? A
: unknown
export type OutputValue<S> =
IsAny<S> extends true
? any
: S extends Schema.Codec<infer A, any>
? A
: S extends StandardSchemaV1<infer A, any>
? A
: unknown
export type EncodedValue<S> =
IsAny<S> extends true
? any
: S extends Schema.Codec<any, infer A>
? A
: S extends StandardSchemaV1<any, infer A>
? A
: unknown
type ToolDefinition = {
readonly name: string
readonly description: string
readonly inputSchema: JsonSchema.JsonSchema
readonly outputSchema?: JsonSchema.JsonSchema
}
export class Failure extends Schema.TaggedErrorClass<Failure>()("LLM.ToolFailure", {
message: Schema.String,
error: Schema.optional(Schema.Defect()),
}) {}
export class RegistrationError extends Schema.TaggedErrorClass<RegistrationError>()("Tool.RegistrationError", {
name: Schema.String,
message: Schema.String,
}) {}
export type Content =
| { readonly type: "text"; readonly text: string }
| { readonly type: "file"; readonly data: string; readonly mime: string; readonly name?: string }
/** Model-facing tool content: plain text or non-empty rich content. */
export type ModelOutput = string | readonly [Content, ...Content[]]
type BaseTool<Input extends SchemaType<any>> = {
readonly description: string
readonly input: Input
}
export type Response<Output extends SchemaType<any>> = {
readonly output: OutputValue<Output>
readonly content?: ModelOutput
readonly metadata?: Metadata
}
export type ContentResponse = {
readonly content: ModelOutput
readonly metadata?: Metadata
}
export type Tool<
Input extends SchemaType<any>,
Output extends SchemaType<any> | undefined = undefined,
> = BaseTool<Input> &
(Output extends SchemaType<any>
? {
readonly output: Output
readonly execute: (input: InputValue<Input>, context: Context) => Effect.Effect<Response<Output>, Failure>
}
: {
readonly output?: undefined
readonly execute: (input: InputValue<Input>, context: Context) => Effect.Effect<ContentResponse, Failure>
})
export type Any = BaseTool<any> & {
readonly output?: SchemaType<any>
readonly execute: (input: any, context: Context) => Effect.Effect<Response<any> | ContentResponse, Failure>
}
export function make<Input extends SchemaType<any>, Output extends SchemaType<any>>(
config: Tool<Input, Output>,
): Tool<Input, Output>
export function make<Input extends SchemaType<any>>(config: Tool<Input>): Tool<Input>
export function make(config: Any): Any
export function make(config: Any): Any {
return config
}
// Registration
export interface RegisterOptions {
readonly namespace?: string
/** Defaults to true. False exposes the tool directly to the provider. */
readonly codemode?: boolean
/** Permission action used for whole-tool visibility filtering. */
readonly permission?: string
}
export interface Registration {
readonly tool: Any
readonly name: string
readonly namespace?: string
readonly permission: string
}
export const validateName = (name: string) =>
/^[A-Za-z][A-Za-z0-9_-]{0,63}$/.test(name)
? Effect.void
: Effect.fail(new RegistrationError({ name, message: `Invalid tool name: ${name}` }))
export const registrationEntries = (
tools: Readonly<Record<string, Any>>,
options?: RegisterOptions,
): Array<Registration & { readonly key: string }> =>
Object.entries(tools).map(([name, tool]) => {
const normalized = name.replace(/[^a-zA-Z0-9_-]/g, "_")
const key =
options?.namespace === undefined ? normalized : `${options.namespace.replaceAll(".", "_")}_${normalized}`
return {
key,
name: normalized,
namespace: options?.namespace,
tool,
permission: options?.permission ?? key,
}
})
export const validateNamespace = (namespace: string) =>
namespace.split(".").every((segment) => /^[A-Za-z][A-Za-z0-9_-]{0,63}$/.test(segment))
? Effect.void
: Effect.fail(
new RegistrationError({ name: namespace, message: `Invalid tool namespace: ${JSON.stringify(namespace)}` }),
)
export const toLLMDefinition = (name: string, tool: Any): ToolDefinition => ({
name,
description: tool.description,
inputSchema: inputJsonSchema(tool.input),
...(tool.output === undefined ? {} : { outputSchema: outputJsonSchema(tool.output) }),
})
// Schema interpretation
export function decodeInput(schema: SchemaType<any>, value: unknown): Effect.Effect<any, Failure> {
if (Schema.isSchema(schema))
return Schema.decodeUnknownEffect(schema)(value).pipe(
Effect.mapError((error) => new Failure({ message: `Invalid tool input: ${error.message}` })),
)
if (isStandardSchema(schema)) return validateStandard(schema, value, "Invalid tool input")
return Effect.succeed(value)
}
export function encodeOutput(schema: SchemaType<any>, value: unknown): Effect.Effect<any, Failure> {
if (Schema.isSchema(schema))
return Schema.encodeEffect(schema)(value).pipe(
Effect.mapError(
(error) => new Failure({ message: `Tool returned an invalid value for its output schema: ${error.message}` }),
),
)
if (isStandardSchema(schema))
return validateStandard(schema, value, "Tool returned an invalid value for its output schema")
return Schema.decodeUnknownEffect(Schema.Json)(value).pipe(
Effect.mapError(
(error) => new Failure({ message: `Tool returned a non-JSON value for its output schema: ${error.message}` }),
),
)
}
function isStandardSchema(schema: SchemaType<any>): schema is StandardSchemaType {
return "~standard" in schema
}
function validateStandard(schema: StandardSchemaType, value: unknown, prefix: string): Effect.Effect<unknown, Failure> {
return Effect.gen(function* () {
const pending = yield* Effect.try({
try: () => schema["~standard"].validate(value),
catch: (error) => standardFailure(prefix, error),
})
const result =
pending instanceof Promise
? yield* Effect.tryPromise({ try: () => pending, catch: (error) => standardFailure(prefix, error) })
: pending
if (result.issues)
return yield* Effect.fail(
new Failure({ message: `${prefix}: ${result.issues.map((issue) => issue.message).join(", ")}` }),
)
return result.value
})
}
function standardFailure(prefix: string, error: unknown) {
return new Failure({ message: `${prefix}: ${error instanceof Error ? error.message : String(error)}` })
}
function inputJsonSchema(schema: SchemaType<any>): JsonSchema.JsonSchema {
if (isStandardSchema(schema))
return schema["~standard"].jsonSchema.input({ target: "draft-2020-12" }) as JsonSchema.JsonSchema
return Schema.isSchema(schema) ? toJsonSchema(schema) : (schema as JsonSchema.JsonSchema)
}
function outputJsonSchema(schema: SchemaType<any>): JsonSchema.JsonSchema {
if (isStandardSchema(schema))
return schema["~standard"].jsonSchema.output({ target: "draft-2020-12" }) as JsonSchema.JsonSchema
return Schema.isSchema(schema) ? toJsonSchema(schema) : (schema as JsonSchema.JsonSchema)
}
function toJsonSchema(schema: Schema.Top): JsonSchema.JsonSchema {
const document = Schema.toJsonSchemaDocument(schema)
if (Object.keys(document.definitions).length === 0) return document.schema
return { ...document.schema, $defs: document.definitions }
}
// Plugin events
export interface ToolExecuteBeforeEvent {
readonly tool: string
readonly sessionID: Session.ID
readonly agent: Agent.ID
readonly messageID: SessionMessage.ID
readonly callID: string
input: unknown
}
type ToolHookBase = {
readonly tool: string
readonly sessionID: Session.ID
readonly agent: Agent.ID
readonly messageID: SessionMessage.ID
readonly callID: string
readonly input: unknown
}
export const ExecuteAfterOutcome = Schema.Union([
Schema.Struct({
status: Schema.Literal("completed"),
content: Schema.NonEmptyArray(LLM.ToolContent),
metadata: Schema.optional(Schema.Record(Schema.String, Schema.Json)),
outputPaths: Schema.optional(Schema.Array(Schema.String)),
}),
Schema.Struct({
status: Schema.Literal("error"),
error: SessionError.Error,
content: Schema.optional(Schema.NonEmptyArray(LLM.ToolContent)),
metadata: Schema.optional(Schema.Record(Schema.String, Schema.Json)),
outputPaths: Schema.optional(Schema.Array(Schema.String)),
}),
]).pipe(Schema.toTaggedUnion("status"))
type Mutable<A> = { -readonly [K in keyof A]: A[K] }
type HookOutcome<A extends { readonly status: string }> = Omit<Mutable<A>, "status"> & Pick<A, "status">
/** The bounded terminal outcome exposed to tool hooks. */
export type Outcome = typeof ExecuteAfterOutcome.Type extends infer A
? A extends { readonly status: string }
? HookOutcome<A>
: never
: never
/**
* The canonical execution outcome as seen by `execute.after` hooks. Hooks
* observe bounded model content, optional metadata, and managed output paths;
* they never observe the raw domain output.
*/
export type ToolExecuteAfterEvent = ToolHookBase & Outcome
export interface ToolDraft {
add(name: string, tool: Any, options?: RegisterOptions): void
}
export interface ToolHooks {
readonly "execute.before": ToolExecuteBeforeEvent
readonly "execute.after": ToolExecuteAfterEvent
}
export interface ToolDomain {
readonly transform: Transform<ToolDraft>
readonly hook: Hooks<ToolHooks>
}

View file

@ -1,314 +1,2 @@
export * as Tool from "./tool.js"
import { Agent } from "@opencode-ai/schema/agent"
import type { LLM } from "@opencode-ai/schema/llm"
import { Session } from "@opencode-ai/schema/session"
import { SessionMessage } from "@opencode-ai/schema/session-message"
import type { StandardJSONSchemaV1, StandardSchemaV1 } from "@standard-schema/spec"
import { Effect, JsonSchema, Schema } from "effect"
import type { Hooks, Transform } from "./registration.js"
export interface Context {
readonly sessionID: Session.ID
readonly agent: Agent.ID
readonly messageID: SessionMessage.ID
readonly callID: string
readonly progress: (update: Progress) => Effect.Effect<void>
}
export interface Progress {
readonly structured: Readonly<Record<string, unknown>>
readonly content?: ReadonlyArray<Content>
}
export type StandardSchemaType<Input = unknown, Output = Input> = StandardSchemaV1<Input, Output> &
StandardJSONSchemaV1<Input, Output>
export type SchemaType<A> = Schema.Codec<A, any> | StandardSchemaType<any, A>
type IsAny<A> = 0 extends 1 & A ? true : false
export type InputValue<S> =
IsAny<S> extends true
? any
: S extends Schema.Codec<infer A, any>
? A
: S extends StandardSchemaV1<any, infer A>
? A
: never
export type OutputValue<S> =
IsAny<S> extends true
? any
: S extends Schema.Codec<infer A, any>
? A
: S extends StandardSchemaV1<infer A, any>
? A
: never
export type EncodedValue<S> =
IsAny<S> extends true
? any
: S extends Schema.Codec<any, infer A>
? A
: S extends StandardSchemaV1<any, infer A>
? A
: never
type ToolDefinition = {
readonly name: string
readonly description: string
readonly inputSchema: JsonSchema.JsonSchema
readonly outputSchema?: JsonSchema.JsonSchema
}
type ToolCall = {
readonly input: unknown
readonly [key: string]: unknown
}
type ToolResultValue =
| { readonly type: "json"; readonly value: unknown }
| { readonly type: "text"; readonly value: unknown }
| { readonly type: "error"; readonly value: unknown }
| { readonly type: "content"; readonly value: ReadonlyArray<LLM.ToolContent> }
type ToolOutput = {
readonly structured: unknown
readonly content: ReadonlyArray<LLM.ToolContent>
}
export class Failure extends Schema.TaggedErrorClass<Failure>()("LLM.ToolFailure", {
message: Schema.String,
error: Schema.optional(Schema.Defect()),
metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
}) {}
export class RegistrationError extends Schema.TaggedErrorClass<RegistrationError>()("Tool.RegistrationError", {
name: Schema.String,
message: Schema.String,
}) {}
export type Content =
| { readonly type: "text"; readonly text: string }
| { readonly type: "file"; readonly data: string; readonly mime: string; readonly name?: string }
export type Definition<
Input extends SchemaType<any>,
Structured extends SchemaType<any>,
Output extends SchemaType<any> = any,
> = {
readonly description: string
readonly input: Input
readonly output: Output
readonly structured?: Structured
readonly permission?: string
readonly toStructuredOutput?: (input: {
readonly input: InputValue<Input>
readonly output: EncodedValue<Output>
}) => OutputValue<Structured>
readonly execute: (input: InputValue<Input>, context: Context) => Effect.Effect<OutputValue<Output>, Failure>
readonly toModelOutput?: (input: {
readonly input: InputValue<Input>
readonly output: EncodedValue<Output>
}) => ReadonlyArray<Content>
}
export type DynamicOutput = {
readonly structured: unknown
readonly content: ReadonlyArray<Content>
}
/**
* Config for a tool whose input shape is a raw JSON Schema not known at compile
* time (MCP servers, plugin manifests). Input is passed through as `unknown`;
* `execute` returns the already-projected structured value and model content.
*/
export type DynamicDefinition = {
readonly description: string
readonly jsonSchema: JsonSchema.JsonSchema
readonly outputSchema?: JsonSchema.JsonSchema
readonly permission?: string
readonly execute: (input: unknown, context: Context) => Effect.Effect<DynamicOutput, Failure>
}
export type AnyTool = Definition<any, any> | DynamicDefinition
export function make<
Input extends SchemaType<any>,
Output extends SchemaType<any>,
Structured extends SchemaType<any> = Output,
>(config: Definition<Input, Structured, Output>): Definition<Input, Structured, Output>
export function make(config: DynamicDefinition): DynamicDefinition
export function make(config: AnyTool): AnyTool
export function make(config: AnyTool): AnyTool {
return config
}
function toModelContent(part: Content) {
if (part.type === "text") return { type: "text" as const, text: part.text }
return { type: "file" as const, uri: `data:${part.mime};base64,${part.data}`, mime: part.mime, name: part.name }
}
export const validateName = (name: string) =>
/^[A-Za-z][A-Za-z0-9_-]{0,63}$/.test(name)
? Effect.void
: Effect.fail(new RegistrationError({ name, message: `Invalid tool name: ${name}` }))
export const registrationEntries = (tools: Readonly<Record<string, AnyTool>>, namespace?: string) =>
Object.entries(tools).map(([name, tool]) => {
const normalized = name.replace(/[^a-zA-Z0-9_-]/g, "_")
return {
key: namespace === undefined ? normalized : `${namespace.replaceAll(".", "_")}_${normalized}`,
name: normalized,
namespace,
tool,
}
})
export const validateNamespace = (namespace: string) =>
namespace.split(".").every((segment) => /^[A-Za-z][A-Za-z0-9_-]{0,63}$/.test(segment))
? Effect.void
: Effect.fail(
new RegistrationError({ name: namespace, message: `Invalid tool namespace: ${JSON.stringify(namespace)}` }),
)
export const withPermission = <T extends AnyTool>(
tool: T,
permission: string,
): Omit<T, "permission"> & {
readonly permission: string
} => ({ ...tool, permission })
export const permission = (tool: AnyTool, name: string) => tool.permission ?? name
export const definition = (name: string, tool: AnyTool): ToolDefinition =>
"jsonSchema" in tool
? {
name,
description: tool.description,
inputSchema: tool.jsonSchema,
outputSchema: tool.outputSchema,
}
: {
name,
description: tool.description,
inputSchema: inputJsonSchema(tool.input),
outputSchema: outputJsonSchema(tool.structured ?? tool.output),
}
export const settle = (tool: AnyTool, call: ToolCall, context: Context): Effect.Effect<ToolOutput, Failure> =>
Effect.gen(function* () {
if ("jsonSchema" in tool) {
const output = yield* tool.execute(call.input, context)
return { structured: output.structured, content: output.content.map(toModelContent) }
}
const input = yield* decodeInput(tool.input, call.input)
const value = yield* tool.execute(input, context)
const output = yield* encodeOutput(tool.output, value)
const structured =
tool.structured && tool.toStructuredOutput
? yield* encodeOutput(tool.structured, tool.toStructuredOutput({ input, output }))
: output
return {
structured,
content:
tool.toModelOutput?.({ input, output }).map(toModelContent) ??
(typeof output === "string" ? [{ type: "text" as const, text: output }] : []),
}
})
function decodeInput(schema: SchemaType<any>, value: unknown): Effect.Effect<any, Failure> {
if (Schema.isSchema(schema))
return Schema.decodeUnknownEffect(schema)(value).pipe(
Effect.mapError((error) => new Failure({ message: `Invalid tool input: ${error.message}` })),
)
return validateStandard(schema, value, "Invalid tool input")
}
function encodeOutput(schema: SchemaType<any>, value: unknown): Effect.Effect<any, Failure> {
if (Schema.isSchema(schema))
return Schema.encodeEffect(schema)(value).pipe(
Effect.mapError(
(error) => new Failure({ message: `Tool returned an invalid value for its output schema: ${error.message}` }),
),
)
return validateStandard(schema, value, "Tool returned an invalid value for its output schema")
}
function validateStandard(schema: StandardSchemaType, value: unknown, prefix: string): Effect.Effect<unknown, Failure> {
return Effect.gen(function* () {
const pending = yield* Effect.try({
try: () => schema["~standard"].validate(value),
catch: (error) => standardFailure(prefix, error),
})
const result =
pending instanceof Promise
? yield* Effect.tryPromise({ try: () => pending, catch: (error) => standardFailure(prefix, error) })
: pending
if (result.issues)
return yield* Effect.fail(
new Failure({ message: `${prefix}: ${result.issues.map((issue) => issue.message).join(", ")}` }),
)
return result.value
})
}
function standardFailure(prefix: string, error: unknown) {
return new Failure({ message: `${prefix}: ${error instanceof Error ? error.message : String(error)}` })
}
function inputJsonSchema(schema: SchemaType<any>): JsonSchema.JsonSchema {
if (!Schema.isSchema(schema))
return schema["~standard"].jsonSchema.input({ target: "draft-2020-12" }) as JsonSchema.JsonSchema
return toJsonSchema(schema)
}
function outputJsonSchema(schema: SchemaType<any>): JsonSchema.JsonSchema {
if (!Schema.isSchema(schema))
return schema["~standard"].jsonSchema.output({ target: "draft-2020-12" }) as JsonSchema.JsonSchema
return toJsonSchema(schema)
}
function toJsonSchema(schema: Schema.Top): JsonSchema.JsonSchema {
const document = Schema.toJsonSchemaDocument(schema)
if (Object.keys(document.definitions).length === 0) return document.schema
return { ...document.schema, $defs: document.definitions }
}
export interface ToolExecuteBeforeEvent {
readonly tool: string
readonly sessionID: Session.ID
readonly agent: Agent.ID
readonly messageID: SessionMessage.ID
readonly callID: string
input: unknown
}
export interface ToolExecuteAfterEvent {
readonly tool: string
readonly sessionID: Session.ID
readonly agent: Agent.ID
readonly messageID: SessionMessage.ID
readonly callID: string
readonly input: unknown
result: ToolResultValue
output?: ToolOutput
outputPaths?: ReadonlyArray<string>
}
export interface RegisterOptions {
readonly namespace?: string
/** Defaults to true. False exposes the tool directly to the provider. */
readonly codemode?: boolean
}
export interface ToolDraft {
add(name: string, tool: AnyTool, options?: RegisterOptions): void
}
export interface ToolHooks {
readonly "execute.before": ToolExecuteBeforeEvent
readonly "execute.after": ToolExecuteAfterEvent
}
export interface ToolDomain {
readonly transform: Transform<ToolDraft>
readonly hook: Hooks<ToolHooks>
}
export * as Tool from "./internal/tool.js"
export * from "./internal/tool.js"

View file

@ -94,19 +94,23 @@ await ctx.session.hook("context", (event) => {
})
```
Promise tools use plain object declarations with async executors:
Promise tools use executable tool values with async executors. Registration
supplies the tool's name and options separately:
```ts
import { Schema } from "effect"
import { Tool } from "@opencode-ai/plugin/v2/tool"
await ctx.tool.transform((tools) => {
tools.add({
name: "echo",
description: "Echo text",
input: Schema.Struct({ text: Schema.String }),
output: Schema.Struct({ text: Schema.String }),
execute: async ({ text }) => ({ text }),
})
tools.add(
"echo",
Tool.make({
description: "Echo text",
input: Schema.Struct({ text: Schema.String }),
output: Schema.Struct({ text: Schema.String }),
execute: async ({ text }) => ({ output: { text }, content: text }),
}),
)
})
```

View file

@ -0,0 +1,64 @@
import type { Hooks, Transform } from "../registration.js"
export type Context = Omit<import("../../effect/internal/tool.js").Context, "progress"> & {
readonly progress: (update: import("../../effect/internal/tool.js").Progress) => Promise<void>
}
export type SchemaType<A> = import("../../effect/internal/tool.js").SchemaType<A>
export type Content = import("../../effect/internal/tool.js").Content
export type Metadata = import("../../effect/internal/tool.js").Metadata
export type ModelOutput = import("../../effect/internal/tool.js").ModelOutput
export type Tool<Input extends SchemaType<any>, Output extends SchemaType<any> | undefined = undefined> = Omit<
import("../../effect/internal/tool.js").Tool<Input, Output>,
"execute"
> & {
readonly execute: (
input: import("../../effect/internal/tool.js").InputValue<Input>,
context: Context,
) => Promise<
Output extends SchemaType<any>
? import("../../effect/internal/tool.js").Response<Output>
: import("../../effect/internal/tool.js").ContentResponse
>
}
export type Any = Omit<import("../../effect/internal/tool.js").Any, "execute"> & {
readonly execute: (
input: any,
context: Context,
) => Promise<
import("../../effect/internal/tool.js").Response<any> | import("../../effect/internal/tool.js").ContentResponse
>
}
export function make<Input extends SchemaType<any>, Output extends SchemaType<any>>(
tool: Tool<Input, Output>,
): Tool<Input, Output>
export function make<Input extends SchemaType<any>>(tool: Tool<Input>): Tool<Input>
export function make(tool: Any): Any
export function make(tool: Any): Any {
return tool
}
export type ToolExecuteBeforeEvent = import("../../effect/internal/tool.js").ToolExecuteBeforeEvent
export type ToolExecuteAfterEvent = import("../../effect/internal/tool.js").ToolExecuteAfterEvent
export type RegisterOptions = import("../../effect/internal/tool.js").RegisterOptions
export interface ToolDraft {
add<Input extends SchemaType<any>, Output extends SchemaType<any>>(
name: string,
tool: Tool<Input, Output>,
options?: RegisterOptions,
): void
add<Input extends SchemaType<any>>(name: string, tool: Tool<Input>, options?: RegisterOptions): void
}
export interface ToolHooks {
readonly "execute.before": ToolExecuteBeforeEvent
readonly "execute.after": ToolExecuteAfterEvent
}
export interface ToolDomain {
readonly transform: Transform<ToolDraft>
readonly hook: Hooks<ToolHooks>
}

View file

@ -1,48 +1,2 @@
import type { Tool } from "../effect/tool.js"
import type { Hooks, Transform } from "./registration.js"
export type Context = Omit<Tool.Context, "progress"> & {
readonly progress: (update: Tool.Progress) => Promise<void>
}
export type SchemaType<A> = Tool.SchemaType<A>
export type Content = Tool.Content
export type DynamicOutput = Tool.DynamicOutput
export type Definition<
Input extends SchemaType<any>,
Output extends SchemaType<any>,
Structured extends SchemaType<any> = Output,
> = Omit<Tool.Definition<Input, Structured, Output>, "execute" | "permission"> & {
readonly name: string
readonly options?: RegisterOptions
readonly execute: (input: Tool.InputValue<Input>, context: Context) => Promise<Tool.OutputValue<Output>>
}
export type DynamicDefinition = Omit<Tool.DynamicDefinition, "execute" | "permission"> & {
readonly name: string
readonly options?: RegisterOptions
readonly execute: (input: unknown, context: Context) => Promise<DynamicOutput>
}
export type AnyTool = Definition<any, any, any> | DynamicDefinition
export type ToolExecuteBeforeEvent = Tool.ToolExecuteBeforeEvent
export type ToolExecuteAfterEvent = Tool.ToolExecuteAfterEvent
export type RegisterOptions = Tool.RegisterOptions
export interface ToolDraft {
add<Input extends SchemaType<any>, Output extends SchemaType<any>, Structured extends SchemaType<any> = Output>(
tool: Definition<Input, Output, Structured>,
): void
add(tool: DynamicDefinition): void
}
export interface ToolHooks {
readonly "execute.before": ToolExecuteBeforeEvent
readonly "execute.after": ToolExecuteAfterEvent
}
export interface ToolDomain {
readonly transform: Transform<ToolDraft>
readonly hook: Hooks<ToolHooks>
}
export * as Tool from "./internal/tool.js"
export * from "./internal/tool.js"

View file

@ -1,29 +1,18 @@
import { expect, test } from "bun:test"
import { Agent } from "@opencode-ai/schema/agent"
import { Session } from "@opencode-ai/schema/session"
import { SessionMessage } from "@opencode-ai/schema/session-message"
import { Effect, Schema } from "effect"
import * as Tool from "../src/v2/effect/tool"
const context = {
sessionID: Session.ID.make("ses_test"),
agent: Agent.ID.make("build"),
messageID: SessionMessage.ID.make("msg_test"),
callID: "call_test",
progress: () => Effect.void,
} satisfies Tool.Context
test("tools remain valid across separate module instances", async () => {
const ForeignTool = await import(`${new URL("../src/v2/effect/tool.ts", import.meta.url).href}?foreign`)
const config = {
description: "Foreign tool",
input: Schema.Struct({ value: Schema.String }),
output: Schema.Struct({ ok: Schema.Boolean }),
execute: () => Effect.succeed({ ok: true }),
execute: () => Effect.succeed({ output: { ok: true } }),
}
const tool = ForeignTool.make(config)
expect(Tool.definition("foreign", tool)).toEqual({
expect(Tool.toLLMDefinition("foreign", tool)).toEqual({
name: "foreign",
description: "Foreign tool",
inputSchema: {
@ -39,10 +28,7 @@ test("tools remain valid across separate module instances", async () => {
additionalProperties: false,
},
})
expect(await Effect.runPromise(Tool.settle(tool, { input: { value: "input" } }, context))).toEqual({
structured: { ok: true },
content: [],
})
expect(await Effect.runPromise(Tool.decodeInput(tool.input, { value: "input" }))).toEqual({ value: "input" })
})
test("portable schemas validate and describe typed tools", async () => {
@ -77,19 +63,18 @@ test("portable schemas validate and describe typed tools", async () => {
description: "Portable tool",
input,
output,
execute: ({ count }) => Effect.succeed(count + 1),
execute: ({ count }) => Effect.succeed({ output: count + 1 }),
})
expect(Tool.definition("portable", tool)).toEqual({
expect(Tool.toLLMDefinition("portable", tool)).toEqual({
name: "portable",
description: "Portable tool",
inputSchema: { type: "object", properties: { count: { type: "string" } } },
outputSchema: { type: "string" },
})
expect(await Effect.runPromise(Tool.settle(tool, { input: { count: "41" } }, context))).toEqual({
structured: "42",
content: [{ type: "text", text: "42" }],
})
const decoded = await Effect.runPromise(Tool.decodeInput(tool.input, { count: "41" }))
expect(decoded).toEqual({ count: 41 })
expect(await Effect.runPromise(Tool.encodeOutput(tool.output, 42))).toBe("42")
})
test("portable schema failures become tool failures", async () => {
@ -104,29 +89,39 @@ test("portable schema failures become tool failures", async () => {
},
},
}
const tool = Tool.make({
description: "Failing tool",
input,
output: input,
execute: Effect.succeed,
})
const error = await Effect.runPromiseExit(Tool.settle(tool, { input: 1 }, context))
const error = await Effect.runPromiseExit(Tool.decodeInput(input, 1))
expect(error.toString()).toContain("Invalid tool input: expected a string")
})
test("two-parameter Definition annotations retain their original meaning", () => {
test("canonical results carry metadata with typed output", async () => {
const input = Schema.Struct({ value: Schema.String })
const output = Schema.Struct({ value: Schema.String, internal: Schema.Boolean })
const structured = Schema.Struct({ value: Schema.String })
const tool: Tool.Definition<typeof input, typeof structured> = Tool.make({
const tool = Tool.make({
description: "Annotated tool",
input,
output,
structured,
toStructuredOutput: ({ output }) => ({ value: output.value }),
execute: ({ value }) => Effect.succeed({ value, internal: true }),
execute: ({ value }) => Effect.succeed({ output: { value, internal: true }, metadata: { value }, content: value }),
})
expect(tool.structured).toBe(structured)
expect(await Effect.runPromise(tool.execute({ value: "out" }, {} as Tool.Context))).toEqual({
output: { value: "out", internal: true },
metadata: { value: "out" },
content: "out",
})
})
test("raw JSON schemas are render-only and omitted output means model-only", async () => {
const tool = Tool.make({
description: "Raw tool",
input: { type: "object", properties: { value: { type: "string" } } },
execute: (input) => Effect.succeed({ content: JSON.stringify(input) }),
})
expect(Tool.toLLMDefinition("raw", tool)).toEqual({
name: "raw",
description: "Raw tool",
inputSchema: { type: "object", properties: { value: { type: "string" } } },
})
expect(await Effect.runPromise(Tool.decodeInput(tool.input, { value: 1 }))).toEqual({ value: 1 })
})

Some files were not shown because too many files have changed in this diff Show more