Compare commits

...

19 Commits

Author SHA1 Message Date
Kit Langton 4139a16cee test(cli): update mini export boundary 2026-07-22 23:42:24 -04:00
Kit Langton ed35983231 refactor(core): finalize canonical tool outcomes 2026-07-22 23:31:10 -04:00
Kit Langton 0dc2509553 refactor: simplify canonical tool result seams
Review cleanups: dead imports, contentFrom happy-path flattening, codemodeTool shadow rename, call-site param naming for toModelOutput/toMetadata, spec Context fields matching the code, progress-metadata leniency comment, and PLAN.md marked implemented.
2026-07-22 22:46:38 -04:00
Kit Langton 162b70e28d fix(tui,cli): unwrap read page envelope in tool displays
Read's canonical model content is the JSON page envelope, so ACP clients and text displays showed raw JSON instead of file text. toolOutputText and the ACP tool update now unwrap the envelope back to the clean content, replacing the dead metadata-based extraction.
2026-07-22 22:46:38 -04:00
Kit Langton 0ddfdf2914 fix(core): validate post-hook tool mutations and bound failure metadata
An untyped JS plugin after-hook could clear failed-call content to an empty array or set non-JSON metadata, producing an unencodable durable event that wedged the tool part in running. The JS boundary now revalidates through one shared Tool.jsonMetadata guard, which also applies the byte bound to failure-snapshot metadata that only the success path enforced before. Consolidates the duplicated stringify and metadata-validation helpers into tool.ts.
2026-07-22 22:46:38 -04:00
Kit Langton 7ad76379f9 fix(core): keep hosted result payloads across same-provider model switches
Hosted provider result payloads are provider-format state, not model state. Gating providerResultState reuse on exact model identity broke Anthropic hosted replay after a model switch within the provider; the fallback result value is derived display content, not the typed block payload. Result-state reuse now requires only provider identity.
2026-07-22 22:46:38 -04:00
Kit Langton 66c6f12f37 fix(core): preserve tool UI metadata for edit, patch, and migrated rows
edit and patch never declared toMetadata, so the TUI diff renderer had nothing to draw for new calls, and the row migration dropped historical structured payloads (including subagent session joins). Both tools now emit { files }, migrated terminal states carry old structured objects as canonical metadata, and undecodable rows are skipped instead of failing the migration on every startup.
2026-07-22 22:46:37 -04:00
Kit Langton ef88f73e33 fix(core): batch canonical tool result migration
Materializing every assistant row at once was measured at ~4.9GB RSS and 105s on an 8.8GB production database. Keyset-paginated batches of 1000 inside the same transaction cut this to 843MB and 37s.
2026-07-22 22:46:37 -04:00
Kit Langton 2152a9f569 fix(tui,cli): consume canonical tool state 2026-07-22 22:46:37 -04:00
Kit Langton 3ecc29f3a2 test(core): migrate tool tests to canonical execution model 2026-07-22 22:46:37 -04:00
Kit Langton cc458a47a3 docs: describe canonical tool results in specs 2026-07-22 22:44:39 -04:00
Kit Langton f2d9cc06b6 feat(ai): keep tool definitions on final step with native none 2026-07-22 22:44:39 -04:00
Kit Langton 5ca4482228 fix(simulation): adapt wire protocol to canonical tool output 2026-07-22 22:44:39 -04:00
Kit Langton 5cdabaef16 feat(core): migrate projected tool rows to canonical shape 2026-07-22 22:44:39 -04:00
Kit Langton e54a252725 refactor(core): convert compact structured projections to metadata 2026-07-22 22:44:39 -04:00
Kit Langton 1ba762df28 refactor(core): rename interpret to Tool.execute 2026-07-22 22:44:39 -04:00
Kit Langton a4e13d578b feat(core): canonical tool execution model 2026-07-22 22:44:39 -04:00
Kit Langton 927d70952c test(core): add MCP tool result baselines 2026-07-22 22:44:39 -04:00
Kit Langton 91fd93acd3 docs(core): add canonical tool result plan 2026-07-22 22:44:38 -04:00
110 changed files with 3712 additions and 2047 deletions
+8
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. Tool declarations lose `structured`, `toStructuredOutput`, the `Structured` generic, and the exported `Tool.settle` interpreter; `toModelOutput` now receives the typed domain output and returns text or non-empty rich content, and the new optional `toMetadata` produces compact JSON UI 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 and hook payloads use the `metadata`/`content` vocabulary, and `execute.after` hooks receive the canonical status union while keeping `outputPaths`. A one-time migration rewrites existing projected tool rows and moves provider-hosted result payloads into provider-owned result state.
@@ -159,7 +159,7 @@ const AnthropicTool = Schema.Struct({
type AnthropicTool = Schema.Schema.Type<typeof AnthropicTool>
const AnthropicToolChoice = Schema.Union([
Schema.Struct({ type: Schema.Literals(["auto", "any"]) }),
Schema.Struct({ type: Schema.Literals(["auto", "any", "none"]) }),
Schema.Struct({ type: Schema.tag("tool"), name: Schema.String }),
])
@@ -297,7 +297,9 @@ const lowerTool = (breakpoints: Cache.Breakpoints, tool: ToolDefinition, inputSc
const lowerToolChoice = (toolChoice: NonNullable<LLMRequest["toolChoice"]>) =>
ProviderShared.matchToolChoice("Anthropic Messages", toolChoice, {
auto: () => ({ type: "auto" as const }),
none: () => undefined,
// Native "none" keeps tool definitions in the request, preserving the
// cached prompt prefix on the final Step.
none: () => ({ type: "none" as const }),
required: () => ({ type: "any" as const }),
tool: (name) => ({ type: "tool" as const, name }),
})
@@ -330,7 +332,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) {
@@ -551,7 +556,7 @@ const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (reques
// over-mark we keep their tool hints and shed the message-tail ones first.
const breakpoints = Cache.newBreakpoints(ANTHROPIC_BREAKPOINT_CAP)
const tools =
request.tools.length === 0 || request.toolChoice?.type === "none"
request.tools.length === 0
? undefined
: request.tools.map((tool) =>
lowerTool(
@@ -680,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 }),
})
}
@@ -244,7 +244,9 @@ const textWithCache = (
const lowerToolChoice = (toolChoice: NonNullable<LLMRequest["toolChoice"]>) =>
ProviderShared.matchToolChoice("Bedrock Converse", toolChoice, {
auto: () => ({ auto: {} }) as const,
none: () => undefined,
// Converse has no native "none". Keep the definitions with auto so the
// cached prompt prefix survives; the runner rejects violating calls locally.
none: () => ({ auto: {} }) as const,
required: () => ({ any: {} }) as const,
tool: (name) => ({ tool: { name } }) as const,
})
@@ -392,7 +394,7 @@ 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"
request.tools.length > 0
? { tools: lowerTools(request.model.compatibility?.toolSchema, breakpoints, request.tools), toolChoice }
: undefined
const system = request.system.length === 0 ? undefined : lowerSystem(breakpoints, request.system)
+3 -1
View File
@@ -313,7 +313,9 @@ const thinkingConfig = (request: LLMRequest) => {
}
const fromRequest = Effect.fn("Gemini.fromRequest")(function* (request: LLMRequest) {
const toolsEnabled = request.tools.length > 0 && request.toolChoice?.type !== "none"
// Tool definitions stay in the request even with toolChoice "none"
// (functionCallingConfig mode NONE), preserving the cached prompt prefix.
const toolsEnabled = request.tools.length > 0
const generation = request.generation
const toolSchemaCompatibility = request.model.compatibility?.toolSchema
const generationConfig = {
@@ -636,7 +636,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" })
+6 -2
View File
@@ -233,7 +233,7 @@ describe("Gemini route", () => {
}),
)
it.effect("omits tools when tool choice is none", () =>
it.effect("keeps tool definitions with native mode NONE when tool choice is none", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare(
LLM.request({
@@ -245,8 +245,12 @@ describe("Gemini route", () => {
}),
)
expect(prepared.body).toEqual({
// Definitions stay in the request so the cached prompt prefix survives
// the final Step; NONE forbids calling them.
expect(prepared.body).toMatchObject({
contents: [{ role: "user", parts: [{ text: "Say hello." }] }],
tools: [{ functionDeclarations: [expect.objectContaining({ name: "lookup" })] }],
toolConfig: { functionCallingConfig: { mode: "NONE" } },
})
}),
)
+9 -14
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,
}),
})
@@ -174,7 +172,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",
@@ -182,9 +180,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
@@ -198,7 +195,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,
+3 -3
View File
@@ -57,11 +57,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.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] : []
+13 -24
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
+25 -23
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: [],
}
}
+11 -18
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",
@@ -616,12 +611,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 },
},
},
{
@@ -632,8 +626,7 @@ function replayFixtureMessages(): SessionMessageInfo[] {
state: {
status: "running",
input: { command: "pwd" },
structured: {},
content: [{ type: "text", text: "/workspace" }],
metadata: {},
},
},
{
@@ -644,7 +637,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" },
},
@@ -677,7 +670,7 @@ function replayToolMessage(id: string) {
state: {
status: "completed",
input: { command: "printf done" },
structured: { exit: 0 },
metadata: { exit: 0 },
content: [{ type: "text", text: "done" }],
},
},
@@ -171,7 +171,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,
}),
@@ -264,7 +264,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,
}),
+33 -30
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" },
})
})
})
+6 -1
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([])
})
+11 -14
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("")
})
+24 -35
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
+6 -4
View File
@@ -230,7 +230,7 @@ export const inputTypeScript = <R>(definition: Definition<R>, pretty = false): s
export const outputTypeScript = <R>(definition: Definition<R>, pretty = false): string =>
definition.output === undefined
? "unknown"
? "void"
: isEffectSchema(definition.output)
? toTypeScript(definition.output, true, pretty)
: jsonSchemaToTypeScript(definition.output, pretty)
@@ -239,6 +239,8 @@ export const decodeInput = <R>(definition: Definition<R>, value: unknown): unkno
isEffectSchema(definition.input) ? Schema.decodeUnknownSync(definition.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
definition.output === undefined
? undefined
: isEffectSchema(definition.output)
? Schema.decodeUnknownSync(definition.output)(value)
: value
+2 -2
View File
@@ -40,7 +40,7 @@ export type Definition<R = never> = {
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. */
export type Options<I extends SchemaType, O extends SchemaType | undefined, R = never> = {
@@ -62,7 +62,7 @@ export const isDefinition = <R = never>(value: unknown): value is Definition<R>
* Defines 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>(
+5 -5
View File
@@ -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 }])
})
@@ -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"),
})
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()
})
})
+2
View File
@@ -18,6 +18,7 @@ const listIssues = Tool.make({
},
required: ["owner"],
},
output: {},
run: () => Effect.succeed("[]"),
})
@@ -417,6 +418,7 @@ describe("non-identifier tool paths", () => {
},
required: ["query", "libraryName"],
} as const,
output: {},
run: () => Effect.succeed("/reactjs/react.dev"),
})
const runtime = CodeMode.make({ tools: { context7: { "resolve-library-id": resolveLibrary } } })
+1
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[]
@@ -0,0 +1,178 @@
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. The
* corresponding durable terminal events are rewritten to remain replayable.
*/
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)
}
let eventCursor = ""
while (true) {
const events = yield* tx.all<{ id: string; type: string; data: string }>(
sql`SELECT id, type, data FROM event WHERE type IN ('session.tool.success.1', 'session.tool.failed.1') AND id > ${eventCursor} ORDER BY id LIMIT 1000`,
)
if (events.length === 0) break
eventCursor = events[events.length - 1].id
yield* rewriteEvents(tx, events)
}
})
},
} 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}`)
}
})
}
function rewriteEvents(
tx: Parameters<DatabaseMigration.Migration["up"]>[0],
events: { id: string; type: string; data: string }[],
) {
return Effect.gen(function* () {
for (const row of events) {
const decoded = decodeJson(row.data)
if (decoded._tag === "None") {
yield* Effect.logWarning("skipping undecodable tool event").pipe(Effect.annotateLogs({ id: row.id }))
continue
}
const data = object(decoded.value)
const preserved = contentOf(data)
const result = resultOf(data)
const resultState =
data.executed === true && result !== undefined
? { resultState: { ...object(data.resultState), result } }
: data.resultState === undefined
? {}
: { resultState: data.resultState }
const base = {
sessionID: data.sessionID,
assistantMessageID: data.assistantMessageID,
callID: data.callID,
executed: data.executed,
...resultState,
}
const next =
row.type === "session.tool.success.1"
? {
...base,
content: completedContent(data),
...metadataOf(data),
}
: {
...base,
error: data.error,
...(preserved.length > 0 ? { content: preserved } : {}),
...metadataOf(data),
}
const type = row.type === "session.tool.success.1" ? "session.tool.success.2" : "session.tool.failed.2"
yield* tx.run(sql`UPDATE event SET type = ${type}, data = ${JSON.stringify(next)} WHERE id = ${row.id}`)
}
})
}
+26 -12
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()),
},
@@ -395,25 +394,40 @@ 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 })
return Effect.sync(() => {
if (event.status === "completed" && decoded.value.status === "completed") {
event.content = decoded.value.content
event.metadata = decoded.value.metadata
event.outputPaths = decoded.value.outputPaths
return
}
if (event.status === "error" && decoded.value.status === "error") {
event.error = decoded.value.error
event.content = decoded.value.content
event.metadata = decoded.value.metadata
event.outputPaths = decoded.value.outputPaths
}
})
}),
)
})
},
+1 -1
View File
@@ -303,7 +303,7 @@ function wireEvent(value: unknown): unknown {
}
function fromPromiseTool(tool: AnyTool) {
if ("jsonSchema" in tool)
if ("output" in tool && tool.output !== undefined)
return Tool.make({
...tool,
execute: (input, context) =>
+6 -3
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]) => {
+8 -10
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 }),
}),
)
}
+24 -19
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 retains definitions (stable prompt prefix preserves provider
// caching) and sets toolChoice "none"; violating calls are 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,
}
})
+6 -19
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)),
)
@@ -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.ToolExecution,
) {
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
? {
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,
@@ -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]
})
@@ -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 }
+13 -23
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],
}
})
+1 -1
View File
@@ -4,7 +4,7 @@ This folder owns Core's one local tool representation, process and Location regi
## 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 })` declaration. 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.
+842
View File
@@ -0,0 +1,842 @@
# V2 Tool Result And Execution Simplification Plan
## Status
Implemented on this branch. This document is the historical plan kept for reviewer context; the authoritative semantic overview is [`specs/v2/tools.md`](../../../../specs/v2/tools.md), and where the two disagree the spec and code win. Delete this file at merge.
The plan's base was `origin/v2` at `b91dd78ab3`. Progress-event ephemerality already landed there (`5a9ed4d350`, `fix: make tool progress live-only`), including the failed-event partial snapshot this plan builds on. Delta-compressed progress is a later follow-up.
## Reader And Job
This plan is for the engineer changing the V2 Plugin, AI, Core, Schema, Protocol, Client, CLI, and TUI packages.
After reading it, that engineer can replace the current `output` / `structured` / `content` / `result` settlement graph with one canonical tool outcome without breaking request-scoped execution snapshots, Code Mode, MCP, provider-hosted tools, partial failure output, durable replay, or frontend rendering.
## Decision
V2 tools will have one typed execution output and three explicit consumers:
```text
execute
-> typed domain Output
|
+-> encode with output schema -> Code Mode value
+-> toModelOutput -> bounded model content
+-> toMetadata -> optional bounded UI metadata
```
The typed `Output` is execution-local. Durable history stores canonical model content and optional JSON metadata, never a generic copy of `Output`.
A successful durable tool has one model-visible representation:
```text
Completed = Input x NonEmptyContent x Metadata?
```
A failed durable tool has one error plus an optional final snapshot of partial observations:
```text
Failed = Input x Error x PartialContent? x Metadata?
```
Failure metadata is never produced by `toMetadata` (which requires a domain `Output` that a failed execution never produced). Its only producer is the last live progress snapshot: the publisher retains the latest progress in process memory and copies its content and metadata into the terminal failed event. This mechanism already landed in `5a9ed4d350` (`fix: make tool progress live-only`).
Provider call and result state remain separate because they preserve irreducible provider-native replay information, not another generic tool result.
## Why The Current Model Must Change
### One settlement has several authorities
The current path can represent one call with independently mutable values:
```ts
{
result: { type: "json", value: { ok: true } },
output: {
structured: { ok: false },
content: [{ type: "text", text: "failed" }],
},
error: {
type: "tool.execution",
message: "A third outcome",
},
}
```
The publisher, projector, model replay, frontend, and provider replay can select different authorities from that value.
### Local failures are represented repeatedly
A local `ToolFailure` currently becomes:
1. An error-valued `ToolResultValue`.
2. A separate `SessionError.Error`.
3. A durable failed event containing both.
4. A projected error state containing both plus the latest progress `content` and `structured` snapshot.
5. A newly constructed model error envelope during replay.
Local replay ignores the stored result and constructs another value from `error`, `content`, and `structured`. Provider-executed replay instead trusts the stored result.
### Default structured output leaks raw domain output
Typed tools copy their complete encoded output into `structured` unless they declare both `structured` and `toStructuredOutput`.
For example, grep produces:
```text
Output Match[]
model content formatted bounded text
frontend structured { value: complete Match[] }
```
The generic output store bounds model content but preserves `structured` unchanged. The complete match array therefore enters durable history and frontend state even though neither consumer requires it.
### Code Mode receives UI-shaped values
`Tool.definition` advertises `structured ?? output`, and Code Mode child execution returns `ToolOutput.structured`.
Shell therefore advertises and returns its compact structured projection:
```ts
{
exit?: number
shellID?: string
truncated: boolean
timeout?: boolean
}
```
instead of its declared output containing command output text. UI projection has accidentally become the Code Mode machine result.
### Terminal failure snapshots landed but the old fallback remains
Since `5a9ed4d350`, progress is ephemeral and the terminal failed event carries the last progress snapshot as optional `content` and `metadata`. That is the design this plan keeps.
But the projector still falls back to reaching into the previous running state when the event fields are absent, and the failed event still also carries `structured` vocabulary and an optional generic `result`. The fallback and the duplicate fields go; the projection becomes a direct copy of the event.
## Design Principles
### Keep one canonical representation
One semantic fact has one stored authority. Alternate provider, UI, and wire views are derived at their boundaries.
Do not store an inherent value and its derived representation as an unrestricted product. If two fields must agree, one is derived or deliberately cached and needs an explicit synchronization law.
### Separate domain values from boundary values
Tool-authored callbacks receive decoded domain values. Core encodes values when they cross into Code Mode, persistence, a provider, or a client.
```text
Domain callbacks receive domain values.
Transport infrastructure receives encoded values.
```
### Keep terminal events self-contained
Projecting durable history must be deterministic. A success or failure event contains every fact needed to reconstruct its terminal state without consulting ephemeral progress or process-local memory.
### Preserve independent facts
Canonical representation does not mean collapsing independent information.
A failure and partial output are independent facts: a tool can fail before producing output or after producing useful bounded output. Provider call state and provider result state are also independent.
### Add type machinery only for demonstrated failures
This plan tightens result, metadata, and lifecycle shapes because current code demonstrates contradictory states and duplicated durable data.
It does not restrict authored input schemas to JSON objects. Built-ins use object inputs and provider adapters may enforce protocol requirements at their boundaries, but no current failure justifies a more complex public input-schema generic.
## Vocabulary
| Term | Meaning |
| --------------- | ----------------------------------------------------------------------------------- |
| Domain `Output` | The typed value returned by `execute`. It exists only during execution. |
| Encoded output | The validated output-codec encoding returned to Code Mode. |
| Model content | The canonical non-empty text/media content sent to the model and stored durably. |
| Metadata | Optional bounded JSON object used for tool-specific UI and client behavior. |
| Partial content | Bounded content observed before a failure and retained by the terminal failure. |
| Progress | Live running-tool observation. Its durability is outside this plan. |
| Provider state | Opaque provider-owned call or result information required for native replay. |
| Tool set | One request-scoped snapshot pairing advertised definitions with captured executors. |
Do not use `settle` for tool execution. Use `execute` for the complete local operation and `completed` / `failed` for terminal outcomes.
## Target Tool Declaration
### Authored tools have one output
The public Effect declaration becomes conceptually:
```ts
type JsonValue = Schema.Json
type Metadata = Readonly<Record<string, JsonValue>>
type ModelOutput = string | NonEmptyReadonlyArray<Tool.Content>
interface Tool.Definition<InputSchema, OutputSchema> {
readonly description: string
readonly input: InputSchema
readonly output: OutputSchema
readonly permission?: string
readonly execute: (
input: Tool.InputValue<InputSchema>,
context: Tool.Context,
) => Effect.Effect<Tool.OutputValue<OutputSchema>, Tool.Failure>
readonly toModelOutput?: (input: {
readonly input: Tool.InputValue<InputSchema>
readonly output: Tool.OutputValue<OutputSchema>
}) => ModelOutput
readonly toMetadata?: (input: {
readonly input: Tool.InputValue<InputSchema>
readonly output: Tool.OutputValue<OutputSchema>
}) => Metadata
}
```
`structured`, `toStructuredOutput`, and the `Structured` generic are removed.
The Promise declaration wraps the same shape and changes only `Effect` to `Promise` at the execution boundary.
### Output schemas encode JSON
An authored tool's output schema is required. Its encoded side must be JSON because the value can cross into Code Mode and the default model projection.
`Schema.Void` is not a valid tool output. A side-effect-only tool returns an honest JSON value:
```ts
Tool.make({
input: Schema.Struct({}),
output: Schema.Null,
execute: () => Effect.succeed(null),
})
```
The SDK registration fixtures that currently use `Schema.Void` must change rather than weakening the tool contract.
### Projections receive domain values
Core validates the handler result by encoding it, but tool-authored projections receive the original domain value:
```ts
const output = yield * tool.execute(input, context)
const encoded = yield * encodeOutput(tool.output, output)
const projected = tool.toModelOutput?.({ input, output })
const metadata = tool.toMetadata?.({ input, output })
```
This keeps Effect codec mechanics out of author callbacks. For a codec that encodes `DateTime.Utc` as an ISO string, projections receive `DateTime.Utc`; Code Mode receives the ISO string.
### Model projection is convenient but canonicalized
Tool authors may return text directly:
```ts
toModelOutput: ({ output }) => `Found ${output.length} matches`
```
They may return rich content:
```ts
toModelOutput: ({ output }) => [
{ type: "text", text: "Generated image" },
{ type: "file", data: output.data, mime: output.mime },
]
```
Core normalizes both forms into one non-empty durable `ToolContent` array.
When `toModelOutput` is absent:
- An encoded string becomes one text content item.
- Any other encoded JSON value is serialized once and becomes one text content item.
Tools do not manually stringify rich content. Provider adapters decide how canonical content maps to provider wire formats.
### Metadata is optional and opt-in
When `toMetadata` is absent, metadata is absent. Core never copies or inspects `Output` to invent metadata.
Metadata uses one shared boundary:
```ts
const Metadata = Schema.Record(Schema.String, Schema.Json)
```
No per-tool metadata schema is introduced. Current consumers already inspect tool-specific fields defensively by tool name. A per-tool schema is justified only if OpenCode later exposes typed third-party renderer registration.
Examples of intentional metadata:
```text
shell { exit, shellID, truncated, timeout }
edit / patch { files }
subagent { sessionID, status }
Code Mode execute { toolCalls, error }
```
Grep and glob need no output metadata by default. Their input already describes the search, and their model content contains the bounded result.
### Invalid metadata cannot reverse side effects
TypeScript rejects non-JSON metadata for authored tools. Core still validates at runtime for JavaScript plugins, dynamic code, casts, cycles, and hook mutations.
If metadata is invalid or exceeds its configured ceiling:
1. Preserve the successful model content.
2. Drop metadata.
3. Log a warning with tool and call identity.
Do not fail a successful tool after side effects merely because optional UI metadata is malformed.
## Dynamic And MCP Tools
### Dynamic output schemas are optional
Authored native tools require output schemas. Dynamic external tools may omit them because the source protocol may not provide one.
Code Mode exposes a dynamic tool without an output schema as:
```ts
tools.server.tool(input): Promise<unknown>
```
Use `unknown`, not `any`. Runtime behavior is identical, but the model-visible signature must not claim properties that were never declared.
### MCP keeps its protocol distinction inside the adapter
MCP legitimately returns two values:
```text
content text/images for the model
structuredContent machine-readable output for Code Mode
```
The MCP adapter applies these rules:
```text
MCP isError
-> Tool.Failure
MCP success with outputSchema
-> require and validate structuredContent
-> Code Mode receives structuredContent
-> model receives bounded MCP content
MCP success without outputSchema
-> Code Mode receives structuredContent ?? joined text ?? null
-> Code Mode signature is unknown
-> model receives bounded MCP content
```
Missing or invalid `structuredContent` is a failure when the MCP server advertises an output schema. Falling back to text would violate the advertised Code Mode return contract.
The complete MCP protocol envelope is not exposed as the Code Mode result. MCP metadata remains adapter-owned unless an explicit compact UI projection requires it.
## Request-Scoped Tool Sets
### Snapshot definitions and executors together
The registry exposes one request-scoped tool set:
```ts
interface ToolRegistry {
readonly snapshot: (permissions?: PermissionV2.Ruleset) => Effect.Effect<ToolSet>
}
interface ToolSet {
readonly definitions: ReadonlyArray<LLM.ToolDefinition>
readonly execute: (input: ToolExecuteInput) => Effect.Effect<ToolExecution, ToolInfrastructureError>
}
```
`snapshot` replaces `materialize`. `ToolSet` replaces `Materialization`. `execute` replaces the nested `resolveToolCall` / `settleTool` / `settle` vocabulary.
The temporal guarantee remains unchanged: one model request executes exactly the tool values it advertised even if registration changes while the request is in flight.
Scoped overlays, permissions, Code Mode synthesis, and atomic batch registration remain private registry behavior.
### Request hooks return one restricted operation
`SessionModelRequest.prepare` applies request-time tool-definition transforms and returns:
```ts
interface PreparedRequest {
readonly request: LLM.Request
readonly executeTool: ToolSet["execute"]
}
```
The runner does not receive the raw tool set, removed-name information, or a resolver union. A call to an unknown or hook-removed tool produces the same unavailable failure for that call.
### Unavailable calls fail independently
Rejecting one unavailable call must publish one terminal error for that call ID. It must not call the whole-step `failUnsettledTools` path or fail unrelated concurrently executing calls.
Malformed unknown and hook-removed calls share the same continuation rule: continue when Step allowance remains; stop when it is exhausted.
### The final Step keeps definitions
Core prepares the same request-scoped tool set on the final Step, retains definitions, sets `toolChoice: "none"`, adds the max-Step prompt, and rejects any violating local call at runtime.
Tool definitions never leave the request, because removing them changes the prompt prefix and breaks provider prompt caching on the most token-heavy request of the session. Adapters lower `toolChoice: "none"` natively where the wire protocol supports it (OpenAI; Anthropic `{"type": "none"}`; Gemini function-calling mode `NONE`) — the Anthropic and Gemini adapters currently drop tools instead and must be fixed. Where the protocol has no "none" (Bedrock Converse), keep the definitions with `auto` and rely on the max-Step instruction plus local runtime rejection. Core does not structurally remove the tool set or create an optional execution snapshot.
## Internal Execution Outcome
The registry returns a discriminated union instead of parallel `result`, `output`, and `error` fields:
```ts
type ToolExecution =
| {
readonly status: "completed"
readonly output: Schema.Json
readonly content: NonEmptyReadonlyArray<ToolContent>
readonly metadata?: Metadata
readonly outputPaths?: ReadonlyArray<string>
}
| {
readonly status: "error"
readonly error: SessionError.Error
readonly content?: NonEmptyReadonlyArray<ToolContent>
readonly metadata?: Metadata
readonly outputPaths?: ReadonlyArray<string>
}
```
`output` is the validated encoded value for Code Mode and remains ephemeral. Durable publication drops it.
Expected `Tool.Failure` values become the error branch. Interruption, defects, and infrastructure failure remain in the Effect failure/cause channel and follow runner policy.
### Partial failure observations are explicit
The execution owner retains the latest bounded progress snapshot (content plus metadata) in process memory. If execution fails after producing progress, the terminal error branch snapshots those values once. This landed in `5a9ed4d350`: `failureSnapshot` in `publish-llm-event.ts` copies the retained progress into the failed event, and the projector prefers the event fields.
This preserves current TUI, ACP, noninteractive, and model behavior without retaining periodic progress durably.
In the target model, the progress channel's `structured` field is renamed `metadata` so one vocabulary pair holds everywhere: content for the model, metadata for the UI. Progress is ephemeral, so the rename touches no durable data.
A failure before progress has no content and no metadata. `Tool.Failure.metadata` is still removed because repository code does not consume it and it would create a second metadata authority; failure metadata comes only from the progress snapshot.
## Durable Events And Session State
### Success stores one model representation
The successful durable event and message state become:
```ts
interface ToolSucceeded {
readonly content: NonEmptyReadonlyArray<ToolContent>
readonly metadata?: Metadata
readonly executed: boolean
readonly resultState?: ProviderState
}
interface ToolStateCompleted {
readonly status: "completed"
readonly input: Readonly<Record<string, JsonValue>>
readonly content: NonEmptyReadonlyArray<ToolContent>
readonly metadata?: Metadata
}
```
There is no generic `structured` field and no optional generic `result` field.
### Failure stores one error and an optional final snapshot
The failed durable event and message state become:
```ts
interface ToolFailed {
readonly error: SessionError.Error
readonly content?: NonEmptyReadonlyArray<ToolContent>
readonly metadata?: Metadata
readonly executed: boolean
readonly resultState?: ProviderState
}
interface ToolStateError {
readonly status: "error"
readonly input: Readonly<Record<string, JsonValue>>
readonly error: SessionError.Error
readonly content?: NonEmptyReadonlyArray<ToolContent>
readonly metadata?: Metadata
}
```
The current failed event (post-`5a9ed4d350`) already carries optional `content` and `metadata`; the remaining change is deleting `structured` and `result` and typing `metadata` as JSON.
The terminal failed event carries the bounded partial snapshot required for deterministic reload. The projector copies the event; it does not reach backward into running progress state.
### Provider replay state remains orthogonal
Provider call and result state remain separately owned by the assistant tool envelope. They contain only provider-native information required for valid continuation syntax. Generic local success and failure do not retain a second provider-style result.
The two current adapters have known, different replay facts:
- OpenAI Responses replays a hosted tool as an `item_reference` built from the item ID already stored in `providerMetadata`. It needs no payload.
- Anthropic server tools (`web_search`, `code_execution`, `web_fetch`) must round-trip the complete structured result payload verbatim as a typed `*_tool_result` block on every subsequent stateless request. The payload includes encrypted fields that cannot be reconstructed from text.
Therefore the Anthropic adapter stores that payload in provider-owned `resultState` at write time, and hosted replay lowering reads `resultState` instead of a generic `result`. The new terminal events never carry a generic `result` field, and Anthropic hosted continuation works in the final state.
Retaining the complete payload is a concrete exception to the general preference against archiving provider output. It is permitted only because the provider protocol requires the value for continuation. Generic output bounding does not apply to `resultState`; bounding applies to the derived model content.
### Projection is a direct fold
The projector performs no result inference:
```text
ToolSucceeded -> ToolStateCompleted
ToolFailed -> ToolStateError
```
It does not infer JSON from empty content, convert `structured` to content, reconstruct an error result, or inherit ephemeral progress.
### Existing durable data migrates once
Tool events and projected message rows already exist in beta databases. The decided migration path is a one-time rewrite, not read-time normalization:
1. Bump the versions of the terminal tool events. Old-version rows fall out of `DurableEventManifest` and are silently skipped by `readAfter`; no event `DELETE` is required.
2. Add one TypeScript migration in `packages/core/src/database/migration/` that rewrites existing `session_message` tool rows into the new `ToolState` shape: `content` from old content, else one stringified text item from old `structured`/`result`; `error` preserved. For `executed: true` rows, copy the old generic `result` into provider-owned `resultState` so Anthropic hosted continuation survives the migration. This is required because `SessionHistory.load` hard-fails with `MessageDecodeError` on undecodable rows.
3. Build no read-time compatibility boundary. `SessionEvent.All` stays latest-version-only. Do not carry `structured` and `result` into new events for source compatibility.
Known accepted consequence: `EventV2.replay`/`replayAll` have no production call sites today (tests and future sync only), so skipped old event versions cannot break a production rebuild. A hypothetical future replay of pre-migration history would produce sessions missing tool results; that is acceptable beta data loss.
Extend `packages/core/test/database-migration.test.ts` with an old-fixture database containing pre-migration tool rows.
## Hooks
### Preserve output paths in this change
`execute.after` keeps `outputPaths`. Removing managed path visibility is not part of this plan.
The hook receives the canonical projected outcome, optional metadata, and output paths. It does not receive raw domain `Output` or the old `ToolOutput`:
```ts
type ToolExecuteAfterEvent = ToolHookBase &
(
| {
status: "completed"
content: NonEmptyReadonlyArray<ToolContent>
metadata?: Metadata
outputPaths?: ReadonlyArray<string>
}
| {
status: "error"
error: SessionError.Error
content?: NonEmptyReadonlyArray<ToolContent>
metadata?: Metadata
outputPaths?: ReadonlyArray<string>
}
)
```
Preserve current hook ordering for this implementation: hooks run after generic bounding and may inspect output paths. Reordering hooks, applying a second bound, and changing spill ownership are follow-up decisions unless implementation reveals a concrete correctness blocker.
## Output Bounding
### Producers and Core have separate responsibilities
Producers own capture semantics:
- Shell/process tools choose tail versus head, maintain backing files, and report producer truncation.
- Read and search tools page or limit their results.
- Other tools may impose smaller domain-specific limits.
Core owns one configurable final line-and-UTF-8-byte ceiling for durable model content. Producers may return less but may not bypass the final ceiling.
The ceiling's default cut keeps the existing head-plus-tail split with the omission marker in the middle: the head shows what the output is, the tail is where errors and summaries live. Producers may choose pure head or pure tail where their domain warrants it.
Do not introduce model-specific token counting.
### Spill is producer-specific
Generic Core bounding does not archive every omitted byte. Shell/process producers may retain complete output temporarily because it is expensive to reproduce. Read and search tools page or rerun. Provider-hosted omitted bytes are not hidden in a second archive solely for replay.
Keep existing output paths where a producer or current managed store intentionally creates them. Removing generic spill storage is separate follow-up work and must not be bundled into the canonical result migration without focused tests.
### Metadata never becomes an unbounded side channel
Metadata is measured independently from model content. Oversized metadata is dropped and logged; it never fails a successful side effect and never stores a hidden complete copy.
### Hosted tools are bounded at the common publisher seam
Provider-hosted tools bypass local execution. Their model-facing content must pass through the same final publisher bound before entering durable history.
Retain only provider identifiers or state required for valid native continuation syntax. Do not archive omitted hosted bytes when the provider can continue without them.
Before applying a generic text bound to a provider-native structured result, prove that continuation uses an identifier or define an adapter-specific schema-preserving bound. A bounded text preview cannot silently replace a structured payload that the provider requires on replay.
## Public API And Package Changes
### Plugin declarations only
The public Plugin package exports declaration construction and registration types. It no longer exports the execution interpreter as `Tool.settle`.
Core privately interprets registered declarations. SDK-next re-exports the same declaration API; it does not expose a second execution model.
### Remove overloaded settlement vocabulary
Remove or rename:
| Current | Target |
| -------------------------- | ------------------------------------------------ |
| `Tool.settle` | private Core execution |
| `ToolRegistry.materialize` | `ToolRegistry.snapshot` |
| `Materialization` | `ToolSet` |
| `Materialization.settle` | `ToolSet.execute` |
| `Settlement` | `ToolExecution` |
| `settleTool` | private execution body |
| `stepSettlement` | `stepCompletion` where still needed |
| `tool.settled` | `tool.completed` where still needed |
| `Progress.structured` | `Progress.metadata` (ephemeral; no durable data) |
Do not introduce both `invoke` and `execute`. `execute` is the one operation verb.
### Add a changeset
The Plugin package is public. Removing `structured`, `toStructuredOutput`, `Tool.settle`, and the old hook shape is user-facing even if the API is labeled V2.
Hand-write a `.changeset/*.md` entry mimicking the existing hand-authored entries (no changesets tooling is installed in this repo) describing the declaration and hook migration. Do not silently retain obsolete overloads unless a shipped compatibility requirement is confirmed.
## Implementation Work Order
This is one implementation pass to the final state on one branch, not a sequence of independently green commits. No transitional compatibility machinery is built: the old and new tool APIs never coexist, and no temporary adapter feeds old fields from new values. Intermediate commits may be red; the work order below exists only because later steps consume types and values created by earlier ones.
Two exceptions to "just get to the final state":
1. Step 1's baselines are written first, on the unmodified base, because tests pinning current observable behavior are the only proof the rewrite preserved it. Baselines written afterward would merely pin whatever the rewrite happens to do.
2. The completed branch is verified as one unit: package typechecks and tests, regenerated Protocol/Client surfaces, the regenerated legacy JS SDK, and the migration fixture.
### 1. Establish regression baselines
Most invariants are already tested and need only mechanical updates during the rewrite: snapshot capture (`session-runner-tool-registry.test.ts` "executes the tool advertised in a model request", "reveals the previous registration after an overlay closes"), parallel tools (`session-runner.test.ts` "Run parallel tools"), final-Step `toolChoice: "none"` (four assertions across session-runner and session-generate tests), `Promise<unknown>` for schema-less Code Mode tools (`codemode.test.ts`), hosted-tool lowering (`packages/ai` provider tests), shell failure output at the tool level (`tool-shell.test.ts` "keeps non-zero exits useful", "returns a useful timeout settlement"), and the failure partial snapshot (`5a9ed4d350` added "persists the latest partial snapshot when a tool fails", "interrupted progress publication remains in the terminal failure snapshot", and "failure before progress omits partial output fields").
The Anthropic hosted-tool round trip is covered by two composing tests: `session-runner.test.ts` persists a hosted call/result and asserts the continuation request carries the full payload, and `anthropic-messages.test.ts` "round-trips provider-executed assistant content into server tool blocks" lowers that message shape into the typed wire block. Both must keep passing (with mechanical field updates) after `result` moves to `resultState`.
Write only the two missing baselines, on the unmodified base:
- MCP `isError: true` becomes one failed tool call (current MCP tests only exercise `isError: false`).
- MCP mixed text-plus-media content reaches the model intact.
Assert through public surfaces so the tests survive the rewrite. Target-state behaviors (grep/glob raw arrays disappearing, per-call unavailable failure, runtime final-Step rejection) are verified in the matrix afterward, not baselined.
Do not rewrite behavior yet.
### 2. Introduce canonical content and metadata types
Add shared types for:
- Non-empty tool content.
- JSON metadata.
- Completed and failed terminal outcomes.
Constrain content and metadata schemas at their actual persistence and protocol boundaries. No adapters to the old representation: consumers move to these types directly in the later steps.
### 3. Change the authored Tool API
Update Effect and Promise declarations:
- Delete `Structured`.
- Delete `structured`.
- Delete `toStructuredOutput`.
- Add `toMetadata`.
- Change `toModelOutput` to accept domain `Output` and return text or non-empty rich content.
- Require JSON-encoded native outputs.
- Remove public `Tool.settle`.
- Update SDK-next exports and registration fixtures.
Migrate all built-ins to the new declaration in the same pass. Add metadata only when a current consumer requires it.
### 4. Replace registry settlement with ToolExecution
Introduce `ToolRegistry.snapshot` and `ToolSet.execute`. Move schema interpretation into private Core code.
Return the completed/error union. Capture bounded partial progress for terminal failures. Preserve registration atomicity, overlays, permissions, and Code Mode capture.
### 5. Give Code Mode encoded Output
Change Code Mode child execution to return `ToolExecution.output`, the validated encoded value — not metadata or model content.
Verify shell, read, grep, glob, edit, patch, subagent, and Code Mode execute signatures. Dynamic tools without output schemas remain `Promise<unknown>`.
### 6. Adapt MCP and dynamic tools
Implement strict MCP output-schema validation and the agreed fallback behavior. Keep the protocol split private to the MCP adapter.
Test text, image, mixed content, structured content, missing structured content, invalid structured content, remote failure, and absent output schema.
### 7. Version terminal events and migrate projected state
Add canonical success/failure event versions. Change terminal Session message states. Add the one-time TypeScript row migration and its old-fixture test described in "Existing durable data migrates once". No read-time normalization layer.
In the same step, move Anthropic hosted-tool payloads to provider-owned `resultState`: the adapter writes the payload there, hosted replay lowering reads it, and the row migration copies old `executed: true` results into it. OpenAI continues replaying from `providerMetadata` item IDs unchanged. No new event shape ever carries a generic `result`.
New SQL queries against the event table must use the new versioned-type constants; old-version rows are intentionally invisible to them.
Regenerate public Protocol and Client surfaces after changing the assembled `HttpApi`, and regenerate the legacy JS SDK (`./packages/sdk/js/script/build.ts`) because it merges the V2 OpenAPI document. Do not edit generated sources directly.
### 8. Simplify publication and replay
Delete local result reconstruction and duplicate provider/local branches where canonical state makes them unnecessary.
The publisher emits one terminal event. The projector copies it. Model replay derives provider wire output from canonical content or error plus provider-owned state. With Anthropic payloads already living in `resultState` after step 7, deleting the generic `result` reconstruction paths is pure removal.
### 9. Simplify request dispatch
Make `SessionModelRequest.prepare` return one request-specific execution operation. Delete `resolveToolCall` and removed-name continuation distinctions.
Fix per-call rejection so one unavailable call cannot fail unrelated fibers.
### 10. Stabilize final-Step behavior
Always snapshot tools. Retain definitions, set `toolChoice: "none"`, add the max-Step instruction, and reject violating execution locally. Fix the Anthropic and Gemini adapters to lower "none" natively instead of dropping tool definitions; keep Bedrock Converse definitions with `auto` since its protocol has no "none". Definitions stay in the request to preserve prompt caching.
### 11. Migrate hooks
Replace the old `ToolOutput` hook payload with the canonical outcome while preserving `outputPaths` and current ordering. Update Promise plugin bridges and focused hook tests.
### 12. Consolidate bounding
Apply the final Core ceiling to local and hosted model content. Validate and bound metadata independently. Preserve producer-specific truncation/spill behavior.
Do not implement progress deltas in this step.
### 13. Delete obsolete machinery and update documentation
Delete unused settlement conversions, types, branches, and compatibility adapters after all callers use the canonical model.
Update `specs/v2/tools.md` to describe implemented behavior, update the V2 schema changelog, add the Changesets entry, and remove this plan when its remaining work is fully represented by current specifications and tracked issues.
## Verification Matrix
### Plugin
- Effect Schema and Standard Schema input/output inference.
- Domain output passed to projections.
- Encoded JSON returned to Code Mode.
- Text and rich-content normalization.
- Metadata compile-time type safety and runtime validation.
- Promise wrapper parity.
- Public execution interpreter no longer exported.
Run from `packages/plugin` and `packages/sdk-next`.
### Code Mode
- Declared outputs produce accurate signatures.
- Missing dynamic output schemas produce `unknown`, never `any`.
- Native execution returns encoded output.
- Invalid encoded output fails before Code Mode observes it.
- Shell output text is available to Code Mode.
Run from `packages/codemode` and `packages/core`.
### MCP
- `isError` becomes one tool failure.
- Model content preserves text and images.
- Declared structured output is required and validated.
- Missing dynamic output schema retains unknown fallback behavior.
- Complete MCP envelopes do not leak into generic Code Mode output or metadata.
Run from `packages/core`.
### Registry And Runner
- Definitions and executors come from one captured snapshot.
- Registration changes affect only later requests.
- Different Sessions execute concurrently.
- Same-Session coordination remains serialized by the existing coordinator.
- Local tools execute in parallel within a Step.
- Durable publication remains serialized.
- Provider-hosted tools never invoke local handlers.
- Unknown and hook-removed calls fail independently.
- Final-Step violations fail only their call.
Run focused tests from `packages/core`.
### Persistence And Replay
- New success state has required non-empty content and optional metadata.
- New failure state has one error plus an optional partial content/metadata snapshot sourced from the last live progress.
- Progress is not required to rebuild terminal state.
- The row migration rewrites an old-fixture database deterministically; old event versions are skipped on read.
- Local model replay uses canonical content/error.
- Provider replay uses canonical outcome plus irreducible provider state.
- OpenAI hosted replay preserves item-reference behavior.
- Anthropic server-tool replay preserves the native structured result block.
- Projection followed by reload preserves the same terminal outcome.
Run focused Schema and Core tests from their package directories.
### Frontends
- TUI renders successful text/media and tool-specific metadata.
- TUI preserves partial output before a terminal error during live streaming and hydration.
- ACP reports canonical content and metadata without raw `structured` output.
- Noninteractive CLI renders partial failure content followed by the error.
- Unknown plugin tools retain a generic content fallback.
- Grep and glob no longer send complete raw arrays as UI metadata.
Run from `packages/tui` and `packages/cli`.
### Generated Surfaces
After public Protocol or Server `HttpApi` changes:
```sh
cd packages/client
bun run generate
```
Run package-local type checks with `bun typecheck`. Do not run tests from the repository root.
## Laws
- **One domain output:** `execute` produces exactly one typed `Output`.
- **Validated machine output:** Code Mode observes exactly the output codec's validated encoding.
- **Domain projection:** `toModelOutput` and `toMetadata` observe decoded input and domain output.
- **Canonical success:** a completed tool has exactly one non-empty model-content representation.
- **Canonical failure:** a failed tool has exactly one error representation.
- **Independent partial output:** failed content, when present, means bounded output observed before failure.
- **Metadata opt-in:** absent `toMetadata` produces absent metadata, never copied output.
- **JSON metadata:** persisted metadata is a bounded JSON object.
- **Deterministic projection:** terminal state is a pure fold of durable terminal events and does not depend on ephemeral progress.
- **Captured execution:** a call executes the exact tool value advertised in its request.
- **Per-call rejection:** rejecting one call cannot fail another call.
- **Provider-state separation:** provider-native replay state is not a second generic tool outcome.
- **No hidden archive:** omitted hosted or generic bounded bytes are not retained under another field unless they are irreducible provider continuation state.
## Non-Goals
- Designing progress-event ephemerality; that work is already in progress separately.
- Designing generic metadata or content delta schemas.
- Removing `outputPaths` from `execute.after`.
- Reordering `execute.after` or adding a second bounding pass without a concrete blocker.
- Restricting authored input schemas beyond demonstrated provider-boundary requirements.
- Redesigning execution locus, assistant lifecycle, usage totals, `ToolChoice`, or shell lifecycle; those findings are tracked separately.
- Adding typed third-party renderer registration.
- Implementing clustered Session execution ownership.
- Archiving complete provider-hosted output solely for exact replay.
## Follow-Up Tracking
- `e54e9273`: resume the V2 tool architecture design session.
- `989201eb`: delta-compress tool progress after ephemeral progress lands.
- `f3f4cfc9`: audit unrelated V2 canonical state representations.
## Fixed-Point Check
The target is ready to implement when these statements remain true under examples for shell, grep, read, edit, subagent, Code Mode, MCP, and provider-hosted tools:
- Removing any target field breaks a demonstrated consumer or guarantee.
- Adding another generic result field creates no new legitimate observation.
- Every stored fact has one canonical representation.
- Every derived representation is created at a named boundary.
- Success, failure, partial output, metadata, and provider state remain independently expressible where the domain permits them.
- Request snapshots preserve definition/executor identity.
- Old durable data has one explicit one-time migration path.
- Remaining complexity buys a demonstrated behavior rather than compatibility with accidental current structure.
+7 -4
View File
@@ -103,9 +103,6 @@ export const Plugin = {
"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,7 +204,13 @@ export const Plugin = {
],
replacements,
} satisfies Output
})
}).pipe(
Effect.map((output) => ({
output,
content: toModelOutput(output, input.oldString, input.newString),
metadata: { files: output.files },
})),
)
},
}),
"edit",
+35 -41
View File
@@ -1,9 +1,9 @@
export * as ExecuteTool from "./execute"
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 { definition, execute, make, type AnyTool, type Content, type Metadata } from "./tool"
const ExecuteFile = Schema.Struct({
data: Schema.String,
@@ -14,16 +14,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),
@@ -55,20 +50,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 +66,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 +103,23 @@ 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 = { output, toolCalls, files: collected, ...(result.ok ? {} : { error: true as const }) }
return {
output: value,
content: [
{ type: "text" as const, text: value.output },
...value.files.map((file) => ({
type: "file" as const,
data: file.data,
mime: file.mime,
...(file.name === undefined ? {} : { name: file.name }),
})),
] as [Content, ...Content[]],
metadata: {
toolCalls: value.toolCalls,
...(value.error ? { error: true as const } : {}),
} satisfies Metadata,
}
}),
})
}
@@ -154,11 +147,12 @@ function runtime(
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 +174,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 []
+8 -14
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
+11 -17
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
+4 -23
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: {
+4 -4
View File
@@ -39,13 +39,13 @@ export const layer = Layer.effectDiscard(
group.tools[tool.name] = Tool.withPermission(
Tool.make({
description: tool.description ?? "",
jsonSchema: {
input: {
...schema,
type: "object",
properties: schema.properties ?? {},
additionalProperties: false,
},
outputSchema: tool.outputSchema as JsonSchema.JsonSchema | undefined,
output: (tool.outputSchema ?? {}) as JsonSchema.JsonSchema,
execute: (input, context) =>
Effect.gen(function* () {
yield* permission.assert({
@@ -89,8 +89,8 @@ export const layer = Layer.effectDiscard(
)
const text = content.flatMap((part) => (part.type === "text" ? [part.text] : [])).join("\n")
return {
structured: result.structured ?? (text === "" ? null : text),
content,
output: result.structured ?? (text === "" ? null : text),
...(content.length === 0 ? {} : { content: content as [Tool.Content, ...Tool.Content[]] }),
}
}).pipe(
Effect.mapError((error) =>
+8 -2
View File
@@ -80,7 +80,6 @@ export const Plugin = {
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,7 +277,14 @@ 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",
+6 -4
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 },
})
}),
),
+14 -14
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 ||
+151 -109
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,15 +10,7 @@ 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, definition, nonEmpty, permission, registrationEntries, validateNamespace } from "./tool"
import { Tools } from "./tools"
import { ToolHooks } from "./hooks"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
@@ -33,38 +25,54 @@ 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.AnyTool>>,
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.AnyTool>>
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
* executors. 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<ToolExecution, 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 ToolExecution =
| {
readonly status: "completed"
readonly output?: unknown
readonly content: Tool.NonEmptyContent
readonly metadata?: Tool.Metadata
readonly outputPaths?: ReadonlyArray<string>
}
| {
readonly status: "error"
readonly error: SessionError.Error
readonly content?: Tool.NonEmptyContent
readonly metadata?: Tool.Metadata
readonly outputPaths?: ReadonlyArray<string>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/ToolRegistry") {}
@@ -76,26 +84,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 +114,28 @@ const registryLayer = Layer.effect(
...note("size", "could not be resized below the image size limit."),
]
})
// 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 = {
readonly tool: AnyTool
readonly tool: Tool.AnyTool
readonly name: string
readonly namespace?: string
}
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.AnyTool) {
// 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 +145,97 @@ 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 executed = 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 execution: ToolExecution = yield* Effect.gen(function* () {
if ("failure" in executed) return { status: "error" as const, error: executed.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(executed.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, executed.value.metadata)
return {
status: "completed" as const,
...(executed.value.output === undefined ? {} : { output: executed.value.output }),
content: nonEmpty(bounded.content) ?? executed.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 =
execution.status === "completed"
? {
...base,
status: "completed",
content: execution.content,
...(execution.metadata === undefined ? {} : { metadata: execution.metadata }),
...(execution.outputPaths === undefined ? {} : { outputPaths: execution.outputPaths }),
}
: {
...base,
status: "error",
error: execution.error,
...(execution.content === undefined ? {} : { content: execution.content }),
...(execution.metadata === undefined ? {} : { metadata: execution.metadata }),
...(execution.outputPaths === undefined ? {} : { outputPaths: execution.outputPaths }),
}
yield* toolHooks.runAfter(afterEvent)
const afterMetadata = yield* validMetadata(input.call.name, afterEvent.metadata)
const afterContent = yield* Effect.gen(function* () {
if (afterEvent.content === undefined || (execution.status === "completed" && afterEvent.content === execution.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,
...(execution.status === "completed" && execution.output !== undefined ? { output: execution.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 }),
}
})
@@ -210,7 +249,10 @@ const registryLayer = Layer.effect(
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 }
}),
@@ -269,7 +311,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>()
@@ -280,18 +322,18 @@ const registryLayer = Layer.effect(
if (whollyDisabled(permission(registration.tool, name), 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)] : []),
...(codemodeTool ? [definition("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<ToolExecution>({
status: "error",
error: { type: "tool.unknown", message: `Unknown tool: ${input.call.name}` },
})
},
+20 -42
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 }),
),
+7 -9
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 },
),
+8 -10
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 },
),
+89 -1
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 AnyTool,
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 Executed = {
readonly output?: unknown
readonly content: NonEmptyContent
readonly metadata?: Metadata
}
export const execute = (tool: AnyTool, input: unknown, context: Context): Effect.Effect<Executed, Failure> =>
Effect.gen(function* () {
const decoded = yield* decodeInput(tool.input, input)
const result = yield* tool.execute(decoded, context)
if (!("output" in tool) || 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
}
+2 -8
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 },
+2 -8
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 }),
+1 -1
View File
@@ -59,7 +59,6 @@ export const Plugin = {
"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,6 +85,7 @@ 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 })),
),
}),
+1 -1
View File
@@ -14,7 +14,7 @@ describe("CodeMode", () => {
description: "Echo text",
input: Schema.Struct({ text: Schema.String }),
output: Schema.String,
execute: ({ text }) => Effect.succeed(text),
execute: ({ text }) => Effect.succeed({ output: text }),
}),
})
@@ -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,207 @@ 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.2")
expect(JSON.parse(event!.data)).toEqual({
sessionID: "ses_test",
assistantMessageID: "msg_tools",
callID: "call_hosted",
executed: true,
resultState: { result: [{ url: "https://example.com" }] },
content: [{ type: "text", text: JSON.stringify([{ url: "https://example.com" }], null, 2) }],
})
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.2")
expect(JSON.parse(failedEvent!.data)).toEqual({
sessionID: "ses_test",
assistantMessageID: "msg_tools",
callID: "call_failed",
executed: false,
error: { type: "tool.execution", message: "timed out" },
metadata: { truncated: 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* () {
+3 -6
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
@@ -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)))
+84 -10
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,
})
+36 -17
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: [] })
}),
)
})
+11 -7
View File
@@ -287,8 +287,8 @@ describe("fromPromise", () => {
input: Schema.Struct({ name: Schema.String }),
output: Schema.String,
execute: async ({ name }, context) => {
await context.progress({ structured: { phase: "greeting" } })
return `Hello, ${name}!`
await context.progress({ phase: "greeting" })
return { output: `Hello, ${name}!` }
},
})
})
@@ -297,18 +297,22 @@ describe("fromPromise", () => {
yield* PluginPromise.fromPromise(promisePlugin).effect(host)
const materialized = yield* registry.materialize()
expect(materialized.definitions).toContainEqual(expect.objectContaining({ name: "hello", description: "Hello" }))
const toolSet = yield* registry.snapshot()
expect(toolSet.definitions).toContainEqual(expect.objectContaining({ name: "hello", description: "Hello" }))
expect(
yield* materialized.settle({
yield* toolSet.execute({
sessionID: SessionV2.ID.make("ses_promise_tool"),
agent: AgentV2.ID.make("build"),
messageID: SessionMessage.ID.make("msg_promise_tool"),
progress: (update) => Effect.sync(() => progress.push(update)),
call: { type: "tool-call", id: "call_promise_tool", name: "hello", input: { name: "world" } },
}),
).toMatchObject({ result: { type: "text", value: "Hello, world!" } })
expect(progress).toEqual([{ structured: { phase: "greeting" }, content: [] }])
).toMatchObject({
status: "completed",
output: "Hello, world!",
content: [{ type: "text", text: "Hello, world!" }],
})
expect(progress).toEqual([{ phase: "greeting" }])
}),
)
})
+2 -2
View File
@@ -95,10 +95,10 @@ const references = Layer.mock(ReferenceInstructions.Service, { load: () => Effec
const mcp = Layer.mock(McpInstructions.Service, { load: () => Effect.succeed(Instructions.empty) })
const plugins = Layer.mock(PluginSupervisor.Service, { flush: Effect.void })
const tools = Layer.mock(ToolRegistry.Service, {
materialize: () =>
snapshot: () =>
Effect.succeed({
definitions: [ToolDefinition.make({ name: "lookup", description: "Lookup", inputSchema: { type: "object" } })],
settle: () => Effect.die(new Error("unused")),
execute: () => Effect.die(new Error("unused")),
}),
register: () => Effect.die(new Error("unused")),
registerBatch: () => Effect.die(new Error("unused")),
@@ -34,7 +34,7 @@ import { ToolRegistry } from "@opencode-ai/core/tool/registry"
import { tempLocationLayer } from "./fixture/location"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { testEffect } from "./lib/effect"
import { registerToolPlugin, settleTool } from "./lib/tool"
import { executeTool, registerToolPlugin } from "./lib/tool"
const readToolNode = makeLocationNode({
name: "test/read-tool-plugin",
@@ -163,7 +163,7 @@ describe("SessionInstructions", () => {
// A read deep under sub/ discovers deep and sub AGENTS.md, walking up to but
// excluding the Location root (already supplied by core initial instructions).
yield* settleTool(registry, readCall(sessionID, "call-deep", "sub/deep/file.txt"))
yield* executeTool(registry, readCall(sessionID, "call-deep", "sub/deep/file.txt"))
const firstInjected = yield* synthetics(sessionID)
expect(firstInjected).toHaveLength(1)
@@ -179,7 +179,7 @@ describe("SessionInstructions", () => {
// A sibling read under sub/other discovers only the new AGENTS.md; sub is already
// injected for this session so it is not re-emitted, and the root is still excluded.
yield* settleTool(registry, readCall(sessionID, "call-other", "sub/other/file2.txt"))
yield* executeTool(registry, readCall(sessionID, "call-other", "sub/other/file2.txt"))
const secondInjected = yield* synthetics(sessionID)
expect(secondInjected).toHaveLength(2)
@@ -210,7 +210,7 @@ describe("SessionInstructions", () => {
yield* seedSynthetic(sessionID, [subPath])
expect(yield* synthetics(sessionID)).toHaveLength(1)
yield* settleTool(registry, readCall(sessionID, "call-sub", "sub/file.txt"))
yield* executeTool(registry, readCall(sessionID, "call-sub", "sub/file.txt"))
// The durable claim on the prior synthetic prevents re-injection; no new synthetic.
expect(yield* synthetics(sessionID)).toHaveLength(1)
@@ -236,7 +236,7 @@ describe("SessionInstructions", () => {
// Listing packages/foo/ discovers its own AGENTS.md, walking up to but excluding
// the Location root (already supplied by core initial instructions).
yield* settleTool(registry, readCall(sessionID, "call-list", "packages/foo"))
yield* executeTool(registry, readCall(sessionID, "call-list", "packages/foo"))
const firstInjected = yield* synthetics(sessionID)
expect(firstInjected).toHaveLength(1)
@@ -247,7 +247,7 @@ describe("SessionInstructions", () => {
// A subsequent file read under the listed directory is a dedup: pkg's AGENTS.md is
// already injected for this session, so nothing new is emitted.
yield* settleTool(registry, readCall(sessionID, "call-file", "packages/foo/file.txt"))
yield* executeTool(registry, readCall(sessionID, "call-file", "packages/foo/file.txt"))
expect(yield* synthetics(sessionID)).toHaveLength(1)
}),
@@ -269,7 +269,7 @@ describe("SessionInstructions", () => {
// The walk starts and stops at the Location root: the root AGENTS.md is searched but
// dropped by the dirname filter, and up() only walks upward so nested dirs are unseen.
yield* settleTool(registry, readCall(sessionID, "call-root-list", "."))
yield* executeTool(registry, readCall(sessionID, "call-root-list", "."))
expect(yield* synthetics(sessionID)).toHaveLength(0)
}),
@@ -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,
@@ -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 () => {
@@ -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: [] },
),
)
},
@@ -68,8 +69,7 @@ const make = (permission?: string) => {
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
}
@@ -79,8 +79,7 @@ const constant = (text: string) =>
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 +90,7 @@ describe("ToolRegistry", () => {
expect(error).toBeInstanceOf(Tool.RegistrationError)
expect(error.message).toBe('Invalid tool namespace: "slack..admin"')
expect((yield* service.materialize()).definitions).toEqual([])
expect((yield* service.snapshot()).definitions).toEqual([])
}),
)
@@ -106,19 +105,22 @@ describe("ToolRegistry", () => {
.pipe(Effect.flip)
expect(error).toBeInstanceOf(Tool.RegistrationError)
expect((yield* service.materialize()).definitions).toEqual([])
expect((yield* service.snapshot()).definitions).toEqual([])
}),
)
it.effect("filters disabled tools with edit aliases and ordered wildcard precedence", () =>
Effect.gen(function* () {
const service = yield* ToolRegistry.Service
yield* service.register({
question: make(),
bash: make(),
edit: make("edit"),
write: make("edit"),
}, { codemode: false })
yield* service.register(
{
question: make(),
bash: make(),
edit: make("edit"),
write: make("edit"),
},
{ codemode: false },
)
const names = (permissions: PermissionV2.Ruleset) =>
toolDefinitions(service, permissions).pipe(Effect.map((definitions) => definitions.map((tool) => tool.name)))
@@ -191,41 +193,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 +245,12 @@ describe("ToolRegistry", () => {
}),
)
it.effect("propagates retention failures through settlement", () =>
it.effect("propagates retention failures through execution", () =>
Effect.gen(function* () {
const service = yield* ToolRegistry.Service
yield* service.register({ echo: make() }, { codemode: false })
const materialized = yield* service.materialize()
const exit = yield* materialized.settle(call("echo", "call-retention-failure")).pipe(Effect.exit)
const toolSet = yield* service.snapshot()
const exit = yield* toolSet.execute(call("echo", "call-retention-failure")).pipe(Effect.exit)
expect(Exit.isFailure(exit)).toBe(true)
if (Exit.isFailure(exit)) expect(Option.getOrUndefined(Cause.findErrorOption(exit.cause))).toBe(retentionFailure)
@@ -250,13 +258,13 @@ describe("ToolRegistry", () => {
}),
)
it.effect("exposes settlement only through materialization", () =>
it.effect("exposes execution only through a snapshot", () =>
Effect.gen(function* () {
const service = yield* ToolRegistry.Service
expect("definitions" in service).toBe(false)
expect("execute" in service).toBe(false)
expect("settle" in service).toBe(false)
expect(typeof service.materialize).toBe("function")
expect(typeof service.snapshot).toBe("function")
}),
)
@@ -264,65 +272,73 @@ describe("ToolRegistry", () => {
Effect.gen(function* () {
const service = yield* ToolRegistry.Service
const contexts: Tool.Context[] = []
yield* service.register({
context: Tool.make({
description: "Context",
input: Schema.Struct({}),
output: Schema.Struct({ ok: Schema.Boolean }),
execute: (_, context) => Effect.sync(() => contexts.push(context)).pipe(Effect.as({ ok: true })),
}),
}, { codemode: false })
yield* service.register(
{
context: Tool.make({
description: "Context",
input: Schema.Struct({}),
output: Schema.Struct({ ok: Schema.Boolean }),
execute: (_, context) => Effect.sync(() => contexts.push(context)).pipe(Effect.as({ 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 +347,30 @@ 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 +384,33 @@ 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 +418,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 +464,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 +480,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 +497,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 +512,12 @@ 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 +527,7 @@ describe("ToolRegistry", () => {
},
})
expect(settlement.result).toMatchObject({ type: "text" })
expect(execution).toMatchObject({ status: "completed", content: [{ type: "text" }] })
expect(executed).toEqual(["old:request"])
}),
)
+214 -166
View File
@@ -238,43 +238,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 +381,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 +402,7 @@ const runnerLayer = AppNodeBuilder.build(SessionRunnerLLM.node, [
[PermissionV2.node, permission],
[Config.node, config],
[McpInstructions.node, mcpInstructions],
[ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig],
[ToolOutputStore.node, toolOutputStore],
[PluginSupervisor.node, pluginSupervisor],
])
const execution = Layer.effect(
@@ -449,7 +460,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 +597,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 +838,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 +866,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 +904,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 +913,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 +921,25 @@ describe("SessionRunnerLLM", () => {
}),
)
it.effect("persists the latest partial snapshot when a tool fails", () =>
it.effect("persists the latest progress metadata when a tool fails", () =>
Effect.gen(function* () {
const session = yield* setup
const registry = yield* ToolRegistry.Service
yield* registry.register({
failing_progress: Tool.make({
description: "Report progress and fail",
input: Schema.Struct({}),
output: Schema.Struct({}),
execute: (_, context) =>
Effect.gen(function* () {
yield* context.progress({
structured: { phase: "running" },
content: [{ type: "text", text: "before failure" }],
})
return yield* new ToolFailure({ message: "failed after progress" })
}),
}),
}, { codemode: false })
yield* registry.register(
{
failing_progress: Tool.make({
description: "Report progress and fail",
input: Schema.Struct({}),
output: Schema.Struct({}),
execute: (_, context) =>
Effect.gen(function* () {
yield* context.progress({ 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 +955,7 @@ describe("SessionRunnerLLM", () => {
id: "call-failing-progress",
state: {
status: "error",
structured: { phase: "running" },
content: [{ type: "text", text: "before failure" }],
metadata: { phase: "running" },
error: { message: "failed after progress" },
},
},
@@ -946,14 +973,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 +1004,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 +1030,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 +2416,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 +2455,6 @@ describe("SessionRunnerLLM", () => {
state: {
status: "completed",
input: { text: "hello" },
structured: { text: "hello" },
content: [{ type: "text", text: "hello" }],
},
},
@@ -2429,7 +2466,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 +2618,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 +2705,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 +2715,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 +2731,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 +2741,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 +3434,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 +3444,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 +3482,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 +3522,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 +3579,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 +3633,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 +3697,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 +3709,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 +3753,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 +3854,8 @@ describe("SessionRunnerLLM", () => {
expect(requests).toHaveLength(2)
expect(requests[0]?.toolChoice).toBeUndefined()
expect(requests[1]?.toolChoice).toMatchObject({ type: "none" })
expect(requests[1]?.tools).toEqual([])
// The final step keeps tool definitions to preserve provider prompt caching.
expect(requests[1]?.tools.map((tool) => tool.name)).toContain("echo")
expect(requests[1]?.messages.at(-1)).toMatchObject({
role: "assistant",
content: [{ type: "text", text: expect.stringContaining("MAXIMUM STEPS REACHED") }],
@@ -3953,7 +4000,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 +4193,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 +4245,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 +4340,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 +4569,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 +4601,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 +4633,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 +4657,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 +4694,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 +4732,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 +4769,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 +4796,7 @@ describe("SessionRunnerLLM", () => {
expect(events.map((event) => event.type)).toEqual([
"session.step.started.1",
"session.tool.called.1",
"session.tool.failed.1",
"session.tool.failed.2",
"session.step.failed.1",
])
expect(
@@ -84,30 +84,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))
}),
)
})
+58 -28
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([])
+73 -32
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.AnyTool = {
description: "Missing output",
input: Schema.Struct({}),
output: Schema.String,
execute: () => Effect.succeed({ content: "not an output" }),
}
const invalid: Tool.AnyTool = {
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",
@@ -15,25 +78,14 @@ test("execute preserves successful results with visible unhandled rejections", a
})
const execute = ExecuteTool.create(new Map([["fail", { tool: child, name: "fail" }]]))
const result = await Effect.runPromise(
Tool.settle(
Tool.execute(
execute,
{
type: "tool-call",
id: "call_execute",
name: "execute",
input: { code: `tools.fail({}); return "done"` },
},
{
sessionID: Session.ID.make("ses_execute"),
agent: Agent.ID.make("build"),
messageID: SessionMessage.ID.make("msg_execute"),
callID: "call_execute",
progress: () => Effect.void,
},
{ 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,13 +104,13 @@ 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([
@@ -67,25 +119,14 @@ test("execute supports callable namespace tools", async () => {
]),
)
const result = await Effect.runPromise(
Tool.settle(
Tool.execute(
execute,
{
type: "tool-call",
id: "call_execute",
name: "execute",
input: { code: "return [await tools.slack.admin({}), await tools.slack.admin.create({})]" },
},
{
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" },
+21 -55
View File
@@ -53,52 +53,31 @@ describe("ToolOutputStore", () => {
const result = yield* store.bound({
sessionID,
callID: "call-aggregate",
output: {
structured: { kind: "report" },
content: [
{ type: "text", text: first },
{ type: "text", text: second },
],
},
content: [
{ type: "text", text: first },
{ type: "text", text: second },
],
})
expect(result.output.structured).toEqual({ kind: "report" })
expect(result.outputPaths).toHaveLength(1)
expect(yield* fs.readFileString(result.outputPaths[0])).toBe(first + second)
if (result.output.content[0]?.type !== "text") throw new Error("expected text preview")
expect(Buffer.byteLength(result.output.content[0].text)).toBeLessThanOrEqual(ToolOutputStore.MAX_BYTES)
if (result.content[0]?.type !== "text") throw new Error("expected text preview")
expect(Buffer.byteLength(result.content[0].text)).toBeLessThanOrEqual(ToolOutputStore.MAX_BYTES)
}),
),
)
it.live("uses bounded text for oversized structured-only output", () =>
withStore(({ store, fs }) =>
Effect.gen(function* () {
const structured = { text: "x".repeat(ToolOutputStore.MAX_BYTES) }
const result = yield* store.bound({ sessionID, callID: "call-json", output: { structured, content: [] } })
expect(result.output.structured).toEqual(structured)
expect(result.outputPaths).toHaveLength(1)
expect(JSON.parse(yield* fs.readFileString(result.outputPaths[0]))).toEqual(structured)
expect(result.output.content).toHaveLength(1)
}),
),
)
it.live("preserves native media and structured metadata without applying a settlement media limit", () =>
it.live("preserves native media without applying an execution media limit", () =>
withStore(({ store }) =>
Effect.gen(function* () {
const data = "a".repeat(6 * 1024 * 1024)
const result = yield* store.bound({
sessionID,
callID: "call-file",
output: {
structured: { caption: "pixel" },
content: [{ type: "file", uri: `data:image/png;base64,${data}`, mime: "image/png", name: "pixel.png" }],
},
content: [{ type: "file", uri: `data:image/png;base64,${data}`, mime: "image/png", name: "pixel.png" }],
})
expect(result.outputPaths).toEqual([])
expect(result.output.structured).toEqual({ caption: "pixel" })
expect(result.output.content).toHaveLength(1)
expect(result.output.content[0]).toEqual({
expect(result.content).toHaveLength(1)
expect(result.content[0]).toEqual({
type: "file",
uri: `data:image/png;base64,${data}`,
mime: "image/png",
@@ -108,7 +87,7 @@ describe("ToolOutputStore", () => {
),
)
it.live("preserves structured metadata and native media when bounding text", () =>
it.live("preserves native media when bounding text", () =>
withStore(({ store, fs }) =>
Effect.gen(function* () {
const text = "x".repeat(ToolOutputStore.MAX_BYTES + 1)
@@ -121,30 +100,29 @@ describe("ToolOutputStore", () => {
const result = yield* store.bound({
sessionID,
callID: "call-text-and-media",
output: { structured: { caption: "pixel" }, content: [{ type: "text", text }, media] },
content: [{ type: "text", text }, media],
})
expect(result.output.structured).toEqual({ caption: "pixel" })
expect(result.output.content[1]).toEqual(media)
expect(result.content[1]).toEqual(media)
expect(yield* fs.readFileString(result.outputPaths[0])).toBe(text)
}),
),
)
it.live("does not double-count structured data duplicated in projected text", () =>
it.live("returns content within the limits unchanged", () =>
withStore(({ store }) =>
Effect.gen(function* () {
const text = "x".repeat(30_000)
const output = { structured: { output: text }, content: [{ type: "text" as const, text }] }
expect(yield* store.bound({ sessionID, callID: "call-duplicated", output })).toEqual({
output,
const content = [{ type: "text" as const, text }]
expect(yield* store.bound({ sessionID, callID: "call-duplicated", content })).toEqual({
content,
outputPaths: [],
})
}),
),
)
it.live("fails oversized settlement when complete retention cannot be written", () =>
it.live("fails oversized execution when complete retention cannot be written", () =>
withStore(({ root, store, fs }) =>
Effect.gen(function* () {
yield* fs.writeFileString(path.join(root, "tool-output"), "not a directory")
@@ -152,7 +130,7 @@ describe("ToolOutputStore", () => {
.bound({
sessionID,
callID: "call-lossy",
output: { structured: {}, content: [{ type: "text", text: "x".repeat(ToolOutputStore.MAX_BYTES + 1) }] },
content: [{ type: "text", text: "x".repeat(ToolOutputStore.MAX_BYTES + 1) }],
})
.pipe(Effect.exit)
expect(Exit.isFailure(exit)).toBe(true)
@@ -162,18 +140,6 @@ describe("ToolOutputStore", () => {
),
)
it.live("does not encode ignored structured metadata when projected content exists", () =>
withStore(({ store }) =>
Effect.gen(function* () {
const output = { structured: { value: 1n }, content: [{ type: "text" as const, text: "readable text" }] }
expect(yield* store.bound({ sessionID, callID: "call-unencodable", output })).toEqual({
output,
outputPaths: [],
})
}),
),
)
it.live("preserves interruption while retaining complete output", () =>
Effect.gen(function* () {
const root = yield* Effect.promise(() => tmpdir())
@@ -198,7 +164,7 @@ describe("ToolOutputStore", () => {
.bound({
sessionID,
callID: "call-interrupted",
output: { structured: {}, content: [{ type: "text", text: "x".repeat(ToolOutputStore.MAX_BYTES + 1) }] },
content: [{ type: "text", text: "x".repeat(ToolOutputStore.MAX_BYTES + 1) }],
})
.pipe(Effect.forkChild)
yield* Fiber.interrupt(fiber)
@@ -217,7 +183,7 @@ describe("ToolOutputStore", () => {
const result = yield* store.bound({
sessionID,
callID: "call-config",
output: { structured: {}, content: [{ type: "text", text: "one\ntwo\nthree" }] },
content: [{ type: "text", text: "one\ntwo\nthree" }],
})
expect(result.outputPaths).toHaveLength(1)
}),
+92 -77
View File
@@ -16,7 +16,7 @@ import { location } from "./fixture/location"
import { tmpdir } from "./fixture/tmpdir"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { testEffect } from "./lib/effect"
import { toolIdentity, executeTool, registerToolPlugin, settleTool, toolDefinitions } from "./lib/tool"
import { toolIdentity, executeTool, registerToolPlugin, toolDefinitions } from "./lib/tool"
const patchToolNode = makeLocationNode({
name: "test/patch-tool-plugin",
@@ -96,29 +96,19 @@ const withTool = <A, E, R>(
const activeLocation = Layer.succeed(
Location.Service,
Location.Service.of(
location(
{ directory: AbsolutePath.make(directory) },
{ projectDirectory: AbsolutePath.make(projectDirectory) },
),
location({ directory: AbsolutePath.make(directory) }, { projectDirectory: AbsolutePath.make(projectDirectory) }),
),
)
return Effect.gen(function* () {
return yield* body(yield* ToolRegistry.Service)
}).pipe(
Effect.provide(
AppNodeBuilder.build(
LayerNode.group([
ToolRegistry.node,
ToolRegistry.toolsNode,
patchToolNode,
]),
[
[FSUtil.node, filesystem],
[Location.node, activeLocation],
[PermissionV2.node, permission],
[ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig],
],
),
AppNodeBuilder.build(LayerNode.group([ToolRegistry.node, ToolRegistry.toolsNode, patchToolNode]), [
[FSUtil.node, filesystem],
[Location.node, activeLocation],
[PermissionV2.node, permission],
[ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig],
]),
),
)
}
@@ -162,18 +152,23 @@ describe("PatchTool", () => {
withTool(tmp.path, (registry) =>
Effect.gen(function* () {
expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["patch"])
const settled = yield* settleTool(
const settled = yield* executeTool(
registry,
call(
"*** Begin Patch\n*** Add File: nested/new.txt\n+created\n*** Update File: update.txt\n@@\n-before\n+after\n*** Delete File: remove.txt\n*** End Patch",
),
)
expect(settled.result).toEqual({
type: "text",
value: "Success. Updated the following files:\nA nested/new.txt\nM update.txt\nD remove.txt",
})
if (process.platform === "win32") expect(settled.result.value).not.toContain("\\")
expect(settled.output?.structured).toMatchObject({
expect(settled.status).toBe("completed")
if (settled.status !== "completed") return
expect(settled.content).toEqual([
{
type: "text",
text: "Success. Updated the following files:\nA nested/new.txt\nM update.txt\nD remove.txt",
},
])
const modelText = settled.content[0]?.type === "text" ? settled.content[0].text : ""
if (process.platform === "win32") expect(modelText).not.toContain("\\")
expect(settled.output).toMatchObject({
applied: [
{ type: "add", resource: "nested/new.txt" },
{ type: "update", resource: "update.txt" },
@@ -248,9 +243,11 @@ describe("PatchTool", () => {
"*** Begin Patch\n*** Add File: created.txt\n+created\n*** Update File: old.txt\n*** Move to: moved.txt\n@@\n-before\n+after\n*** End Patch",
),
),
).toEqual({
type: "text",
value: "Success. Updated the following files:\nA created.txt\nM moved.txt",
).toMatchObject({
status: "completed",
content: [
{ type: "text", text: "Success. Updated the following files:\nA created.txt\nM moved.txt" },
],
})
expect(yield* exists(source)).toBe(false)
expect(yield* Effect.promise(() => fs.readFile(path.join(tmp.path, "moved.txt"), "utf8"))).toBe(
@@ -278,7 +275,9 @@ describe("PatchTool", () => {
return Effect.promise(() =>
Promise.all([
fs.writeFile(source, "before\n"),
fs.mkdir(path.dirname(destination), { recursive: true }).then(() => fs.writeFile(destination, "existing\n")),
fs
.mkdir(path.dirname(destination), { recursive: true })
.then(() => fs.writeFile(destination, "existing\n")),
]),
).pipe(
Effect.andThen(
@@ -291,7 +290,7 @@ describe("PatchTool", () => {
"*** Begin Patch\n*** Update File: old.txt\n*** Move to: nested/moved.txt\n@@\n-before\n+after\n*** End Patch",
),
),
).toMatchObject({ type: "text" })
).toMatchObject({ status: "completed" })
expect(yield* exists(source)).toBe(false)
expect(yield* Effect.promise(() => fs.readFile(destination, "utf8"))).toBe("after\n")
}),
@@ -331,13 +330,15 @@ describe("PatchTool", () => {
const source = path.join(directory, "old", "name.txt")
yield* Effect.promise(() => fs.mkdir(path.dirname(source), { recursive: true }))
yield* Effect.promise(() => fs.writeFile(source, "old content\n"))
const settled = yield* settleTool(
const settled = yield* executeTool(
registry,
call(
"*** Begin Patch\n*** Update File: old/name.txt\n*** Move to: renamed/dir/name.txt\n@@\n-old content\n+new content\n*** End Patch",
),
)
expect(settled.output?.structured).toMatchObject({
expect(settled.status).toBe("completed")
if (settled.status !== "completed") return
expect(settled.output).toMatchObject({
applied: [{ type: "update", resource: "renamed/dir/name.txt" }],
files: [
{
@@ -393,7 +394,7 @@ describe("PatchTool", () => {
yield* Effect.promise(() => fs.mkdir(path.join(directory, "dir")))
expect(
yield* executeTool(registry, call("*** Begin Patch\n*** Delete File: dir\n*** End Patch")),
).toMatchObject({ type: "error" })
).toMatchObject({ status: "error" })
expect(yield* exists(path.join(directory, "dir"))).toBe(true)
}),
),
@@ -407,11 +408,9 @@ describe("PatchTool", () => {
expect(
yield* executeTool(
registry,
call(
"*** Begin Patch\n*** Update File: two-chunks.txt\n@@\n-b\n+B\n\n-d\n+D\n*** End Patch",
),
call("*** Begin Patch\n*** Update File: two-chunks.txt\n@@\n-b\n+B\n\n-d\n+D\n*** End Patch"),
),
).toMatchObject({ type: "error" })
).toMatchObject({ status: "error" })
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("a\nb\nc\nd\n")
}),
),
@@ -420,7 +419,10 @@ describe("PatchTool", () => {
it.live("requires patchText", () =>
withTempTool((_directory, registry) =>
Effect.gen(function* () {
expect(yield* executeTool(registry, call(""))).toEqual({ type: "error", value: "patchText is required" })
expect(yield* executeTool(registry, call(""))).toEqual({
status: "error",
error: { type: "tool.execution", message: "patchText is required" },
})
}),
),
)
@@ -429,12 +431,18 @@ describe("PatchTool", () => {
withTempTool((_directory, registry) =>
Effect.gen(function* () {
expect(yield* executeTool(registry, call("invalid patch"))).toEqual({
type: "error",
value: "patch verification failed: The first line of the patch must be '*** Begin Patch'",
status: "error",
error: {
type: "tool.execution",
message: "patch verification failed: The first line of the patch must be '*** Begin Patch'",
},
})
expect(yield* executeTool(registry, call("*** Begin Patch\n*** Add File: foo\n+hello"))).toEqual({
type: "error",
value: "patch verification failed: The last line of the patch must be '*** End Patch'",
status: "error",
error: {
type: "tool.execution",
message: "patch verification failed: The last line of the patch must be '*** End Patch'",
},
})
}),
),
@@ -444,8 +452,8 @@ describe("PatchTool", () => {
withTempTool((_directory, registry) =>
Effect.gen(function* () {
expect(yield* executeTool(registry, call("*** Begin Patch\n*** End Patch"))).toEqual({
type: "error",
value: "patch rejected: empty patch",
status: "error",
error: { type: "tool.execution", message: "patch rejected: empty patch" },
})
}),
),
@@ -454,15 +462,13 @@ describe("PatchTool", () => {
it.live("rejects an invalid hunk header", () =>
withTempTool((_directory, registry) =>
Effect.gen(function* () {
expect(
yield* executeTool(
registry,
call("*** Begin Patch\n*** Frobnicate File: foo\n*** End Patch"),
),
).toEqual({
type: "error",
value:
"patch verification failed: Invalid hunk at line 2: '*** Frobnicate File: foo' is not a valid hunk header. Valid hunk headers: '*** Add File: {path}', '*** Delete File: {path}', '*** Update File: {path}'",
expect(yield* executeTool(registry, call("*** Begin Patch\n*** Frobnicate File: foo\n*** End Patch"))).toEqual({
status: "error",
error: {
type: "tool.execution",
message:
"patch verification failed: Invalid hunk at line 2: '*** Frobnicate File: foo' is not a valid hunk header. Valid hunk headers: '*** Add File: {path}', '*** Delete File: {path}', '*** Update File: {path}'",
},
})
}),
),
@@ -490,13 +496,13 @@ describe("PatchTool", () => {
const bom = "\uFEFF"
const target = path.join(directory, "example.cs")
yield* Effect.promise(() => fs.writeFile(target, `${bom}using System;\n\nclass Test {}\n`))
const settled = yield* settleTool(
const settled = yield* executeTool(
registry,
call(
"*** Begin Patch\n*** Update File: example.cs\n@@\n class Test {}\n+class Next {}\n*** End Patch",
),
call("*** Begin Patch\n*** Update File: example.cs\n@@\n class Test {}\n+class Next {}\n*** End Patch"),
)
const output = Schema.decodeUnknownSync(PatchTool.Output)(settled.output?.structured)
expect(settled.status).toBe("completed")
if (settled.status !== "completed") return
const output = Schema.decodeUnknownSync(PatchTool.Output)(settled.output)
expect(output.files[0]?.patch).not.toContain(bom)
expect(output.files[0]?.patch).not.toContain("-using System;")
expect(output.files[0]?.patch).not.toContain("+using System;")
@@ -517,7 +523,10 @@ describe("PatchTool", () => {
registry,
call("*** Begin Patch\n*** Update File: unchanged.txt\n@@\n-missing\n+changed\n*** End Patch"),
),
).toMatchObject({ type: "error", value: expect.stringContaining("Failed to find expected lines") })
).toMatchObject({
status: "error",
error: { message: expect.stringContaining("Failed to find expected lines") },
})
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("line1\nline2\n")
}),
),
@@ -532,10 +541,12 @@ describe("PatchTool", () => {
call("*** Begin Patch\n*** Update File: missing.txt\n@@\n-old\n+new\n*** End Patch"),
),
).toMatchObject({
type: "error",
value: expect.stringContaining(
`patch verification failed: Failed to read file to update ${path.join(directory, "missing.txt")}: `,
),
status: "error",
error: {
message: expect.stringContaining(
`patch verification failed: Failed to read file to update ${path.join(directory, "missing.txt")}: `,
),
},
})
}),
),
@@ -548,8 +559,11 @@ describe("PatchTool", () => {
expect(
yield* executeTool(registry, call("*** Begin Patch\n*** Update File: nested\n@@\n-old\n+new\n*** End Patch")),
).toEqual({
type: "error",
value: `patch verification failed: Failed to read file to update ${path.join(directory, "nested")}: path is a directory`,
status: "error",
error: {
type: "tool.execution",
message: `patch verification failed: Failed to read file to update ${path.join(directory, "nested")}: path is a directory`,
},
})
}),
),
@@ -560,7 +574,7 @@ describe("PatchTool", () => {
Effect.gen(function* () {
expect(
yield* executeTool(registry, call("*** Begin Patch\n*** Delete File: missing.txt\n*** End Patch")),
).toMatchObject({ type: "error", value: expect.stringContaining("patch verification failed") })
).toMatchObject({ status: "error", error: { message: expect.stringContaining("patch verification failed") } })
}),
),
)
@@ -580,7 +594,7 @@ describe("PatchTool", () => {
registry,
call(`*** Begin Patch\n*** Update File: ${target}\n@@\n-before\n+after\n*** End Patch`),
),
).toMatchObject({ type: "text" })
).toMatchObject({ status: "completed" })
expect(assertions.map((input) => input.action)).toEqual(["external_directory", "edit"])
expect(readsBeforeEditApproval).toBe(1)
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("after\n")
@@ -614,7 +628,7 @@ describe("PatchTool", () => {
registry,
call(`*** Begin Patch\n*** Update File: ${target}\n@@\n-before\n+after\n*** End Patch`),
),
).toMatchObject({ type: "error" })
).toMatchObject({ status: "error" })
expect(assertions.map((input) => input.action)).toEqual(["external_directory"])
expect(readsBeforeEditApproval).toBe(0)
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("before\n")
@@ -649,7 +663,7 @@ describe("PatchTool", () => {
registry,
call("*** Begin Patch\n*** Update File: ../sibling.txt\n@@\n-before\n+after\n*** End Patch"),
),
).toMatchObject({ type: "text" })
).toMatchObject({ status: "completed" })
expect(assertions.map((input) => input.action)).toEqual(["edit"])
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("after\n")
}),
@@ -680,7 +694,7 @@ describe("PatchTool", () => {
registry,
call("*** Begin Patch\n*** Update File: link.txt\n@@\n-before\n+after\n*** End Patch"),
),
).toMatchObject({ type: "text" })
).toMatchObject({ status: "completed" })
expect(assertions.map((input) => input.action)).toEqual(["edit"])
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("after\n")
}),
@@ -711,7 +725,7 @@ describe("PatchTool", () => {
registry,
call(`*** Begin Patch\n*** Update File: ${relative}\n@@\n-before\n+after\n*** End Patch`),
),
).toMatchObject({ type: "text" })
).toMatchObject({ status: "completed" })
expect(assertions.map((input) => input.action)).toEqual(["external_directory", "edit"])
expect(readsBeforeEditApproval).toBe(1)
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("after\n")
@@ -747,7 +761,7 @@ describe("PatchTool", () => {
`*** Begin Patch\n*** Update File: ${first}\n@@\n-before\n+after\n*** Update File: ${second}\n@@\n-before\n+after\n*** End Patch`,
),
),
).toMatchObject({ type: "text" })
).toMatchObject({ status: "completed" })
expect(assertions.map((input) => input.action)).toEqual([
"external_directory",
"external_directory",
@@ -786,8 +800,10 @@ describe("PatchTool", () => {
),
),
).toMatchObject({
type: "error",
value: expect.stringContaining("patch verification failed: Failed to read file to update"),
status: "error",
error: {
message: expect.stringContaining("patch verification failed: Failed to read file to update"),
},
})
expect(yield* exists(path.join(tmp.path, "created.txt"))).toBe(false)
}),
@@ -812,7 +828,7 @@ describe("PatchTool", () => {
registry,
call("*** Begin Patch\n*** Add File: existing.txt\n+replacement\n*** End Patch"),
),
).toMatchObject({ type: "text" })
).toMatchObject({ status: "completed" })
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("replacement\n")
}),
),
@@ -837,7 +853,7 @@ describe("PatchTool", () => {
registry,
call("*** Begin Patch\n*** Add File: appeared.txt\n+replacement\n*** End Patch"),
),
).toMatchObject({ type: "text" })
).toMatchObject({ status: "completed" })
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("replacement\n")
}),
)
@@ -876,5 +892,4 @@ describe("PatchTool", () => {
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
),
)
})
+13 -18
View File
@@ -12,7 +12,7 @@ import { Image } from "@opencode-ai/core/image"
import { testEffect } from "./lib/effect"
import { imagePassthrough } from "./lib/image"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { toolIdentity, executeTool, registerToolPlugin, settleTool, toolDefinitions } from "./lib/tool"
import { toolIdentity, executeTool, registerToolPlugin, toolDefinitions } from "./lib/tool"
const sessionID = SessionV2.ID.make("ses_question_tool_test")
const assertions: PermissionV2.AssertInput[] = []
@@ -99,13 +99,13 @@ describe("QuestionTool", () => {
expect(yield* toolDefinitions(registry, [{ action: "question", resource: "*", effect: "deny" }])).toEqual([])
expect(
yield* settleTool(registry, {
yield* executeTool(registry, {
sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-question-denied", name: "question", input: questionInput },
}),
).toEqual({
result: { type: "error", value: "Permission denied: question" },
status: "error",
error: {
type: "permission.rejected",
message: "Permission denied: question",
@@ -144,26 +144,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({
+67 -74
View File
@@ -22,7 +22,7 @@ import { ReadToolFileSystem } from "@opencode-ai/core/tool/read-filesystem"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { SessionInstructions } from "@opencode-ai/core/session/instructions"
import { testEffect } from "./lib/effect"
import { toolIdentity, executeTool, registerToolPlugin, settleTool, toolDefinitions } from "./lib/tool"
import { toolIdentity, executeTool, registerToolPlugin, toolDefinitions } from "./lib/tool"
const readToolNode = makeLocationNode({
name: "test/read-tool-plugin",
@@ -199,21 +199,19 @@ describe("ReadTool", () => {
expect(yield* toolDefinitions(registry)).toMatchObject([{ name: "read" }])
expect(yield* toolDefinitions(registry, [{ action: "read", resource: "*", effect: "deny" }])).toEqual([])
expect(
yield* executeTool(registry, {
sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-read", name: "read", input: { path: "README.md" } },
}),
).toEqual({
type: "json",
value: {
uri: "file:///README.md",
name: "README.md",
content: "hello",
encoding: "utf8",
mime: "text/plain",
},
const execution = yield* executeTool(registry, {
sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-read", name: "read", input: { path: "README.md" } },
})
expect(execution.status).toBe("completed")
if (execution.status !== "completed") return
expect(execution.output).toEqual({
uri: "file:///README.md",
name: "README.md",
content: "hello",
encoding: "utf8",
mime: "text/plain",
})
expect(assertions).toMatchObject([{ sessionID, action: "read", resources: ["README.md"], save: ["*"] }])
expect(readCalls).toEqual([
@@ -236,7 +234,7 @@ describe("ReadTool", () => {
...toolIdentity,
call: { type: "tool-call", id: "call-external-read", name: "read", input: { path: external } },
}),
).toMatchObject({ type: "json" })
).toMatchObject({ status: "completed" })
expect(assertions).toMatchObject([
{
sessionID,
@@ -261,19 +259,17 @@ describe("ReadTool", () => {
}
const registry = yield* ToolRegistry.Service
expect(
yield* executeTool(registry, {
sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-image", name: "read", input: { path: "pixel.png" } },
}),
).toEqual({
type: "content",
value: [
{ type: "text", text: "Image read successfully" },
{ type: "file", uri: `data:image/png;base64,${png}`, mime: "image/png", name: "pixel.png" },
],
const execution = yield* executeTool(registry, {
sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-image", name: "read", input: { path: "pixel.png" } },
})
expect(execution.status).toBe("completed")
if (execution.status !== "completed") return
expect(execution.content).toEqual([
{ type: "text", text: "Image read successfully" },
{ type: "file", uri: `data:image/png;base64,${png}`, mime: "image/png", name: "pixel.png" },
])
expect(readCalls).toEqual([
{
input: AbsolutePath.make(path.join(process.cwd(), "pixel.png")),
@@ -281,21 +277,17 @@ describe("ReadTool", () => {
},
])
const settled = yield* settleTool(registry, {
const settled = yield* executeTool(registry, {
sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-image-settle", name: "read", input: { path: "pixel.png" } },
})
expect(settled.output?.structured).toMatchObject({
uri: "file:///pixel.png",
name: "pixel.png",
mime: "image/png",
encoding: "base64",
// Image base64 is carried by the content file item only; structured is slimmed
// so the original bytes are never persisted twice.
content: "",
})
expect(settled.output?.content).toMatchObject([
expect(settled.status).toBe("completed")
if (settled.status !== "completed") return
// Image base64 is carried by the content file item only; read produces no
// metadata, so the original bytes are never persisted twice.
expect(settled.metadata).toBeUndefined()
expect(settled.content).toMatchObject([
{ type: "text", text: "Image read successfully" },
{ type: "file", mime: "image/png", uri: `data:image/png;base64,${png}` },
])
@@ -319,26 +311,25 @@ describe("ReadTool", () => {
}
const registry = yield* ToolRegistry.Service
const settled = yield* settleTool(registry, {
const settled = yield* executeTool(registry, {
sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-large-image", name: "read", input: { path: "large.png" } },
})
expect(settled.outputPaths).toBeUndefined()
expect(settled.output?.structured).toMatchObject({
expect(settled.status).toBe("completed")
if (settled.status !== "completed") return
expect(settled.output).toMatchObject({
uri: "file:///large.png",
name: "large.png",
mime: "image/png",
encoding: "base64",
})
expect(settled.result).toEqual({
type: "content",
value: [
{ type: "text", text: "Image read successfully" },
{ type: "file", uri: `data:image/png;base64,${png}`, mime: "image/png", name: "large.png" },
],
})
expect(settled.content).toEqual([
{ type: "text", text: "Image read successfully" },
{ type: "file", uri: `data:image/png;base64,${png}`, mime: "image/png", name: "large.png" },
])
}),
)
@@ -361,8 +352,8 @@ describe("ReadTool", () => {
call: { type: "tool-call", id: "call-image-fallback", name: "read", input: { path: "pixel.png" } },
}),
).toMatchObject({
type: "content",
value: [{ type: "text" }, { type: "file", uri: `data:image/png;base64,${png}`, mime: "image/png" }],
status: "completed",
content: [{ type: "text" }, { type: "file", uri: `data:image/png;base64,${png}`, mime: "image/png" }],
})
}),
)
@@ -384,9 +375,9 @@ describe("ReadTool", () => {
...toolIdentity,
call: { type: "tool-call", id: "call-truncated-image", name: "read", input: { path: "truncated.png" } },
}),
).toEqual({
type: "content",
value: [
).toMatchObject({
status: "completed",
content: [
{ type: "text", text: "Image read successfully" },
{ type: "text", text: "[1 image omitted: could not be decoded.]" },
],
@@ -425,9 +416,9 @@ describe("ReadTool", () => {
...toolIdentity,
call: { type: "tool-call", id: "call-wide-image", name: "read", input: { path: "wide.png" } },
}),
).toEqual({
type: "content",
value: [
).toMatchObject({
status: "completed",
content: [
{ type: "text", text: "Image read successfully" },
{ type: "text", text: "[1 image omitted: could not be resized below the image size limit.]" },
],
@@ -463,9 +454,9 @@ describe("ReadTool", () => {
call: { type: "tool-call", id: "call-resize-image", name: "read", input: { path: "wide.png" } },
})
expect(result.type).toBe("content")
if (result.type !== "content") return
const media = result.value[1]
expect(result.status).toBe("completed")
if (result.status !== "completed") return
const media = result.content[1]
expect(media?.type).toBe("file")
if (media?.type !== "file") return
const resized = photon.PhotonImage.new_from_byteslice(Buffer.from(media.uri.split(",")[1] ?? "", "base64"))
@@ -503,9 +494,9 @@ describe("ReadTool", () => {
...toolIdentity,
call: { type: "tool-call", id: "call-max-bytes", name: "read", input: { path: "pixel.png" } },
}),
).toEqual({
type: "content",
value: [
).toMatchObject({
status: "completed",
content: [
{ type: "text", text: "Image read successfully" },
{ type: "text", text: "[1 image omitted: could not be resized below the image size limit.]" },
],
@@ -532,8 +523,8 @@ describe("ReadTool", () => {
call: { type: "tool-call", id: "call-disguised-image", name: "read", input: { path: "pixel.bin" } },
}),
).toMatchObject({
type: "content",
value: [{ type: "text" }, { type: "file", mime: "image/png", name: "pixel.bin" }],
status: "completed",
content: [{ type: "text" }, { type: "file", mime: "image/png", name: "pixel.bin" }],
})
}),
)
@@ -554,7 +545,7 @@ describe("ReadTool", () => {
input: { path: "archive.dat", offset: 2, limit: 1 },
},
}),
).toEqual({ type: "error", value: "Cannot read binary file: archive.dat" })
).toEqual({ status: "error", error: { type: "unknown", message: "Cannot read binary file: archive.dat" } })
expect(readCalls).toEqual([
{ input: AbsolutePath.make(path.join(process.cwd(), "archive.dat")), page: { offset: 2, limit: 1 } },
])
@@ -589,7 +580,7 @@ describe("ReadTool", () => {
...toolIdentity,
call: { type: "tool-call", id: "call-read", name: "read", input: { path: "README.md" } },
}),
).toEqual({ type: "error", value: "Unable to read README.md" })
).toEqual({ status: "error", error: { type: "permission.rejected", message: "Permission denied: read" } })
expect(readCalls).toEqual([])
}),
)
@@ -604,7 +595,9 @@ describe("ReadTool", () => {
...toolIdentity,
call: { type: "tool-call", id: "call-missing-path", name: "read", input: { path: missingPath } },
}),
).toEqual({ type: "error", value: `Unable to read ${missingPath}` })
// The message-less PathError cause must not erase the tool's curated
// failure message; the canonical error is the sole authority.
).toEqual({ status: "error", error: { type: "tool.execution", message: `Unable to read ${missingPath}` } })
expect(assertions).toEqual([])
expect(readCalls).toEqual([])
}),
@@ -626,7 +619,7 @@ describe("ReadTool", () => {
input: { path: "src", offset: 2, limit: 10 },
},
}),
).toEqual({ type: "json", value: { entries: [], truncated: false } })
).toMatchObject({ status: "completed", output: { entries: [], truncated: false } })
expect(assertions).toMatchObject([{ sessionID, action: "read", resources: ["src"], save: ["*"] }])
expect(listCalls).toEqual([{ offset: 2, limit: 10 }])
}),
@@ -644,7 +637,7 @@ describe("ReadTool", () => {
...toolIdentity,
call: { type: "tool-call", id: "call-read-directory-denied", name: "read", input: { path: "src" } },
}),
).toEqual({ type: "error", value: "Unable to read src" })
).toEqual({ status: "error", error: { type: "permission.rejected", message: "Permission denied: read" } })
expect(listCalls).toEqual([])
}),
)
@@ -691,9 +684,9 @@ describe("ReadTool", () => {
input: { path: "large.txt", offset: 2, limit: 1 },
},
}),
).toEqual({
type: "json",
value: { type: "text-page", content: "hello", mime: "text/plain", offset: 2, truncated: true, next: 3 },
).toMatchObject({
status: "completed",
output: { type: "text-page", content: "hello", mime: "text/plain", offset: 2, truncated: true, next: 3 },
})
expect(readCalls).toEqual([
{ input: AbsolutePath.make(path.join(process.cwd(), "large.txt")), page: { offset: 2, limit: 1 } },
@@ -718,7 +711,7 @@ describe("ReadTool", () => {
...toolIdentity,
call: { type: "tool-call", id: "call-direct-binary", name: "read", input: { path: "late-binary" } },
}),
).toEqual({ type: "error", value: "Cannot read binary file: late-binary" })
).toEqual({ status: "error", error: { type: "unknown", message: "Cannot read binary file: late-binary" } })
}),
)
})
+15 -10
View File
@@ -19,7 +19,7 @@ import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
import { location } from "./fixture/location"
import { tmpdir } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect"
import { executeTool, registerToolPlugin, settleTool, toolIdentity } from "./lib/tool"
import { executeTool, registerToolPlugin, toolIdentity } from "./lib/tool"
const globToolNode = makeLocationNode({
name: "test/glob-tool-plugin",
@@ -83,15 +83,17 @@ describe("search tools", () => {
)
yield* withTools(tmp.path, (registry) =>
Effect.gen(function* () {
const glob = yield* settleTool(registry, call("glob", { pattern: "*" }))
const grep = yield* settleTool(registry, call("grep", { pattern: "needle" }))
const glob = yield* executeTool(registry, call("glob", { pattern: "*" }))
const grep = yield* executeTool(registry, call("grep", { pattern: "needle" }))
expect(glob.output?.structured).toEqual({ count: FileSystem.DEFAULT_SEARCH_LIMIT })
expect(grep.output?.structured).toEqual({ matches: FileSystem.DEFAULT_SEARCH_LIMIT })
expect(glob.output?.content).toEqual([{ type: "text", text: String(glob.result.value) }])
expect(grep.output?.content).toEqual([{ type: "text", text: String(grep.result.value) }])
expect(String(glob.result.value).split("\n")).toHaveLength(FileSystem.DEFAULT_SEARCH_LIMIT)
expect(grep.result.value).toStartWith(`Found ${FileSystem.DEFAULT_SEARCH_LIMIT} matches\n`)
expect(glob.metadata).toEqual({ count: FileSystem.DEFAULT_SEARCH_LIMIT })
expect(grep.metadata).toEqual({ matches: FileSystem.DEFAULT_SEARCH_LIMIT })
expect(glob.content).toHaveLength(1)
expect(grep.content).toHaveLength(1)
const globText = glob.content?.[0]?.type === "text" ? glob.content[0].text : ""
const grepText = grep.content?.[0]?.type === "text" ? grep.content[0].text : ""
expect(globText.split("\n")).toHaveLength(FileSystem.DEFAULT_SEARCH_LIMIT)
expect(grepText).toStartWith(`Found ${FileSystem.DEFAULT_SEARCH_LIMIT} matches\n`)
}),
)
}),
@@ -110,7 +112,10 @@ describe("search tools", () => {
registry,
call(name, { path: "missing", pattern: name === "glob" ? "*" : "needle" }),
)
expect(result).toEqual({ type: "error", value: "Search path does not exist: missing" })
expect(result).toEqual({
status: "error",
error: { type: "tool.execution", message: "Search path does not exist: missing" },
})
}),
),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
+52 -66
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_/)
}),
)
},
@@ -499,12 +487,12 @@ describe("ShellTool", () => {
(tmp) => {
reset()
return withSession(tmp.path, (registry) =>
settleTool(registry, call({ command: idleCommand, timeout: 50 })),
executeTool(registry, call({ command: idleCommand, timeout: 50 })),
).pipe(
Effect.andThen((settled) =>
Effect.sync(() => {
expect(settled.output?.structured).toMatchObject({ timeout: true, truncated: false })
expect(settled.output?.content[1]).toMatchObject({
expect(settled.metadata).toMatchObject({ timeout: true, truncated: false })
expect(settled.content?.[1]).toMatchObject({
type: "text",
text: expect.stringContaining("Command timed out"),
})
@@ -529,10 +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"),
})
+21 -13
View File
@@ -17,7 +17,7 @@ import { it } from "./lib/effect"
import { imagePassthrough } from "./lib/image"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { toolIdentity, executeTool, registerToolPlugin, settleTool, toolDefinitions } from "./lib/tool"
import { toolIdentity, executeTool, registerToolPlugin, toolDefinitions } from "./lib/tool"
const skillToolNode = makeLocationNode({
name: "test/skill-tool-plugin",
@@ -108,23 +108,22 @@ describe("SkillTool", () => {
...toolIdentity,
call: { type: "tool-call", id: "call-skill", name: "skill", input: { id: "effect" } },
}),
).toEqual({
type: "text",
value: SkillTool.toModelOutput(info, [reference]),
).toMatchObject({
status: "completed",
content: [{ type: "text", text: SkillTool.toModelOutput(info, [reference]) }],
})
expect(SkillTool.toModelOutput(info, [reference])).toContain(`Base directory for this skill: ${directory}`)
expect(
yield* settleTool(registry, {
yield* executeTool(registry, {
sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-skill-overflow", name: "skill", input: { id: "effect" } },
}),
).toEqual({
result: { type: "text", value: SkillTool.toModelOutput(info, [reference]) },
output: {
structured: { name: "Effect", directory },
content: [{ type: "text", text: SkillTool.toModelOutput(info, [reference]) }],
},
status: "completed",
output: { name: "Effect", directory, output: SkillTool.toModelOutput(info, [reference]) },
content: [{ type: "text", text: SkillTool.toModelOutput(info, [reference]) }],
metadata: { name: "Effect", directory },
})
expect(assertions).toMatchObject([
{ sessionID, action: "skill", resources: ["effect"], save: ["effect"] },
@@ -136,7 +135,10 @@ describe("SkillTool", () => {
...toolIdentity,
call: { type: "tool-call", id: "call-missing-skill", name: "skill", input: { id: "missing" } },
}),
).toEqual({ type: "error", value: "Unable to load skill missing" })
).toEqual({
status: "error",
error: { type: "tool.execution", message: "Unable to load skill missing" },
})
deny = true
expect(
yield* executeTool(registry, {
@@ -144,7 +146,10 @@ describe("SkillTool", () => {
...toolIdentity,
call: { type: "tool-call", id: "call-denied-skill", name: "skill", input: { id: "effect" } },
}),
).toEqual({ type: "error", value: "Unable to load skill effect" })
).toEqual({
status: "error",
error: { type: "permission.rejected", message: "Permission denied: skill" },
})
deny = false
const flat = SkillV2.Info.make({
id: SkillV2.ID.make("public"),
@@ -166,7 +171,10 @@ describe("SkillTool", () => {
...toolIdentity,
call: { type: "tool-call", id: "call-flat-skill", name: "skill", input: { id: "public" } },
}),
).toEqual({ type: "text", value: SkillTool.toModelOutput(flat, []) })
).toMatchObject({
status: "completed",
content: [{ type: "text", text: SkillTool.toModelOutput(flat, []) }],
})
}).pipe(Effect.provide(skillToolLayer))
}),
),
+41 -31
View File
@@ -28,7 +28,7 @@ import { ToolRegistry } from "@opencode-ai/core/tool/registry"
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
import { tmpdir } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect"
import { executeTool, settleTool, toolIdentity, waitForTool } from "./lib/tool"
import { executeTool, toolIdentity, waitForTool } from "./lib/tool"
const childText = "child final response"
const childModel = ModelV2.Ref.make({ id: ModelV2.ID.make("child"), providerID: ProviderV2.ID.make("test") })
@@ -148,7 +148,7 @@ describe("SubagentTool", () => {
const locations = yield* LocationServiceMap.Service
const registry = yield* ToolRegistry.Service.pipe(Effect.provide(locations.get(parent.location)))
yield* waitForTool(registry, SubagentTool.name)
expect((yield* registry.materialize()).definitions.map((tool) => tool.name)).toContain(SubagentTool.name)
expect((yield* registry.snapshot()).definitions.map((tool) => tool.name)).toContain(SubagentTool.name)
expect(
yield* executeTool(registry, {
sessionID: parent.id,
@@ -160,7 +160,10 @@ describe("SubagentTool", () => {
input: { agent: "primary", description: "primary", prompt: "should fail" },
},
}),
).toEqual({ type: "error", value: "Agent primary cannot run as a subagent" })
).toEqual({
status: "error",
error: { type: "tool.execution", message: "Agent primary cannot run as a subagent" },
})
}),
),
),
@@ -193,7 +196,13 @@ describe("SubagentTool", () => {
input: { agent: "reviewer", description: "nested", prompt: "should fail" },
},
}),
).toEqual({ type: "error", value: expect.stringContaining("Subagent depth limit reached (1)") })
).toEqual({
status: "error",
error: {
type: "tool.execution",
message: expect.stringContaining("Subagent depth limit reached (1)"),
},
})
expect((yield* sessions.list({ parentID: parent.id })).data).toHaveLength(0)
}),
),
@@ -219,7 +228,7 @@ describe("SubagentTool", () => {
const registry = yield* ToolRegistry.Service.pipe(Effect.provide(locations.get(parent.location)))
yield* waitForTool(registry, SubagentTool.name)
const settled = yield* settleTool(registry, {
const settled = yield* executeTool(registry, {
sessionID: parent.id,
...toolIdentity,
call: {
@@ -231,17 +240,15 @@ describe("SubagentTool", () => {
})
expect(settled).toMatchObject({
result: { type: "text", value: childText },
output: {
structured: { status: "completed" },
content: [{ type: "text", text: childText }],
},
status: "completed",
metadata: { status: "completed" },
content: [{ type: "text", text: childText }],
})
expect(settled.output?.structured).toEqual({
sessionID: outputSessionID(settled.output?.structured),
expect(settled.metadata).toEqual({
sessionID: outputSessionID(settled.metadata),
status: "completed",
})
expect((yield* sessions.get(outputSessionID(settled.output?.structured))).parentID).toBe(parent.id)
expect((yield* sessions.get(outputSessionID(settled.metadata))).parentID).toBe(parent.id)
}),
),
),
@@ -263,7 +270,7 @@ describe("SubagentTool", () => {
yield* waitForTool(registry, SubagentTool.name)
const progress: ToolRegistry.Progress[] = []
const settled = yield* settleTool(registry, {
const settled = yield* executeTool(registry, {
sessionID: parent.id,
...toolIdentity,
progress: (update) => Effect.sync(() => progress.push(update)),
@@ -276,15 +283,13 @@ describe("SubagentTool", () => {
})
expect(settled).toMatchObject({
result: { type: "text", value: childText },
output: {
structured: { status: "completed" },
content: [{ type: "text", text: childText }],
},
status: "completed",
metadata: { status: "completed" },
content: [{ type: "text", text: childText }],
})
const child = yield* sessions.get(outputSessionID(settled.output?.structured))
expect(settled.output?.structured).toEqual({ sessionID: child.id, status: "completed" })
expect(progress[0]?.structured).toEqual({ sessionID: child.id, status: "running" })
const child = yield* sessions.get(outputSessionID(settled.metadata))
expect(settled.metadata).toEqual({ sessionID: child.id, status: "completed" })
expect(progress[0]?.metadata).toEqual({ sessionID: child.id, status: "running" })
expect(child).toMatchObject({
parentID: parent.id,
location: parent.location,
@@ -295,7 +300,7 @@ describe("SubagentTool", () => {
"You are a subagent spawned by another session.\nreview this",
)
const fallback = yield* settleTool(registry, {
const fallback = yield* executeTool(registry, {
sessionID: parent.id,
...toolIdentity,
call: {
@@ -305,7 +310,7 @@ describe("SubagentTool", () => {
input: { agent: "fallback", description: "fallback", prompt: "fallback" },
},
})
const fallbackChild = yield* sessions.get(outputSessionID(fallback.output?.structured))
const fallbackChild = yield* sessions.get(outputSessionID(fallback.metadata))
expect(fallbackChild).toMatchObject({ parentID: parent.id, model: parentModel })
}),
),
@@ -338,7 +343,13 @@ describe("SubagentTool", () => {
input: { agent: "reviewer", description: "fail review", prompt: "please fail" },
},
}),
).toEqual({ type: "error", value: expect.stringContaining("No model is available for session") })
).toEqual({
status: "error",
error: {
type: "tool.execution",
message: expect.stringContaining("No model is available for session"),
},
})
}),
),
),
@@ -366,7 +377,7 @@ describe("SubagentTool", () => {
Effect.forkScoped({ startImmediately: true }),
)
const settled = yield* settleTool(registry, {
const settled = yield* executeTool(registry, {
sessionID: parent.id,
...toolIdentity,
call: {
@@ -376,13 +387,12 @@ describe("SubagentTool", () => {
input: { agent: "reviewer", description: "background review", prompt: "review", background: true },
},
})
const childID = outputSessionID(settled.output?.structured)
expect(settled.output?.structured).toMatchObject({
const childID = outputSessionID(settled.metadata)
expect(settled.metadata).toMatchObject({
status: "running",
})
expect(settled.output?.structured).toEqual({ sessionID: childID, status: "running" })
expect(settled.result).toEqual({ type: "text", value: expect.stringContaining(`id: ${childID}`) })
expect(settled.output?.content).toEqual([{ type: "text", text: expect.stringContaining(`id: ${childID}`) }])
expect(settled.metadata).toEqual({ sessionID: childID, status: "running" })
expect(settled.content).toEqual([{ type: "text", text: expect.stringContaining(`id: ${childID}`) }])
const admission = Array.from(yield* Fiber.join(admitted))[0]
expect(admission?.data.input.data.text).toContain(`<subagent id="${childID}" state="completed"`)
+45 -36
View File
@@ -14,7 +14,7 @@ import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { Image } from "@opencode-ai/core/image"
import { testEffect } from "./lib/effect"
import { imagePassthrough } from "./lib/image"
import { toolIdentity, executeTool, registerToolPlugin, settleTool, toolDefinitions } from "./lib/tool"
import { toolIdentity, executeTool, registerToolPlugin, toolDefinitions } from "./lib/tool"
const webFetchToolNode = makeLocationNode({
name: "test/webfetch-tool-plugin",
@@ -93,12 +93,11 @@ describe("WebFetchTool registration", () => {
const url = "http://example.com/public"
expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["webfetch"])
expect(yield* settleTool(registry, call({ url, format: "text", timeout: 4 }))).toEqual({
result: { type: "text", value: "hello" },
output: {
structured: { contentType: "text/plain" },
content: [{ type: "text", text: "hello" }],
},
expect(yield* executeTool(registry, call({ url, format: "text", timeout: 4 }))).toEqual({
status: "completed",
output: { url, contentType: "text/plain", format: "text", output: "hello" },
content: [{ type: "text", text: "hello" }],
metadata: { contentType: "text/plain" },
})
expect(assertions).toMatchObject([
{ sessionID, action: "webfetch", resources: [url], save: ["*"], metadata: { url, format: "text", timeout: 4 } },
@@ -113,9 +112,9 @@ describe("WebFetchTool registration", () => {
const registry = yield* ToolRegistry.Service
const url = "http://localhost/private"
expect(yield* executeTool(registry, call({ url, format: "text" }))).toEqual({
type: "text",
value: "hello",
expect(yield* executeTool(registry, call({ url, format: "text" }))).toMatchObject({
status: "completed",
content: [{ type: "text", text: "hello" }],
})
expect(assertions).toMatchObject([
{ sessionID, action: "webfetch", resources: [url], save: ["*"], metadata: { url, format: "text" } },
@@ -141,9 +140,9 @@ describe("WebFetchTool registration", () => {
const registry = yield* ToolRegistry.Service
const url = new URL("/redirect", server.url).toString()
expect(yield* executeTool(registry, call({ url, format: "text" }))).toEqual({
type: "text",
value: "redirected",
expect(yield* executeTool(registry, call({ url, format: "text" }))).toMatchObject({
status: "completed",
content: [{ type: "text", text: "redirected" }],
})
expect(assertions).toMatchObject([
{ sessionID, action: "webfetch", resources: [url], save: ["*"], metadata: { url, format: "text" } },
@@ -158,9 +157,10 @@ describe("WebFetchTool registration", () => {
reset()
const registry = yield* ToolRegistry.Service
// toSessionError unwraps the "Unable to fetch <url>" ToolFailure to its cause message.
expect(yield* executeTool(registry, call({ url: "file:///etc/passwd", format: "text" }))).toEqual({
type: "error",
value: "Unable to fetch file:///etc/passwd",
status: "error",
error: { type: "unknown", message: "URL must use http:// or https://" },
})
expect(assertions).toEqual([])
expect(requests).toEqual([])
@@ -178,13 +178,13 @@ describe("WebFetchTool registration", () => {
)
const registry = yield* ToolRegistry.Service
expect(yield* executeTool(registry, call({ url: "https://1.1.1.1", format: "markdown" }))).toEqual({
type: "text",
value: "# Hello\n\nworld",
expect(yield* executeTool(registry, call({ url: "https://1.1.1.1", format: "markdown" }))).toMatchObject({
status: "completed",
content: [{ type: "text", text: "# Hello\n\nworld" }],
})
expect(yield* executeTool(registry, call({ url: "https://1.1.1.1", format: "text" }))).toEqual({
type: "text",
value: "Helloworld",
expect(yield* executeTool(registry, call({ url: "https://1.1.1.1", format: "text" }))).toMatchObject({
status: "completed",
content: [{ type: "text", text: "Helloworld" }],
})
}),
)
@@ -201,9 +201,9 @@ describe("WebFetchTool registration", () => {
const registry = yield* ToolRegistry.Service
const url = "https://1.1.1.1/deep-html"
expect(yield* executeTool(registry, call({ url, format: "markdown" }))).toEqual({
type: "error",
value: `Unable to fetch ${url}`,
expect(yield* executeTool(registry, call({ url, format: "markdown" }))).toMatchObject({
status: "error",
error: { type: "unknown" },
})
}),
)
@@ -219,8 +219,11 @@ describe("WebFetchTool registration", () => {
}),
)
expect(yield* executeTool(registry, call({ url: "https://1.1.1.1/declared", format: "text" }))).toEqual({
type: "error",
value: "Unable to fetch https://1.1.1.1/declared",
status: "error",
error: {
type: "unknown",
message: `Response too large (exceeds ${WebFetchTool.MAX_RESPONSE_BYTES} byte limit)`,
},
})
respond = () =>
@@ -228,8 +231,11 @@ describe("WebFetchTool registration", () => {
new Response("x".repeat(WebFetchTool.MAX_RESPONSE_BYTES + 1), { headers: { "content-type": "text/plain" } }),
)
expect(yield* executeTool(registry, call({ url: "https://1.1.1.1/streamed", format: "text" }))).toEqual({
type: "error",
value: "Unable to fetch https://1.1.1.1/streamed",
status: "error",
error: {
type: "unknown",
message: `Response too large (exceeds ${WebFetchTool.MAX_RESPONSE_BYTES} byte limit)`,
},
})
}),
)
@@ -240,14 +246,14 @@ describe("WebFetchTool registration", () => {
const registry = yield* ToolRegistry.Service
respond = () => Effect.succeed(new Response("png", { headers: { "content-type": "image/png" } }))
expect(yield* executeTool(registry, call({ url: "https://1.1.1.1/image", format: "html" }))).toEqual({
type: "error",
value: "Unable to fetch https://1.1.1.1/image",
status: "error",
error: { type: "unknown", message: "Unsupported fetched image content type: image/png" },
})
respond = () => Effect.succeed(new Response("pdf", { headers: { "content-type": "application/pdf" } }))
expect(yield* executeTool(registry, call({ url: "https://1.1.1.1/file", format: "html" }))).toEqual({
type: "error",
value: "Unable to fetch https://1.1.1.1/file",
status: "error",
error: { type: "unknown", message: "Unsupported fetched file content type: application/pdf" },
})
}),
)
@@ -264,9 +270,9 @@ describe("WebFetchTool registration", () => {
)
const registry = yield* ToolRegistry.Service
expect(yield* executeTool(registry, call({ url: "https://1.1.1.1", format: "text" }))).toEqual({
type: "text",
value: "ok",
expect(yield* executeTool(registry, call({ url: "https://1.1.1.1", format: "text" }))).toMatchObject({
status: "completed",
content: [{ type: "text", text: "ok" }],
})
expect(requests).toHaveLength(2)
expect(requests[0]?.headers["user-agent"]).toContain("Mozilla/5.0")
@@ -285,7 +291,10 @@ describe("WebFetchTool registration", () => {
).pipe(Effect.forkChild)
yield* TestClock.adjust(Duration.seconds(1))
expect(yield* Fiber.join(fiber)).toEqual({ type: "error", value: "Unable to fetch https://1.1.1.1/slow" })
expect(yield* Fiber.join(fiber)).toEqual({
status: "error",
error: { type: "unknown", message: "Request timed out" },
})
}),
)
})
+21 -11
View File
@@ -13,7 +13,7 @@ import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { Image } from "@opencode-ai/core/image"
import { testEffect } from "./lib/effect"
import { imagePassthrough } from "./lib/image"
import { toolIdentity, executeTool, registerToolPlugin, settleTool, toolDefinitions } from "./lib/tool"
import { toolIdentity, executeTool, registerToolPlugin, toolDefinitions } from "./lib/tool"
const webSearchToolNode = makeLocationNode({
name: "test/websearch-tool-plugin",
@@ -172,7 +172,10 @@ describe("WebSearchTool registration", () => {
},
},
}),
).toEqual({ type: "text", value: "exa results" })
).toMatchObject({
status: "completed",
content: [{ type: "text", text: "exa results" }],
})
expect(assertions).toMatchObject([
{
sessionID,
@@ -221,7 +224,7 @@ describe("WebSearchTool registration", () => {
config = { provider: "parallel", enableExa: false, enableParallel: false, parallelApiKey: "parallel-secret" }
const registry = yield* ToolRegistry.Service
const settled = yield* settleTool(registry, {
const settled = yield* executeTool(registry, {
sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-parallel", name: "websearch", input: { query: "effect layers" } },
@@ -242,11 +245,10 @@ describe("WebSearchTool registration", () => {
})
expect(requests[0]?.body).not.toHaveProperty("params.arguments.model_name")
expect(settled).toEqual({
result: { type: "text", value: "parallel results" },
output: {
structured: { provider: "parallel" },
content: [{ type: "text", text: "parallel results" }],
},
status: "completed",
output: { provider: "parallel", text: "parallel results" },
content: [{ type: "text", text: "parallel results" }],
metadata: { provider: "parallel" },
})
expect(JSON.stringify(settled)).not.toContain("parallel-secret")
}),
@@ -260,7 +262,7 @@ describe("WebSearchTool registration", () => {
config = { provider: "exa", enableExa: false, enableParallel: false, exaApiKey: "exa secret" }
const registry = yield* ToolRegistry.Service
const settled = yield* settleTool(registry, {
const settled = yield* executeTool(registry, {
sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-exa-key", name: "websearch", input: { query: "effect schema" } },
@@ -285,7 +287,10 @@ describe("WebSearchTool registration", () => {
...toolIdentity,
call: { type: "tool-call", id: "call-empty", name: "websearch", input: { query: "nothing" } },
}),
).toEqual({ type: "text", value: WebSearchTool.NO_RESULTS })
).toMatchObject({
status: "completed",
content: [{ type: "text", text: WebSearchTool.NO_RESULTS }],
})
}),
)
@@ -318,7 +323,12 @@ describe("WebSearchTool registration", () => {
...toolIdentity,
call: { type: "tool-call", id: "call-large-response", name: "websearch", input: { query: "too much" } },
}),
).toEqual({ type: "error", value: "Unable to search the web for too much" })
// toSessionError unwraps the "Unable to search the web for <query>" ToolFailure
// to its byte-limit cause message.
).toEqual({
status: "error",
error: { type: "unknown", message: expect.stringContaining("response exceeded") },
})
expect(chunksRead).toBeLessThan(10)
expect(cancelled).toBe(true)
}),
+32 -26
View File
@@ -18,7 +18,7 @@ import { location } from "./fixture/location"
import { tmpdir } from "./fixture/tmpdir"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { testEffect } from "./lib/effect"
import { toolIdentity, executeTool, registerToolPlugin, settleTool, toolDefinitions } from "./lib/tool"
import { toolIdentity, executeTool, registerToolPlugin, toolDefinitions } from "./lib/tool"
const writeToolNode = makeLocationNode({
name: "test/write-tool-plugin",
@@ -119,18 +119,16 @@ describe("WriteTool", () => {
return withTool(tmp.path, (registry) =>
Effect.gen(function* () {
expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["write"])
const settled = yield* settleTool(registry, call({ path: "src/new.txt", content: "created" }))
const settled = yield* executeTool(registry, call({ path: "src/new.txt", content: "created" }))
expect(settled).toEqual({
result: { type: "text", value: "Created file successfully: src/new.txt" },
status: "completed",
output: {
structured: {
operation: "write",
target: path.join(yield* Effect.promise(() => fs.realpath(tmp.path)), "src", "new.txt"),
resource: "src/new.txt",
existed: false,
},
content: [{ type: "text", text: "Created file successfully: src/new.txt" }],
operation: "write",
target: path.join(yield* Effect.promise(() => fs.realpath(tmp.path)), "src", "new.txt"),
resource: "src/new.txt",
existed: false,
},
content: [{ type: "text", text: "Created file successfully: src/new.txt" }],
})
expect(yield* Effect.promise(() => fs.readFile(path.join(tmp.path, "src", "new.txt"), "utf8"))).toBe(
"created",
@@ -151,12 +149,14 @@ describe("WriteTool", () => {
reset()
return Effect.promise(() => fs.writeFile(path.join(tmp.path, "existing.txt"), "before")).pipe(
Effect.andThen(
withTool(tmp.path, (registry) => settleTool(registry, call({ path: "existing.txt", content: "after" }))),
withTool(tmp.path, (registry) => executeTool(registry, call({ path: "existing.txt", content: "after" }))),
),
Effect.andThen((settled) =>
Effect.gen(function* () {
expect(settled.result).toEqual({ type: "text", value: "Wrote file successfully: existing.txt" })
expect(settled.output?.structured).toMatchObject({ resource: "existing.txt", existed: true })
expect(settled.status).toBe("completed")
if (settled.status !== "completed") return
expect(settled.content).toEqual([{ type: "text", text: "Wrote file successfully: existing.txt" }])
expect(settled.output).toMatchObject({ resource: "existing.txt", existed: true })
expect(yield* Effect.promise(() => fs.readFile(path.join(tmp.path, "existing.txt"), "utf8"))).toBe(
"after",
)
@@ -182,8 +182,8 @@ describe("WriteTool", () => {
Effect.andThen(
withTool(tmp.path, (registry) =>
Effect.gen(function* () {
yield* settleTool(registry, call({ path: "preserved.txt", content: "after" }, "call-preserved"))
yield* settleTool(
yield* executeTool(registry, call({ path: "preserved.txt", content: "after" }, "call-preserved"))
yield* executeTool(
registry,
call({ path: "deduplicated.txt", content: "\uFEFFafter" }, "call-deduplicated"),
)
@@ -208,7 +208,10 @@ describe("WriteTool", () => {
return withTool(tmp.path, (registry) => executeTool(registry, call({ path: target, content: "inside" }))).pipe(
Effect.andThen((result) =>
Effect.gen(function* () {
expect(result).toEqual({ type: "text", value: "Created file successfully: absolute.txt" })
expect(result).toMatchObject({
status: "completed",
content: [{ type: "text", text: "Created file successfully: absolute.txt" }],
})
expect(assertions.map((input) => input.action)).toEqual(["edit"])
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("inside")
}),
@@ -236,7 +239,7 @@ describe("WriteTool", () => {
),
Effect.andThen((result) =>
Effect.sync(() => {
expect(result.type).toBe("text")
expect(result.status).toBe("completed")
expect(assertions.map((input) => input.action)).toEqual(["edit"])
expect(assertions[0]?.resources).toEqual(["link.txt"])
}),
@@ -259,7 +262,7 @@ describe("WriteTool", () => {
reset()
const target = path.join(outside.path, "external.txt")
return withTool(active.path, (registry) =>
settleTool(registry, call({ path: target, content: "external" })),
executeTool(registry, call({ path: target, content: "external" })),
).pipe(
Effect.andThen((settled) =>
Effect.gen(function* () {
@@ -271,10 +274,13 @@ describe("WriteTool", () => {
],
})
expect(assertions[1]).toMatchObject({ resources: [canonicalTarget.replaceAll("\\", "/")], save: ["*"] })
expect(settled.output?.structured).toMatchObject({
target: canonicalTarget,
resource: canonicalTarget.replaceAll("\\", "/"),
existed: false,
expect(settled).toMatchObject({
status: "completed",
output: {
target: canonicalTarget,
resource: canonicalTarget.replaceAll("\\", "/"),
existed: false,
},
})
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("external")
expect(writes).toEqual([canonicalTarget])
@@ -336,8 +342,8 @@ describe("WriteTool", () => {
executeTool(registry, call({ path: external, content: "blocked" })),
),
).toEqual({
type: "error",
value: `Unable to write ${external}`,
status: "error",
error: { type: "permission.rejected", message: "Permission denied: external_directory" },
})
expect(assertions.map((input) => input.action)).toEqual(["external_directory"])
expect(writes).toEqual([])
@@ -349,8 +355,8 @@ describe("WriteTool", () => {
executeTool(registry, call({ path: "denied.txt", content: "blocked" })),
),
).toEqual({
type: "error",
value: "Unable to write denied.txt",
status: "error",
error: { type: "permission.rejected", message: "Permission denied: edit" },
})
expect(assertions.map((input) => input.action)).toEqual(["edit"])
expect(writes).toEqual([])
+9 -3
View File
@@ -291,7 +291,7 @@ export default Plugin.define({
tools.add({
name: "greeting",
description: "Create a greeting",
jsonSchema: {
input: {
type: "object",
properties: {
name: { type: "string" },
@@ -299,11 +299,17 @@ export default Plugin.define({
required: ["name"],
additionalProperties: false,
},
output: {
type: "object",
properties: { greeting: { type: "string" } },
required: ["greeting"],
additionalProperties: false,
},
execute: async ({ name }) => {
const text = `Hello, ${name}!`
return {
structured: { greeting: text },
content: [{ type: "text", text }],
output: { greeting: text },
content: text,
}
},
})
+117 -113
View File
@@ -1,13 +1,22 @@
export * as Tool from "./tool.js"
import { Agent } from "@opencode-ai/schema/agent"
import type { LLM } from "@opencode-ai/schema/llm"
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"
// Tool declarations
/** 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
@@ -16,14 +25,12 @@ export interface Context {
readonly progress: (update: Progress) => Effect.Effect<void>
}
export interface Progress {
readonly structured: Readonly<Record<string, unknown>>
readonly content?: ReadonlyArray<Content>
}
/** 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> = Schema.Codec<A, any> | StandardSchemaType<any, A>
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
@@ -32,7 +39,7 @@ export type InputValue<S> =
? A
: S extends StandardSchemaV1<any, infer A>
? A
: never
: unknown
export type OutputValue<S> =
IsAny<S> extends true
? any
@@ -40,7 +47,7 @@ export type OutputValue<S> =
? A
: S extends StandardSchemaV1<infer A, any>
? A
: never
: unknown
export type EncodedValue<S> =
IsAny<S> extends true
? any
@@ -48,7 +55,7 @@ export type EncodedValue<S> =
? A
: S extends StandardSchemaV1<any, infer A>
? A
: never
: unknown
type ToolDefinition = {
readonly name: string
@@ -57,26 +64,9 @@ type ToolDefinition = {
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", {
@@ -88,62 +78,55 @@ 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,
> = {
/** Model-facing tool content: plain text or non-empty rich content. */
export type ModelOutput = string | readonly [Content, ...Content[]]
type BaseDefinition<Input extends SchemaType<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>
export type Result<Output extends SchemaType<any>> = {
readonly output: OutputValue<Output>
readonly content?: ModelOutput
readonly metadata?: Metadata
}
/**
* 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 ContentResult = {
readonly content: ModelOutput
readonly metadata?: Metadata
}
export type AnyTool = Definition<any, any> | DynamicDefinition
export function make<
export type Definition<
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
Output extends SchemaType<any> | undefined = undefined,
> = BaseDefinition<Input> &
(Output extends SchemaType<any>
? {
readonly output: Output
readonly execute: (input: InputValue<Input>, context: Context) => Effect.Effect<Result<Output>, Failure>
}
: {
readonly output?: undefined
readonly execute: (input: InputValue<Input>, context: Context) => Effect.Effect<ContentResult, Failure>
})
export type AnyTool = BaseDefinition<any> & {
readonly output?: SchemaType<any>
readonly execute: (input: any, context: Context) => Effect.Effect<Result<any> | ContentResult, Failure>
}
export function make<Input extends SchemaType<any>, Output extends SchemaType<any>>(
config: Definition<Input, Output>,
): Definition<Input, Output>
export function make<Input extends SchemaType<any>>(config: Definition<Input>): Definition<Input>
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 }
}
// Registration
export const validateName = (name: string) =>
/^[A-Za-z][A-Za-z0-9_-]{0,63}$/.test(name)
@@ -178,58 +161,42 @@ export const withPermission = <T extends AnyTool>(
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 }] : []),
}
({
name,
description: tool.description,
inputSchema: inputJsonSchema(tool.input),
...("output" in tool && tool.output !== undefined ? { outputSchema: outputJsonSchema(tool.output) } : {}),
})
function decodeInput(schema: SchemaType<any>, value: unknown): Effect.Effect<any, Failure> {
// 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}` })),
)
return validateStandard(schema, value, "Invalid tool input")
if (isStandardSchema(schema)) return validateStandard(schema, value, "Invalid tool input")
return Effect.succeed(value)
}
function encodeOutput(schema: SchemaType<any>, value: unknown): Effect.Effect<any, Failure> {
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}` }),
),
)
return validateStandard(schema, value, "Tool returned an invalid value for its output schema")
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> {
@@ -255,15 +222,15 @@ function standardFailure(prefix: string, error: unknown) {
}
function inputJsonSchema(schema: SchemaType<any>): JsonSchema.JsonSchema {
if (!Schema.isSchema(schema))
if (isStandardSchema(schema))
return schema["~standard"].jsonSchema.input({ target: "draft-2020-12" }) as JsonSchema.JsonSchema
return toJsonSchema(schema)
return Schema.isSchema(schema) ? toJsonSchema(schema) : (schema as JsonSchema.JsonSchema)
}
function outputJsonSchema(schema: SchemaType<any>): JsonSchema.JsonSchema {
if (!Schema.isSchema(schema))
if (isStandardSchema(schema))
return schema["~standard"].jsonSchema.output({ target: "draft-2020-12" }) as JsonSchema.JsonSchema
return toJsonSchema(schema)
return Schema.isSchema(schema) ? toJsonSchema(schema) : (schema as JsonSchema.JsonSchema)
}
function toJsonSchema(schema: Schema.Top): JsonSchema.JsonSchema {
@@ -272,6 +239,8 @@ function toJsonSchema(schema: Schema.Top): JsonSchema.JsonSchema {
return { ...document.schema, $defs: document.definitions }
}
// Plugin events
export interface ToolExecuteBeforeEvent {
readonly tool: string
readonly sessionID: Session.ID
@@ -281,18 +250,53 @@ export interface ToolExecuteBeforeEvent {
input: unknown
}
export interface ToolExecuteAfterEvent {
type ToolHookBase = {
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 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"))
/**
* 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 &
(
| {
readonly status: "completed"
content: readonly [LLM.ToolContent, ...LLM.ToolContent[]]
metadata?: Metadata
outputPaths?: ReadonlyArray<string>
}
| {
readonly status: "error"
error: SessionError.Error
content?: readonly [LLM.ToolContent, ...LLM.ToolContent[]]
metadata?: Metadata
outputPaths?: ReadonlyArray<string>
}
)
export interface RegisterOptions {
readonly namespace?: string
/** Defaults to true. False exposes the tool directly to the provider. */
+25 -17
View File
@@ -6,35 +6,43 @@ export type Context = Omit<Tool.Context, "progress"> & {
}
export type SchemaType<A> = Tool.SchemaType<A>
export type Content = Tool.Content
export type DynamicOutput = Tool.DynamicOutput
export type Metadata = Tool.Metadata
export type ModelOutput = Tool.ModelOutput
type BaseDefinition<Input extends SchemaType<any>> = {
readonly name: string
readonly description: string
readonly input: Input
readonly options?: RegisterOptions
}
export type Definition<
Input extends SchemaType<any>,
Output extends SchemaType<any>,
Structured extends SchemaType<any> = Output,
> = Omit<Tool.Definition<Input, Structured, Output>, "execute" | "permission"> & {
Output extends SchemaType<any> | undefined = undefined,
> = BaseDefinition<Input> &
(Output extends SchemaType<any>
? {
readonly output: Output
readonly execute: (input: Tool.InputValue<Input>, context: Context) => Promise<Tool.Result<Output>>
}
: {
readonly output?: undefined
readonly execute: (input: Tool.InputValue<Input>, context: Context) => Promise<Tool.ContentResult>
})
export type AnyTool = Omit<Tool.AnyTool, "execute" | "permission"> & {
readonly name: string
readonly options?: RegisterOptions
readonly execute: (input: Tool.InputValue<Input>, context: Context) => Promise<Tool.OutputValue<Output>>
readonly execute: (input: any, context: Context) => Promise<Tool.Result<any> | Tool.ContentResult>
}
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
add<Input extends SchemaType<any>, Output extends SchemaType<any>>(tool: Definition<Input, Output>): void
add<Input extends SchemaType<any>>(tool: Definition<Input>): void
}
export interface ToolHooks {
+31 -35
View File
@@ -1,25 +1,14 @@
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)
@@ -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,7 +63,7 @@ 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({
@@ -86,10 +72,9 @@ test("portable schemas validate and describe typed tools", async () => {
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,40 @@ 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.definition("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 })
})
+19 -10
View File
@@ -409,40 +409,49 @@ export namespace Tool {
})
export type Called = typeof Called.Type
/** Live replacement snapshot for a running tool. */
/** Live replacement metadata for a running tool. */
export const Progress = Event.ephemeral({
type: "session.tool.progress",
schema: {
...ToolBase,
structured: Schema.Record(Schema.String, Schema.Unknown),
content: Schema.Array(ToolContent),
metadata: Schema.Record(Schema.String, Schema.Json),
},
})
export type Progress = typeof Progress.Type
/** Canonical terminal success: one non-empty model representation plus optional UI metadata. */
export const Success = Event.durable({
type: "session.tool.success",
...options,
durable: {
aggregate: "sessionID",
version: 2,
},
schema: {
...ToolBase,
structured: Schema.Record(Schema.String, Schema.Unknown),
content: Schema.Array(ToolContent),
result: Schema.Unknown.pipe(optional),
content: Schema.NonEmptyArray(ToolContent),
metadata: Schema.Record(Schema.String, Schema.Json).pipe(optional),
executed: Schema.Boolean,
resultState: SessionMessage.ProviderState.pipe(optional),
},
})
export type Success = typeof Success.Type
/**
* Canonical terminal failure: one error plus the final bounded snapshot of
* partial progress. The event is self-contained; projection never reaches
* into ephemeral progress history.
*/
export const Failed = Event.durable({
type: "session.tool.failed",
...options,
durable: {
aggregate: "sessionID",
version: 2,
},
schema: {
...ToolBase,
error: SessionError.Error,
content: Schema.NonEmptyArray(ToolContent).pipe(optional),
metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(optional),
result: Schema.Unknown.pipe(optional),
metadata: Schema.Record(Schema.String, Schema.Json).pipe(optional),
executed: Schema.Boolean,
resultState: SessionMessage.ProviderState.pipe(optional),
},
+5 -8
View File
@@ -110,27 +110,24 @@ export interface ToolStateRunning extends Schema.Schema.Type<typeof ToolStateRun
export const ToolStateRunning = Schema.Struct({
status: Schema.tag("running"),
input: Schema.Record(Schema.String, Schema.Unknown),
structured: Schema.Record(Schema.String, Schema.Unknown),
content: ToolContent.pipe(Schema.Array),
metadata: Schema.Record(Schema.String, Schema.Unknown),
}).annotate({ identifier: "Session.Message.ToolState.Running" })
export interface ToolStateCompleted extends Schema.Schema.Type<typeof ToolStateCompleted> {}
export const ToolStateCompleted = Schema.Struct({
status: Schema.tag("completed"),
input: Schema.Record(Schema.String, Schema.Unknown),
content: ToolContent.pipe(Schema.Array),
structured: Schema.Record(Schema.String, Schema.Unknown),
result: Schema.Unknown.pipe(optional),
content: Schema.NonEmptyArray(ToolContent),
metadata: Schema.Record(Schema.String, Schema.Json).pipe(optional),
}).annotate({ identifier: "Session.Message.ToolState.Completed" })
export interface ToolStateError extends Schema.Schema.Type<typeof ToolStateError> {}
export const ToolStateError = Schema.Struct({
status: Schema.tag("error"),
input: Schema.Record(Schema.String, Schema.Unknown),
content: ToolContent.pipe(Schema.Array),
structured: Schema.Record(Schema.String, Schema.Unknown),
error: SessionError.Error,
result: Schema.Unknown.pipe(optional),
content: Schema.NonEmptyArray(ToolContent).pipe(optional),
metadata: Schema.Record(Schema.String, Schema.Json).pipe(optional),
}).annotate({ identifier: "Session.Message.ToolState.Error" })
export const ToolState = Schema.Union([ToolStateStreaming, ToolStateRunning, ToolStateCompleted, ToolStateError]).pipe(
+2 -2
View File
@@ -125,8 +125,8 @@ describe("public event manifest", () => {
"session.tool.input.started.1",
"session.tool.input.ended.1",
"session.tool.called.1",
"session.tool.success.1",
"session.tool.failed.1",
"session.tool.success.2",
"session.tool.failed.2",
"session.reasoning.started.1",
"session.reasoning.ended.1",
"session.retry.scheduled.1",
+3 -3
View File
@@ -77,7 +77,7 @@ it.live(
description: "Marks the initial Location plugin generation",
input: Schema.Struct({}),
output: Schema.Void,
execute: () => Effect.void,
execute: () => Effect.succeed({ output: undefined }),
}),
),
)
@@ -104,7 +104,7 @@ it.live(
description: "Tool registered after Location boot",
input: Schema.Struct({}),
output: Schema.Void,
execute: () => Effect.void,
execute: () => Effect.succeed({ output: undefined }),
}),
),
)
@@ -225,7 +225,7 @@ it.live(
description: "Embedded test tool",
input: Schema.Struct({}),
output: Schema.Struct({ ok: Schema.Boolean }),
execute: () => Effect.succeed({ ok: true }),
execute: () => Effect.succeed({ output: { ok: true } }),
}),
),
)
@@ -10,6 +10,7 @@ import {
Exit,
Fiber,
FiberSet,
JsonSchema,
Layer,
PubSub,
Queue,
@@ -451,7 +452,7 @@ const makeToolDriver = Effect.fn("SimulatedProvider.makeToolDriver")(function* (
name: string,
input: unknown,
context: Tool.Context,
): Effect.Effect<Tool.DynamicOutput, Tool.Failure> =>
): Effect.Effect<Tool.Result<JsonSchema.JsonSchema>, Tool.Failure> =>
Effect.gen(function* () {
const encoded = yield* Schema.decodeUnknownEffect(Schema.Json)(input).pipe(
Effect.mapError((error) => new Tool.Failure({ message: `Simulated tool input is not JSON: ${error.message}` })),
@@ -518,7 +519,15 @@ const makeToolDriver = Effect.fn("SimulatedProvider.makeToolDriver")(function* (
),
),
)
if (invocation.type === "success") return invocation.output
// The simulation wire protocol keeps its historical field names; map to the
// canonical output at this boundary.
if (invocation.type === "success")
return {
output: invocation.output.structured,
...(invocation.output.content.length === 0
? {}
: { content: invocation.output.content as [Tool.Content, ...Tool.Content[]] }),
}
return yield* Effect.fail(new Tool.Failure({ message: invocation.message }))
})
@@ -546,10 +555,8 @@ const makeToolDriver = Effect.fn("SimulatedProvider.makeToolDriver")(function* (
registration.name,
Tool.make({
description: registration.description,
jsonSchema: registration.inputSchema,
...(registration.outputSchema === undefined
? {}
: { outputSchema: registration.outputSchema }),
input: registration.inputSchema,
output: registration.outputSchema ?? {},
...(registration.permission === undefined ? {} : { permission: registration.permission }),
execute: (input, context) =>
invoke(
+1 -4
View File
@@ -473,10 +473,7 @@ export namespace Backend {
: `${registration.options.namespace.replaceAll(".", "_")}_${registration.name}`
}
export const ToolProgress = Schema.Struct({
structured: Schema.Record(Schema.String, Schema.Json),
content: Schema.optionalKey(Schema.Array(ToolContent)),
})
export const ToolProgress = Schema.Record(Schema.String, Schema.Json)
export interface ToolProgress extends Schema.Schema.Type<typeof ToolProgress> {}
export const ToolOutput = Schema.Struct({
+1 -1
View File
@@ -127,7 +127,7 @@ test("decodes the simulated tool lifecycle", () => {
params: {
id: "tool_1",
sequence: 0,
update: { structured: { phase: "searching" }, content: [{ type: "text", text: "Searching" }] },
update: { phase: "searching" },
},
}),
).toMatchObject({ method: "tool.update" })
@@ -264,19 +264,19 @@ test("controls arbitrary tools through scoped SDK overlays", async () => {
)
expect(yield* Queue.take(messages)).toMatchObject({ id: 1, result: { attached: true } })
const registry = yield* ToolRegistry.Service
const materialized = yield* registry.materialize()
expect(materialized.definitions).toContainEqual(
const toolSet = yield* registry.snapshot()
expect(toolSet.definitions).toContainEqual(
expect.objectContaining({ name: "lookup", description: "Look up a value" }),
)
const secondaryMaterialized = yield* ToolRegistry.Service.use((secondaryRegistry) =>
secondaryRegistry.materialize(),
const secondaryToolSet = yield* ToolRegistry.Service.use((secondaryRegistry) =>
secondaryRegistry.snapshot(),
).pipe(Effect.provide(secondary))
expect(secondaryMaterialized.definitions).toContainEqual(
expect(secondaryToolSet.definitions).toContainEqual(
expect.objectContaining({ name: "lookup", description: "Look up a value" }),
)
const progress: ToolRegistry.Progress[] = []
const settle = (callID: string, query: string) =>
materialized.settle({
const executeCall = (callID: string, query: string) =>
toolSet.execute({
sessionID: SessionV2.ID.make("ses_simulated_tools"),
agent: AgentV2.ID.make("build"),
messageID: SessionMessage.ID.make("msg_simulated_tools"),
@@ -289,7 +289,7 @@ test("controls arbitrary tools through scoped SDK overlays", async () => {
},
})
const successful = yield* settle("call_success", "answer").pipe(Effect.forkScoped)
const successful = yield* executeCall("call_success", "answer").pipe(Effect.forkScoped)
const successInvocation = yield* takeToolInvocation(messages)
expect(successInvocation.params).toMatchObject({
name: "lookup",
@@ -309,10 +309,7 @@ test("controls arbitrary tools through scoped SDK overlays", async () => {
params: {
id: successID,
sequence: 0,
update: {
structured: { phase: "searching" },
content: [{ type: "text", text: "Searching" }],
},
update: { phase: "searching" },
},
})
socket.send(update)
@@ -334,7 +331,7 @@ test("controls arbitrary tools through scoped SDK overlays", async () => {
params: {
id: successID,
sequence: 0,
update: { structured: { phase: "different" } },
update: { phase: "different" },
},
}),
)
@@ -350,7 +347,7 @@ test("controls arbitrary tools through scoped SDK overlays", async () => {
params: {
id: successID,
sequence: 2,
update: { structured: { phase: "skipped" } },
update: { phase: "skipped" },
},
}),
)
@@ -389,20 +386,13 @@ test("controls arbitrary tools through scoped SDK overlays", async () => {
)
expect(yield* Queue.take(messages)).toMatchObject({ id: 23, result: { ok: true } })
expect(yield* Fiber.join(successful)).toMatchObject({
result: { type: "text", value: "42" },
output: {
structured: { answer: 42 },
content: [{ type: "text", text: "42" }],
},
status: "completed",
output: { answer: 42 },
content: [{ type: "text", text: "42" }],
})
expect(progress).toEqual([
{
structured: { phase: "searching" },
content: [{ type: "text", text: "Searching" }],
},
])
expect(progress).toEqual([{ phase: "searching" }])
const failed = yield* settle("call_failure", "missing").pipe(Effect.forkScoped)
const failed = yield* executeCall("call_failure", "missing").pipe(Effect.forkScoped)
const failedInvocation = yield* takeToolInvocation(messages)
const failedID = requireString(requireRecord(failedInvocation.params).id)
socket.send(
@@ -415,12 +405,13 @@ test("controls arbitrary tools through scoped SDK overlays", async () => {
)
expect(yield* Queue.take(messages)).toMatchObject({ id: 4, result: { ok: true } })
expect(yield* Fiber.join(failed)).toMatchObject({
result: { type: "error", value: "lookup failed" },
status: "error",
error: { message: "lookup failed" },
})
const concurrent = [
yield* settle("call_first", "first").pipe(Effect.forkScoped),
yield* settle("call_second", "second").pipe(Effect.forkScoped),
yield* executeCall("call_first", "first").pipe(Effect.forkScoped),
yield* executeCall("call_second", "second").pipe(Effect.forkScoped),
]
const invocations = [yield* takeToolInvocation(messages), yield* takeToolInvocation(messages)]
const byCall = new Map(
@@ -447,10 +438,18 @@ test("controls arbitrary tools through scoped SDK overlays", async () => {
)
expect(yield* Queue.take(messages)).toMatchObject({ id, result: { ok: true } })
}
expect((yield* Fiber.join(concurrent[0])).result).toEqual({ type: "text", value: "first result" })
expect((yield* Fiber.join(concurrent[1])).result).toEqual({ type: "text", value: "second result" })
expect(yield* Fiber.join(concurrent[0])).toMatchObject({
status: "completed",
output: "first result",
content: [{ type: "text", text: "first result" }],
})
expect(yield* Fiber.join(concurrent[1])).toMatchObject({
status: "completed",
output: "second result",
content: [{ type: "text", text: "second result" }],
})
const cancelled = yield* settle("call_cancelled", "slow").pipe(Effect.forkScoped)
const cancelled = yield* executeCall("call_cancelled", "slow").pipe(Effect.forkScoped)
const cancelledInvocation = yield* takeToolInvocation(messages)
const cancelledID = requireString(requireRecord(cancelledInvocation.params).id)
yield* Fiber.interrupt(cancelled)
@@ -474,13 +473,10 @@ test("controls arbitrary tools through scoped SDK overlays", async () => {
error: { message: expect.stringContaining("not found or already finished") },
})
const replayed = yield* settle("call_replayed", "reconnect").pipe(Effect.forkScoped)
const replayed = yield* executeCall("call_replayed", "reconnect").pipe(Effect.forkScoped)
const original = yield* takeToolInvocation(messages)
const originalID = requireString(requireRecord(original.params).id)
const replayedProgress = {
structured: { phase: "before-reconnect" },
content: [{ type: "text", text: "Still running" }],
}
const replayedProgress = { phase: "before-reconnect" }
socket.send(
JSON.stringify({
jsonrpc: "2.0",
@@ -505,7 +501,7 @@ test("controls arbitrary tools through scoped SDK overlays", async () => {
error: { message: expect.stringContaining("already attached") },
})
yield* closeSocket(socket)
const disconnected = yield* registry.materialize()
const disconnected = yield* registry.snapshot()
expect(disconnected.definitions).toContainEqual(expect.objectContaining({ name: "lookup" }))
replacement.send(
JSON.stringify({
@@ -547,7 +543,7 @@ test("controls arbitrary tools through scoped SDK overlays", async () => {
}),
)
expect(yield* Queue.take(replacementMessages)).toMatchObject({ id: 26, result: { ok: true } })
expect(progress.filter((update) => update.structured.phase === "before-reconnect")).toHaveLength(1)
expect(progress.filter((update) => update.phase === "before-reconnect")).toHaveLength(1)
replacement.send(
JSON.stringify({
jsonrpc: "2.0",
@@ -563,12 +559,13 @@ test("controls arbitrary tools through scoped SDK overlays", async () => {
}),
)
expect(yield* Queue.take(replacementMessages)).toMatchObject({ id: 10, result: { ok: true } })
expect((yield* Fiber.join(replayed)).result).toEqual({
type: "text",
value: "replayed result",
expect(yield* Fiber.join(replayed)).toMatchObject({
status: "completed",
output: "replayed result",
content: [{ type: "text", text: "replayed result" }],
})
const preserved = yield* settle("call_preserved", "same generation").pipe(Effect.forkScoped)
const preserved = yield* executeCall("call_preserved", "same generation").pipe(Effect.forkScoped)
const preservedInvocation = yield* takeToolInvocation(replacementMessages)
const preservedID = requireString(requireRecord(preservedInvocation.params).id)
replacement.send(
@@ -583,7 +580,11 @@ test("controls arbitrary tools through scoped SDK overlays", async () => {
}),
)
expect(yield* Queue.take(replacementMessages)).toMatchObject({ id: 27, result: { ok: true } })
expect((yield* Fiber.join(preserved)).result).toEqual({ type: "text", value: "preserved" })
expect(yield* Fiber.join(preserved)).toMatchObject({
status: "completed",
output: "preserved",
content: [{ type: "text", text: "preserved" }],
})
const namespaced = [
{ ...registration, name: "search", options: { namespace: "github", codemode: false } },
@@ -598,18 +599,18 @@ test("controls arbitrary tools through scoped SDK overlays", async () => {
}),
)
expect(yield* Queue.take(replacementMessages)).toMatchObject({ id: 11, result: { attached: true } })
const replaced = yield* registry.materialize()
const replaced = yield* registry.snapshot()
const replacedNames = replaced.definitions.map((definition) => definition.name)
expect(replacedNames).toEqual(expect.arrayContaining(["github_search", "web_search"]))
expect(replacedNames).not.toContain("lookup")
const secondaryReplaced = yield* ToolRegistry.Service.use((secondaryRegistry) =>
secondaryRegistry.materialize(),
secondaryRegistry.snapshot(),
).pipe(Effect.provide(secondary))
const secondaryNames = secondaryReplaced.definitions.map((definition) => definition.name)
expect(secondaryNames).toEqual(expect.arrayContaining(["github_search", "web_search"]))
expect(secondaryNames).not.toContain("lookup")
const routed = yield* replaced
.settle({
.execute({
sessionID: SessionV2.ID.make("ses_simulated_tools"),
agent: AgentV2.ID.make("build"),
messageID: SessionMessage.ID.make("msg_simulated_tools"),
@@ -636,9 +637,13 @@ test("controls arbitrary tools through scoped SDK overlays", async () => {
}),
)
expect(yield* Queue.take(replacementMessages)).toMatchObject({ id: 12, result: { ok: true } })
expect((yield* Fiber.join(routed)).result).toEqual({ type: "text", value: "routed" })
expect(yield* Fiber.join(routed)).toMatchObject({
status: "completed",
output: "routed",
content: [{ type: "text", text: "routed" }],
})
expect(
yield* materialized.settle({
yield* toolSet.execute({
sessionID: SessionV2.ID.make("ses_simulated_tools"),
agent: AgentV2.ID.make("build"),
messageID: SessionMessage.ID.make("msg_simulated_tools"),
@@ -650,7 +655,8 @@ test("controls arbitrary tools through scoped SDK overlays", async () => {
},
}),
).toMatchObject({
result: { type: "error", value: expect.stringContaining("no longer active") },
status: "error",
error: { message: expect.stringContaining("no longer active") },
})
expect(activations).toBe(2)
}).pipe(Effect.provide(primary))
+6 -8
View File
@@ -32,6 +32,7 @@ import type { Plugin } from "@opencode-ai/plugin/v2/tui"
import { createStore, produce, reconcile } from "solid-js/store"
import { createSimpleContext } from "./helper"
import { useClient } from "./client"
import { nonEmptyToolContent } from "../util/tool-display"
import { createEffect, createSignal, onCleanup } from "solid-js"
export type DataSessionStatus = "idle" | "running"
@@ -599,7 +600,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
match.time.ran = event.created
match.executed = event.data.executed
match.providerState = event.data.state
match.state = { status: "running", input: event.data.input, structured: {}, content: [] }
match.state = { status: "running", input: event.data.input, metadata: {} }
})
break
case "session.tool.progress":
@@ -609,8 +610,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
event.data.callID,
)
if (match?.state.status !== "running") return
match.state.structured = event.data.structured
match.state.content = [...event.data.content]
match.state.metadata = event.data.metadata
})
break
case "session.tool.success":
@@ -623,9 +623,8 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
match.state = {
status: "completed",
input: match.state.input,
structured: event.data.structured,
metadata: event.data.metadata,
content: [...event.data.content],
result: event.data.result,
}
match.executed = event.data.executed || match.executed === true
match.providerResultState = event.data.resultState
@@ -643,9 +642,8 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
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,
metadata: event.data.metadata,
content: event.data.content,
}
match.executed = event.data.executed || match.executed === true
match.providerResultState = event.data.resultState
+5 -8
View File
@@ -342,13 +342,13 @@ function make(state: State, tool: string, input: Record<string, JsonValue>): Ref
}
}
function startTool(state: State, ref: Ref, structured: Record<string, JsonValue> = {}): SessionMessageAssistantTool {
function startTool(state: State, ref: Ref, metadata: Record<string, JsonValue> = {}): SessionMessageAssistantTool {
state.started.add(ref.call)
const part = {
type: "tool" as const,
id: ref.call,
name: ref.tool,
state: { status: "running" as const, input: ref.input, structured, content: [] },
state: { status: "running" as const, input: ref.input, metadata },
time: { created: ref.start, ran: ref.start },
}
present(state, [toolCommit(part, ref.msg, "start")])
@@ -395,8 +395,8 @@ function doneTool(
state: {
status: "completed",
input: ref.input,
content: output.output ? [{ type: "text", text: output.output }] : [],
structured: output.metadata ?? {},
content: [{ type: "text", text: output.output }],
metadata: output.metadata,
},
time: { created: ref.start, ran: ref.start, completed: Date.now() },
}
@@ -415,8 +415,6 @@ function failTool(state: State, ref: Ref, error: string): void {
status: "error",
input: ref.input,
error: { type: "unknown", message: error },
structured: {},
content: [],
},
time: { created: ref.start, ran: ref.start, completed: Date.now() },
},
@@ -527,8 +525,7 @@ function emitTask(state: State): void {
offset: 1,
limit: 200,
},
structured: {},
content: [],
metadata: {},
},
time: { created: Date.now(), ran: Date.now() },
} satisfies SessionMessageAssistantTool
+1 -1
View File
@@ -53,7 +53,7 @@ export function permissionInfo(request: MiniPermissionRequest, directory?: strin
resources: request.resources,
metadata: request.metadata,
input: state?.status === "streaming" ? undefined : state?.input,
structured: state?.status === "streaming" ? undefined : state?.structured,
toolMetadata: state?.status === "streaming" ? undefined : state?.metadata,
},
(value) => toolPath(value, { home: true, directory }),
)
+22 -23
View File
@@ -1,7 +1,7 @@
// Current-native subagent (child Session) tracking for the mini transport.
//
// Discovers child Sessions of the active parent from four current sources:
// 1. projected subagent tool output (`structured.sessionID`) during hydration
// 1. projected subagent tool output (`metadata.sessionID`) during hydration
// 2. the current session list filtered by `parentID` during hydration
// 3. the process-local active-session map during hydration
// 4. live events from unknown sessions whose `parentID` matches the parent
@@ -33,6 +33,7 @@ import type {
StreamCommit,
} from "./types"
import { canonicalToolName, normalizeTool, toolOutputText, toolView } from "./tool"
import { toolDisplayContent } from "../util/tool-display"
const CHILD_MESSAGE_LIMIT = 80
const CHILD_FRAME_LIMIT = 80
@@ -55,7 +56,7 @@ export function toolCommit(
): StreamCommit {
const part = normalizeTool(input)
const status = part.state.status
const output = status === "streaming" ? "" : toolOutputText(part.name, part.state.content)
const output = status === "streaming" ? "" : toolOutputText(part.name, toolDisplayContent(part.state))
const partial = status === "error" && phase === "progress" && value !== undefined
const text =
status === "running" || partial
@@ -178,10 +179,10 @@ function blockerCategory(event: V2Event): "permission" | "form" | undefined {
if (event.type === "form.created" || event.type === "form.replied" || event.type === "form.cancelled") return "form"
}
function childSessionID(structured: Record<string, unknown> | undefined) {
const sessionID = text(structured?.sessionID)
function childSessionID(metadata: Record<string, unknown> | undefined) {
const sessionID = text(metadata?.sessionID)
if (!sessionID || !sessionID.startsWith("ses")) return undefined
const status = structured?.status
const status = metadata?.status
if (status !== "running" && status !== "completed") return undefined
return { sessionID, running: status === "running" }
}
@@ -199,7 +200,7 @@ function tab(child: ChildState): FooterSubagentTab {
export function createSubagentTracker(input: SubagentTrackerInput): SubagentTracker {
const children = new Map<string, ChildState>()
// Live subagent tool calls in the parent, so tool.success structured output
// Live subagent tool calls in the parent, so tool.success metadata
// can be joined with the call's input metadata.
const pendingCalls = new Map<string, Record<string, unknown>>()
// Recently resolved non-family sessions. Retention is bounded so unrelated
@@ -309,7 +310,7 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
return
}
const current = child.tools.get(key)
const output = toolOutputText(part.name, part.state.content)
const output = toolOutputText(part.name, toolDisplayContent(part.state))
if (part.state.status === "running") {
if (!current || current.part.state.status === "streaming")
setFrame(child, frame, toolCommit(part, messageID, "start", undefined, input.directory))
@@ -779,7 +780,7 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
name: current?.part.name ?? "tool",
executed: event.data.executed,
providerState: event.data.state,
state: { status: "running", input: event.data.input, structured: {}, content: [] },
state: { status: "running", input: event.data.input, metadata: {} },
time: { created: current?.part.time.created ?? event.created, ran: event.created },
},
event.data.assistantMessageID,
@@ -804,8 +805,7 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
state: {
status: "running",
input: part && part.state.status !== "streaming" ? part.state.input : {},
structured: event.data.structured,
content: event.data.content,
metadata: event.data.metadata,
},
time: {
created: part?.time.created ?? event.created,
@@ -837,18 +837,15 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
? {
status: "error",
input: part && part.state.status !== "streaming" ? part.state.input : {},
structured:
event.data.metadata ?? (part && part.state.status !== "streaming" ? part.state.structured : {}),
content: event.data.content ?? (part && part.state.status !== "streaming" ? part.state.content : []),
metadata: event.data.metadata,
content: event.data.content,
error: event.data.error,
result: event.data.result,
}
: {
status: "completed",
input: part && part.state.status !== "streaming" ? part.state.input : {},
structured: event.data.structured,
metadata: event.data.metadata,
content: event.data.content,
result: event.data.result,
},
time: {
created: part?.time.created ?? event.created,
@@ -921,7 +918,7 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
const mainTool = (item: SessionMessageAssistantTool, active?: Record<string, unknown>) => {
const tool = normalizeTool(item)
if (tool.name !== "subagent" || tool.state.status === "streaming") return
const found = childSessionID(record(tool.state.structured))
const found = childSessionID(record(tool.state.metadata))
if (!found) return
const child = admitChild(found.sessionID)
if (!child) return
@@ -967,7 +964,7 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
const key = sourceKey(event.data.assistantMessageID, event.data.callID)
const pending = pendingCalls.get(key)
if (event.type !== "session.tool.progress") pendingCalls.delete(key)
const found = childSessionID(record(event.type === "session.tool.failed" ? event.data.metadata : event.data.structured))
const found = childSessionID(record(event.data.metadata))
if (!found) return
const child = admitChild(found.sessionID)
if (!child) return
@@ -1085,11 +1082,13 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
input.emit()
},
snapshot() {
const tabs = [...children.values()].toSorted((a, b) => {
const active = Number(b.status === "running") - Number(a.status === "running")
if (active !== 0) return active
return b.lastUpdatedAt - a.lastUpdatedAt
}).map(tab)
const tabs = [...children.values()]
.toSorted((a, b) => {
const active = Number(b.status === "running") - Number(a.status === "running")
if (active !== 0) return active
return b.lastUpdatedAt - a.lastUpdatedAt
})
.map(tab)
const child = selected ? children.get(selected) : undefined
const details: Record<string, FooterSubagentDetail> =
child && !child.detailStale ? { [child.sessionID]: { commits: child.frames.map((item) => item.commit) } } : {}
+8 -12
View File
@@ -16,6 +16,7 @@ import { writeSessionOutput } from "./stream"
import { createFragmentReconciler, fragmentRef, type FragmentReconciler } from "./stream-v2.fragment"
import { createSubagentTracker, toolCommit, toolFinalPhase } from "./stream-v2.subagent"
import { normalizeTool, toolOutputText } from "./tool"
import { toolDisplayContent } from "../util/tool-display"
import type {
FooterApi,
FooterView,
@@ -558,7 +559,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
return
}
const current = state.tools.get(key)
const output = toolOutputText(part.name, part.state.content)
const output = toolOutputText(part.name, toolDisplayContent(part.state))
const prefix = current ? output.startsWith(current.output) : false
const version = current && !prefix ? current.version + 1 : (current?.version ?? 0)
const delta = current && prefix ? output.slice(current.output.length) : output
@@ -670,8 +671,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
{
kind: "reasoning",
source: "reasoning",
text:
update.previous.length === 0 ? `Thinking: ${item.text}` : item.text.slice(update.previous.length),
text: update.previous.length === 0 ? `Thinking: ${item.text}` : item.text.slice(update.previous.length),
phase: "progress",
messageID: message.id,
partID: fragment.partID,
@@ -1042,7 +1042,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
name: current?.part.name ?? "tool",
executed: event.data.executed,
providerState: event.data.state,
state: { status: "running", input: event.data.input, structured: {}, content: [] },
state: { status: "running", input: event.data.input, metadata: {} },
time: { created: current?.part.time.created ?? event.created, ran: event.created },
}
renderTool(event.data.assistantMessageID, item)
@@ -1062,8 +1062,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
state: {
status: "running",
input: part && part.state.status !== "streaming" ? part.state.input : {},
structured: event.data.structured,
content: event.data.content,
metadata: event.data.metadata,
},
time: { created: part?.time.created ?? event.created, ran: part?.time.ran ?? event.created },
})
@@ -1084,18 +1083,15 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
? {
status: "error",
input: part && part.state.status !== "streaming" ? part.state.input : {},
structured:
event.data.metadata ?? (part && part.state.status !== "streaming" ? part.state.structured : {}),
content: event.data.content ?? (part && part.state.status !== "streaming" ? part.state.content : []),
metadata: event.data.metadata,
content: event.data.content,
error: event.data.error,
result: event.data.result,
}
: {
status: "completed",
input: part && part.state.status !== "streaming" ? part.state.input : {},
structured: event.data.structured,
metadata: event.data.metadata,
content: event.data.content,
result: event.data.result,
},
time: { created: part?.time.created ?? event.created, ran: part?.time.ran, completed: event.created },
}
+1 -1
View File
@@ -75,7 +75,7 @@ function traceCommit(commit: StreamCommit) {
state: {
status: commit.part.state.status,
input: summarize(commit.part.state.input),
structured: "structured" in commit.part.state ? summarize(commit.part.state.structured) : undefined,
metadata: "metadata" in commit.part.state ? summarize(commit.part.state.metadata) : undefined,
content: "content" in commit.part.state ? summarize(commit.part.state.content) : undefined,
error: "error" in commit.part.state ? summarize(commit.part.state.error) : undefined,
},
+2 -1
View File
@@ -1,2 +1,3 @@
export { toolInlineInfo, toolOutputText } from "./tool"
export { readDisplayText, toolInlineInfo, toolOutputText } from "./tool"
export { nonEmptyToolContent } from "../util/tool-display"
export type { MiniToolPart } from "./types"
+38 -11
View File
@@ -21,6 +21,7 @@ import {
canonicalToolName,
finiteNumber,
primitiveInputSummary,
toolDisplayContent,
toolDisplayMetadata,
webSearchProviderLabel,
} from "../util/tool-display"
@@ -157,10 +158,36 @@ function text(v: unknown): string {
return typeof v === "string" ? v : ""
}
export function toolOutputText(name: string, content: ReadonlyArray<{ type: string; text?: string }>) {
export function toolOutputText(name: string, content: ReadonlyArray<{ type: string; text?: string }> | undefined) {
if (!content) return ""
// V2 shell content appends model-only status after the user-visible command output.
if (canonicalToolName(name) === "shell") return content.find((item) => item.type === "text")?.text ?? ""
return content.flatMap((item) => (item.type === "text" && item.text ? [item.text] : [])).join("\n")
const joined = content.flatMap((item) => (item.type === "text" && item.text ? [item.text] : [])).join("\n")
if (canonicalToolName(name) === "read") return readDisplayText(joined) ?? joined
return joined
}
/** Read's model content is a JSON page envelope; unwrap the human-facing text. */
export function readDisplayText(text: string): string | undefined {
if (!text.startsWith("{")) return undefined
const parsed = (() => {
try {
return JSON.parse(text) as unknown
} catch {
return undefined
}
})()
const envelope = dict(parsed)
if (typeof envelope.content === "string" && (envelope.type === "text-page" || envelope.encoding === "utf8"))
return envelope.content
if (!Array.isArray(envelope.entries)) return undefined
return envelope.entries
.flatMap((entry): string[] => {
if (typeof entry === "string") return [entry]
const path = dict(entry).path
return typeof path === "string" ? [path] : []
})
.join("\n")
}
function normalizeInput(name: string, value: unknown) {
@@ -202,16 +229,16 @@ function normalizeFile(value: unknown): PatchFile | undefined {
}
}
function normalizeStructured(name: string, value: unknown) {
const structured = dict(value)
const files = list(structured.files).flatMap((item) => {
function normalizeMetadata(name: string, value: unknown) {
const metadata = dict(value)
const files = list(metadata.files).flatMap((item) => {
const file = normalizeFile(item)
return file ? [file] : []
})
const sessionID = text(structured.sessionID) || text(structured.sessionId)
const sessionID = text(metadata.sessionID) || text(metadata.sessionId)
return {
...structured,
...(["edit", "patch"].includes(name) && Array.isArray(structured.files) ? { files } : {}),
...metadata,
...(["edit", "patch"].includes(name) && Array.isArray(metadata.files) ? { files } : {}),
...(name === "subagent" && sessionID ? { sessionID } : {}),
}
}
@@ -225,7 +252,7 @@ export function normalizeTool(tool: SessionMessageAssistantTool): SessionMessage
state: {
...tool.state,
input: normalizeInput(name, tool.state.input),
structured: normalizeStructured(name, toolDisplayMetadata(tool.state)),
metadata: normalizeMetadata(name, toolDisplayMetadata(tool.state)),
},
} as SessionMessageAssistantTool
}
@@ -1089,13 +1116,13 @@ function frame(part: SessionMessageAssistantTool, directory?: string): ToolFrame
output: "",
time: { start: tool.time.created },
}
const output = toolOutputText(tool.name, tool.state.content)
const output = toolOutputText(tool.name, toolDisplayContent(tool.state))
return {
directory,
raw: output,
name: tool.name,
input: normalizeInput(tool.name, tool.state.input),
meta: normalizeStructured(tool.name, tool.state.structured),
meta: normalizeMetadata(tool.name, tool.state.metadata),
state: dict(tool.state),
status: tool.state.status,
error: tool.state.status === "error" ? tool.state.error.message : "",
+63 -17
View File
@@ -42,6 +42,7 @@ import {
canonicalToolName,
finiteNumber,
primitiveInputSummary,
toolDisplayContent,
toolDisplayMetadata,
webSearchProviderLabel,
} from "../../util/tool-display"
@@ -2046,7 +2047,7 @@ function ToolPart(props: { part: SessionMessageAssistantTool }) {
},
get output() {
if (props.part.state.status === "streaming") return undefined
return props.part.state.content
return toolDisplayContent(props.part.state)
.flatMap((content) => (content.type === "text" ? [content.text] : [content.name ?? content.uri]))
.join("\n")
},
@@ -2448,6 +2449,8 @@ function BlockToolContent(props: BlockToolProps & { borderColor: RGBA }) {
)
}
const SHELL_DISPLAY_LIMIT = 1024 * 1024
function Shell(props: ToolProps) {
const { themeV2 } = useTheme()
const ctx = use()
@@ -2459,6 +2462,7 @@ function Shell(props: ToolProps) {
})
const color = createMemo(() => (permission() ? themeV2.text.feedback.warning.default : themeV2.text.default))
const shellID = createMemo(() => stringValue(props.metadata.shellID))
const background = createMemo(() => Boolean(shellID()) && props.part.state.status !== "running")
const backgroundRunning = createMemo(() => {
const id = shellID()
return Boolean(id && data.shell.get(id))
@@ -2467,31 +2471,73 @@ function Shell(props: ToolProps) {
const command = createMemo(() => stringValue(props.input.command))
const [expanded, setExpanded] = createSignal(false)
const [backgroundOutput, setBackgroundOutput] = createSignal("")
const [outputTruncated, setOutputTruncated] = createSignal(false)
let loading = false
const loadBackgroundOutput = async () => {
let drainRequested = false
let cursor = 0
let wasRunning = false
const loadBackgroundOutput = async (drain = false) => {
const id = shellID()
if (!id || loading) return
if (!id) return
if (loading) {
if (drain) drainRequested = true
return
}
loading = true
const location = data.session.get(ctx.sessionID)?.location
await client.api.shell
.output({
id,
limit: 1024 * 1024,
location: location ? { directory: location.directory, workspace: location.workspaceID } : undefined,
})
.then((response) => setBackgroundOutput(stripAnsi(response.data.output.trim())))
.catch(() => undefined)
do {
const response = await client.api.shell
.output({
id,
cursor,
limit: SHELL_DISPLAY_LIMIT,
location: location ? { directory: location.directory, workspace: location.workspaceID } : undefined,
})
.catch(() => undefined)
if (!response) break
if (response.data.output)
setBackgroundOutput((output) => {
const next = stripAnsi(output + response.data.output)
if (next.length <= SHELL_DISPLAY_LIMIT) return next
setOutputTruncated(true)
return next.slice(-SHELL_DISPLAY_LIMIT)
})
if (response.data.cursor <= cursor) break
cursor = response.data.cursor
if (!drain || cursor >= response.data.size) break
const tail = Math.max(cursor, response.data.size - SHELL_DISPLAY_LIMIT)
if (tail > cursor) {
cursor = tail
setOutputTruncated(true)
}
} while (true)
loading = false
if (drainRequested) {
drainRequested = false
void loadBackgroundOutput(true)
}
}
createEffect(() => {
if (!expanded() || !backgroundRunning()) return
const running = backgroundRunning()
if (!running) {
if (wasRunning) void loadBackgroundOutput(true)
wasRunning = false
return
}
wasRunning = true
if (background() && !expanded()) return
void loadBackgroundOutput()
const interval = setInterval(() => void loadBackgroundOutput(), 1_000)
onCleanup(() => clearInterval(interval))
})
const output = createMemo(() => {
if (props.part.state.status === "streaming") return ""
if (shellID()) return expanded() ? backgroundOutput() : ""
const content = props.part.state.content[0]
if (shellID()) {
if (background() && !expanded()) return ""
const text = backgroundOutput().trim()
return outputTruncated() ? `[earlier output omitted]\n${text}` : text
}
const content = toolDisplayContent(props.part.state)[0]
return stripAnsi(content?.type === "text" ? content.text.trim() : "")
})
const maxLines = 10
@@ -2507,7 +2553,7 @@ function Shell(props: ToolProps) {
const toggle = () => {
const next = !expanded()
setExpanded(next)
if (next) void loadBackgroundOutput()
if (next) void loadBackgroundOutput(!backgroundRunning())
}
return (
@@ -2538,7 +2584,7 @@ function Shell(props: ToolProps) {
</Spinner>
</Show>
</Show>
<Show when={shellID()}>
<Show when={background()}>
<StatusBadge>Background</StatusBadge>
</Show>
</box>
@@ -3054,7 +3100,7 @@ function formatSessionTranscript(session: SessionInfo, messages: SessionMessageI
? item.state.error.message
: item.state.status === "streaming"
? ""
: item.state.content
: toolDisplayContent(item.state)
.flatMap((entry) => (entry.type === "text" ? [entry.text] : [entry.name ?? entry.uri]))
.join("\n")
return [`**Tool: ${item.name}**\n\n**Input:**\n\`\`\`json\n${input}\n\`\`\`\n\n${output}`]
@@ -118,14 +118,14 @@ export function PermissionPrompt(props: { request: PermissionV2Request; director
const source = createMemo(() => {
const tool = props.request.source
if (!tool) return { input: undefined, structured: undefined }
if (!tool) return { input: undefined, metadata: undefined }
const message = data.session.message.get(props.request.sessionID, tool.messageID)
if (message?.type !== "assistant") return { input: undefined, structured: undefined }
if (message?.type !== "assistant") return { input: undefined, metadata: undefined }
const part = message.content.find((part) => part.type === "tool" && part.id === tool.callID)
if (part?.type === "tool" && part.state.status !== "streaming") {
return { input: part.state.input, structured: part.state.structured }
return { input: part.state.input, metadata: part.state.metadata }
}
return { input: undefined, structured: undefined }
return { input: undefined, metadata: undefined }
})
const { themeV2 } = useTheme()
@@ -182,7 +182,7 @@ export function PermissionPrompt(props: { request: PermissionV2Request; director
resources: props.request.resources,
metadata: props.request.metadata,
input: source().input,
structured: source().structured,
toolMetadata: source().metadata,
},
pathFormatter.format,
)
+2 -2
View File
@@ -17,7 +17,7 @@ export type PermissionPresentationInput = {
resources: ReadonlyArray<unknown>
metadata?: unknown
input?: unknown
structured?: unknown
toolMetadata?: unknown
}
export function permissionPresentation(
@@ -26,7 +26,7 @@ export function permissionPresentation(
): PermissionPresentation {
const action = canonicalToolName(source.action)
const input = normalizeInput(action, source.input)
const metadata = { ...dict(source.structured), ...dict(source.metadata) }
const metadata = { ...dict(source.toolMetadata), ...dict(source.metadata) }
const resources = source.resources.filter((item): item is string => typeof item === "string")
if (action === "edit") {

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