From 74e92f73e034dfcfbd092ae12d8ff2ea2300414e Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" <219766164+opencode-agent[bot]@users.noreply.github.com> Date: Thu, 23 Jul 2026 12:32:33 -0500 Subject: [PATCH 01/11] refactor(ai): remove unused response format (#38540) Co-authored-by: Aiden Cline --- packages/ai/src/llm.ts | 2 +- packages/ai/src/schema/messages.ts | 9 --------- packages/core/src/aisdk.ts | 7 ------- 3 files changed, 1 insertion(+), 17 deletions(-) diff --git a/packages/ai/src/llm.ts b/packages/ai/src/llm.ts index e4781d8608..ecdf30ae47 100644 --- a/packages/ai/src/llm.ts +++ b/packages/ai/src/llm.ts @@ -81,7 +81,7 @@ const GENERATE_OBJECT_TOOL_NAME = "generate_object" const GENERATE_OBJECT_TOOL_DESCRIPTION = "Return the structured result by calling this tool." -type GenerateObjectBase = Omit +type GenerateObjectBase = Omit export class GenerateObjectResponse { constructor( diff --git a/packages/ai/src/schema/messages.ts b/packages/ai/src/schema/messages.ts index 4a9de3a735..e6617ddc9e 100644 --- a/packages/ai/src/schema/messages.ts +++ b/packages/ai/src/schema/messages.ts @@ -261,13 +261,6 @@ export namespace ToolChoice { } } -export const ResponseFormat = Schema.Union([ - Schema.Struct({ type: Schema.Literal("text") }), - Schema.Struct({ type: Schema.Literal("json"), schema: JsonSchema }), - Schema.Struct({ type: Schema.Literal("tool"), tool: ToolDefinition }), -]).pipe(Schema.toTaggedUnion("type")) -export type ResponseFormat = Schema.Schema.Type - export class LLMRequest extends Schema.Class("LLM.Request")({ id: Schema.optional(Schema.String), model: ModelSchema, @@ -278,7 +271,6 @@ export class LLMRequest extends Schema.Class("LLM.Request")({ generation: Schema.optional(GenerationOptions), providerOptions: Schema.optional(ProviderOptions), http: Schema.optional(HttpOptions), - responseFormat: Schema.optional(ResponseFormat), cache: Schema.optional(CachePolicy), metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)), }) {} @@ -296,7 +288,6 @@ export namespace LLMRequest { generation: request.generation, providerOptions: request.providerOptions, http: request.http, - responseFormat: request.responseFormat, cache: request.cache, metadata: request.metadata, }) diff --git a/packages/core/src/aisdk.ts b/packages/core/src/aisdk.ts index 6d749b8526..33a4d670af 100644 --- a/packages/core/src/aisdk.ts +++ b/packages/core/src/aisdk.ts @@ -422,7 +422,6 @@ function callOptions(request: LLMRequest): LanguageModelV3CallOptions { presencePenalty: request.generation?.presencePenalty, frequencyPenalty: request.generation?.frequencyPenalty, seed: request.generation?.seed, - responseFormat: responseFormat(request), tools: request.tools.map(tool), toolChoice: toolChoice(request.toolChoice), headers: request.http?.headers, @@ -527,12 +526,6 @@ function toolChoice(input: LLMRequest["toolChoice"]): LanguageModelV3ToolChoice return { type: input.type } } -function responseFormat(request: LLMRequest): LanguageModelV3CallOptions["responseFormat"] { - if (request.responseFormat?.type === "json") - return { type: "json", schema: request.responseFormat.schema as JSONSchema7 } - if (request.responseFormat) return { type: "text" } -} - function providerOptions(input: LLMRequest["providerOptions"]): SharedV3ProviderOptions | undefined { if (!input) return undefined return Object.fromEntries(Object.entries(input).map(([key, value]) => [key, jsonObject(value)])) From ad596fb42bfbf29a2585babbae1e987c54240eea Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" <219766164+opencode-agent[bot]@users.noreply.github.com> Date: Thu, 23 Jul 2026 12:34:24 -0500 Subject: [PATCH 02/11] chore(core): upgrade fff to 0.10.1 (#38545) Co-authored-by: Aiden Cline --- bun.lock | 22 +++++++++--------- package.json | 1 - packages/core/package.json | 2 +- patches/@ff-labs%2Ffff-bun@0.9.3.patch | 31 -------------------------- 4 files changed, 13 insertions(+), 43 deletions(-) delete mode 100644 patches/@ff-labs%2Ffff-bun@0.9.3.patch diff --git a/bun.lock b/bun.lock index 72db1b9e92..24e3c97c4e 100644 --- a/bun.lock +++ b/bun.lock @@ -362,7 +362,7 @@ "@aws-sdk/credential-providers": "3.1057.0", "@effect/platform-node": "catalog:", "@effect/sql-sqlite-bun": "catalog:", - "@ff-labs/fff-bun": "0.9.4", + "@ff-labs/fff-bun": "0.10.1", "@lydell/node-pty": "catalog:", "@modelcontextprotocol/sdk": "1.29.0", "@opencode-ai/ai": "workspace:*", @@ -1641,23 +1641,25 @@ "@fastify/busboy": ["@fastify/busboy@2.1.1", "", {}, "sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA=="], - "@ff-labs/fff-bin-darwin-arm64": ["@ff-labs/fff-bin-darwin-arm64@0.9.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-xyivu2xB++O5xXDx5Qm50JsU2aXt8YgXlGVhH/HE7UMYDrE6L6f1RYdYs8Y0bn0D3D0+bFBrN5ELPszK9E4Wbw=="], + "@ff-labs/fff-bin-android-arm64": ["@ff-labs/fff-bin-android-arm64@0.10.1", "", { "os": "android", "cpu": "arm64" }, "sha512-6Bsaa6yKEd2HV1M2WtqSYhoZucKYffIUms6GYoPN48QHP8hZgO8GXJ85/JDxKlkCJsN2hw1ROiwQK0+xUHYhFQ=="], - "@ff-labs/fff-bin-darwin-x64": ["@ff-labs/fff-bin-darwin-x64@0.9.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-xLooAhCnTDCipPSMMZz7kGF3lhRHx6aP5fb6DJ0Ipyw/w/UWJb+xITJFszUl/QnIBoJ/qjDc93/FZMo1dk6gVA=="], + "@ff-labs/fff-bin-darwin-arm64": ["@ff-labs/fff-bin-darwin-arm64@0.10.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-7yUP+56sG3UTrLg7eepOD16yM2dgiD7g6Ase2XWQB7oXwLV7mBMylPKIli2pP3kHzfw9K+BS3WAwHKHJ2QhxYw=="], - "@ff-labs/fff-bin-linux-arm64-gnu": ["@ff-labs/fff-bin-linux-arm64-gnu@0.9.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-m5+8vA+1veaUUWonwva1WsU6m1HRm8CpYUzr06KDB65mewlmPbqz7+Fh7hjEfiD8C4mHVHe6RysULvAH1yhsdw=="], + "@ff-labs/fff-bin-darwin-x64": ["@ff-labs/fff-bin-darwin-x64@0.10.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-9zb+P1xtyqu/jVklm5RKF2zm9doRRsBNbkF/a8S4aSqSJoNlQR8ZF7C129fzOLfffVAjjgcO2l8oJgxOzHYiwQ=="], - "@ff-labs/fff-bin-linux-arm64-musl": ["@ff-labs/fff-bin-linux-arm64-musl@0.9.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-EMeWm7CSTVkizy4ZEzUkLDP024tVcbCUthduuIhekFQRDsiaAze0YboIylWb9HBHJCZlCCoZrWAl4nnJbsX7AA=="], + "@ff-labs/fff-bin-linux-arm64-gnu": ["@ff-labs/fff-bin-linux-arm64-gnu@0.10.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-KTwr9CUfCJv0vtG2xG+nMxDae/2NJY2/oVmtgSvTnH9baI6JprqsFGphbukx7mdW/QMPwBiYtoO8ZGoC7i92jA=="], - "@ff-labs/fff-bin-linux-x64-gnu": ["@ff-labs/fff-bin-linux-x64-gnu@0.9.4", "", { "os": "linux", "cpu": "x64" }, "sha512-pglE0uLkhnlE6bStXqfgUjYTSj+2sVwXaPfoA0QksidAsQor6NRt8004mygzC9DPubgHq5B9QezPfEwigKaP9Q=="], + "@ff-labs/fff-bin-linux-arm64-musl": ["@ff-labs/fff-bin-linux-arm64-musl@0.10.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-oznmSpV+zjiAPiwqbA4y+SAsMaYvDhGllHiT9L4ebdQnSOfcQzlUwvo45OhBPwHBt47tIois4bkF/MITlDk54A=="], - "@ff-labs/fff-bin-linux-x64-musl": ["@ff-labs/fff-bin-linux-x64-musl@0.9.4", "", { "os": "linux", "cpu": "x64" }, "sha512-VNKxgl8qs3aTfXViX7lqRK1aLu311h8dtBFqG4Scv+9Oi7WprybUp5L7IZ8sxKERaDAaiJMXHodXa1c90QdK8w=="], + "@ff-labs/fff-bin-linux-x64-gnu": ["@ff-labs/fff-bin-linux-x64-gnu@0.10.1", "", { "os": "linux", "cpu": "x64" }, "sha512-KDpl8lwSEOauP/6FSJIvnARzE+ILm2rVIRQAio9dc5nn56EvlsryBWry0c5V0Tbw1PV0lNrZ+bzcIiPuTacvzw=="], - "@ff-labs/fff-bin-win32-arm64": ["@ff-labs/fff-bin-win32-arm64@0.9.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-uFEt0aNL54vQxq1ivjxRuo+thnhS4wLqa4INl4VXnXJUmwB42XXxD+gsj7vzhBLLx4cFf0aWgy/+TVDR8yjZtQ=="], + "@ff-labs/fff-bin-linux-x64-musl": ["@ff-labs/fff-bin-linux-x64-musl@0.10.1", "", { "os": "linux", "cpu": "x64" }, "sha512-mZCpojVtGNDr/wdCUEAHdfrl6qORvKHv3Pw35TaOdshnG4wocoA5bBKTGj/zmAU11o0GMK4V+wUhdNrgqoE10w=="], - "@ff-labs/fff-bin-win32-x64": ["@ff-labs/fff-bin-win32-x64@0.9.4", "", { "os": "win32", "cpu": "x64" }, "sha512-Yd2Eyxj+slWv+0QDW9/xBpu9FXq+hwD0rXQD5184/88d+xwWCLKhEP2w8I6OO9XCg+kLT79UJb+k0WwXUtBtMw=="], + "@ff-labs/fff-bin-win32-arm64": ["@ff-labs/fff-bin-win32-arm64@0.10.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-SbT76ETXC5AgV9J8sVDozKG8wzrrxsOn8lbBprlOtx+O5V5EYmS1W7BlsatTHy6ddQ3uU5oOYO04PWZBuP6wXg=="], - "@ff-labs/fff-bun": ["@ff-labs/fff-bun@0.9.4", "", { "optionalDependencies": { "@ff-labs/fff-bin-darwin-arm64": "0.9.4", "@ff-labs/fff-bin-darwin-x64": "0.9.4", "@ff-labs/fff-bin-linux-arm64-gnu": "0.9.4", "@ff-labs/fff-bin-linux-arm64-musl": "0.9.4", "@ff-labs/fff-bin-linux-x64-gnu": "0.9.4", "@ff-labs/fff-bin-linux-x64-musl": "0.9.4", "@ff-labs/fff-bin-win32-arm64": "0.9.4", "@ff-labs/fff-bin-win32-x64": "0.9.4" }, "os": [ "linux", "win32", "darwin", ], "cpu": [ "x64", "arm64", ] }, "sha512-7HUraaK/g5dStAnuKAuzsXVOQvqqX0ylo5G+DxYwsCjCDc42bjoEAAHqz/3Sn3raUNw97KMoz87XR9QyrLEfVw=="], + "@ff-labs/fff-bin-win32-x64": ["@ff-labs/fff-bin-win32-x64@0.10.1", "", { "os": "win32", "cpu": "x64" }, "sha512-dcpHUCBEZoXKQCdKa3bADfgcNyeyfM8tatXrILqIFYi9GL8E4GnzKx1rGkgP5ukVtuj0WuoJyyqSvGjpJPIT8w=="], + + "@ff-labs/fff-bun": ["@ff-labs/fff-bun@0.10.1", "", { "optionalDependencies": { "@ff-labs/fff-bin-android-arm64": "0.10.1", "@ff-labs/fff-bin-darwin-arm64": "0.10.1", "@ff-labs/fff-bin-darwin-x64": "0.10.1", "@ff-labs/fff-bin-linux-arm64-gnu": "0.10.1", "@ff-labs/fff-bin-linux-arm64-musl": "0.10.1", "@ff-labs/fff-bin-linux-x64-gnu": "0.10.1", "@ff-labs/fff-bin-linux-x64-musl": "0.10.1", "@ff-labs/fff-bin-win32-arm64": "0.10.1", "@ff-labs/fff-bin-win32-x64": "0.10.1" }, "os": [ "!aix", "!sunos", "!freebsd", "!openbsd", ], "cpu": [ "x64", "arm64", ] }, "sha512-9oUCxypGbf2q3vNfKZ31wdzt5KqjhA9S6TwQaFol/j1lkSHVGvtIa3RvdGHPs1UUuvR/MO7b6pHj054BR9bXPQ=="], "@floating-ui/core": ["@floating-ui/core@1.8.0", "", { "dependencies": { "@floating-ui/utils": "^0.2.12" } }, "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ=="], diff --git a/package.json b/package.json index 7bb37f946f..2110c4d9af 100644 --- a/package.json +++ b/package.json @@ -155,7 +155,6 @@ "@types/node": "catalog:" }, "patchedDependencies": { - "@ff-labs/fff-bun@0.9.3": "patches/@ff-labs%2Ffff-bun@0.9.3.patch", "@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch", "@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch", "@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch", diff --git a/packages/core/package.json b/packages/core/package.json index cf54590d52..7756c08d7e 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -91,7 +91,7 @@ "@effect/sql-sqlite-bun": "catalog:", "@lydell/node-pty": "catalog:", "@modelcontextprotocol/sdk": "1.29.0", - "@ff-labs/fff-bun": "0.9.4", + "@ff-labs/fff-bun": "0.10.1", "@opencode-ai/codemode": "workspace:*", "@opencode-ai/effect-drizzle-sqlite": "workspace:*", "@opencode-ai/effect-sqlite-node": "workspace:*", diff --git a/patches/@ff-labs%2Ffff-bun@0.9.3.patch b/patches/@ff-labs%2Ffff-bun@0.9.3.patch deleted file mode 100644 index 23a7dd54fb..0000000000 --- a/patches/@ff-labs%2Ffff-bun@0.9.3.patch +++ /dev/null @@ -1,31 +0,0 @@ -diff --git a/src/download.ts b/src/download.ts -index 3454256..6dca25a 100644 ---- a/src/download.ts -+++ b/src/download.ts -@@ -7,7 +7,7 @@ - */ - -+declare const FFF_LIBC: "gnu" | "musl"; - import { existsSync } from "node:fs"; --import { createRequire } from "node:module"; - import { dirname, join } from "node:path"; - import { fileURLToPath } from "node:url"; - import { getLibFilename, getNpmPackageName } from "./platform"; -@@ -54,14 +54,10 @@ export function binaryExists(): boolean { - * in the same directory. - */ - function resolveFromNpmPackage(): string | null { -- const packageName = getNpmPackageName(); -- - try { -- // Use createRequire to resolve the platform package's location -- const require = createRequire(join(getPackageDir(), "package.json")); -- const packageJsonPath = require.resolve(`${packageName}/package.json`); -- const packageDir = dirname(packageJsonPath); -- const binaryPath = join(packageDir, getLibFilename()); -+ const binaryPath = require( -+ `@ff-labs/fff-bin-${process.platform === "linux" ? `linux-${process.arch}-${typeof FFF_LIBC === "string" ? FFF_LIBC : getNpmPackageName().endsWith("musl") ? "musl" : "gnu"}` : `${process.platform}-${process.arch}`}/${process.platform === "win32" ? "fff_c.dll" : process.platform === "darwin" ? "libfff_c.dylib" : "libfff_c.so"}`, -+ ); - - if (existsSync(binaryPath)) { - return binaryPath; From 360e7b412d64ac6ad8bd63415dbf68af0a47c60a Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" <219766164+opencode-agent[bot]@users.noreply.github.com> Date: Thu, 23 Jul 2026 13:37:16 -0400 Subject: [PATCH 03/11] feat(tui): expose debug settings (#38546) Co-authored-by: James Long --- packages/tui/src/component/dialog-config.tsx | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/packages/tui/src/component/dialog-config.tsx b/packages/tui/src/component/dialog-config.tsx index 8ff9db50c7..1ca585ec13 100644 --- a/packages/tui/src/component/dialog-config.tsx +++ b/packages/tui/src/component/dialog-config.tsx @@ -222,6 +222,14 @@ const settings: Setting[] = [ values: [false, true], labels: ["off", "on"], }, + { + title: "Turn token usage", + category: "Debug", + path: ["debug", "turn_tokens"], + default: false, + values: [false, true], + labels: ["off", "on"], + }, ] export function DialogConfig() { From 18fccac6ff8b6e27786869f2d03d86e1b6b1ac1a Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" <219766164+opencode-agent[bot]@users.noreply.github.com> Date: Thu, 23 Jul 2026 13:59:51 -0400 Subject: [PATCH 04/11] fix(tui): preserve first message in new sessions (#38542) Co-authored-by: Kit Langton Co-authored-by: James Long <17031+jlongster@users.noreply.github.com> --- packages/tui/src/context/data.tsx | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/tui/src/context/data.tsx b/packages/tui/src/context/data.tsx index ddf3823e75..a5a18ff077 100644 --- a/packages/tui/src/context/data.tsx +++ b/packages/tui/src/context/data.tsx @@ -298,6 +298,12 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ case "session.created": result.session.invalidate(event.data.sessionID) void result.session.sync(event.data.sessionID) + // Band-aid: a newly created session starts empty, so live events can be its source of truth. + // Fetching pending inputs and projected messages separately lets promotion move an input between snapshots, + // causing both requests to miss it and overwrite event-built state. Skip those racy initial reads until + // hydration can load pending and projected messages atomically. + sync.complete(`session.pending:${event.data.sessionID}`) + sync.complete(`session.message:${event.data.sessionID}`) break case "session.deleted": removeSession(event.data.sessionID) From 2c814120c7f918237812510655fed067ccb902a3 Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Thu, 23 Jul 2026 13:52:20 -0500 Subject: [PATCH 05/11] fix(ai): keep tools when Anthropic tool_choice is none (#38553) --- .../ai/src/protocols/anthropic-messages.ts | 10 ++++--- .../test/provider/anthropic-messages.test.ts | 28 +++++++++++++++++++ 2 files changed, 34 insertions(+), 4 deletions(-) diff --git a/packages/ai/src/protocols/anthropic-messages.ts b/packages/ai/src/protocols/anthropic-messages.ts index f5b9ea67d9..cc03dfa65b 100644 --- a/packages/ai/src/protocols/anthropic-messages.ts +++ b/packages/ai/src/protocols/anthropic-messages.ts @@ -159,7 +159,7 @@ const AnthropicTool = Schema.Struct({ type AnthropicTool = Schema.Schema.Type 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,7 @@ const lowerTool = (breakpoints: Cache.Breakpoints, tool: ToolDefinition, inputSc const lowerToolChoice = (toolChoice: NonNullable) => ProviderShared.matchToolChoice("Anthropic Messages", toolChoice, { auto: () => ({ type: "auto" as const }), - none: () => undefined, + none: () => ({ type: "none" as const }), required: () => ({ type: "any" as const }), tool: (name) => ({ type: "tool" as const, name }), }) @@ -542,7 +542,6 @@ const outputConfig = (request: LLMRequest) => { } const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (request: LLMRequest) { - const toolChoice = request.toolChoice ? yield* lowerToolChoice(request.toolChoice) : undefined const generation = request.generation const toolSchemaCompatibility = request.model.compatibility?.toolSchema const outputLimit = request.model.defaults?.limits?.output ?? request.model.route.defaults.limits?.output ?? 4096 @@ -551,7 +550,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( @@ -560,6 +559,9 @@ const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (reques ToolSchemaProjection.modelCompatibility(tool.inputSchema, toolSchemaCompatibility), ), ) + // Anthropic rejects tool_choice when tools are absent; "none" is only meaningful with tools present. + const toolChoice = + tools === undefined || !request.toolChoice ? undefined : yield* lowerToolChoice(request.toolChoice) const system = request.system.length === 0 ? undefined diff --git a/packages/ai/test/provider/anthropic-messages.test.ts b/packages/ai/test/provider/anthropic-messages.test.ts index 30b3c1b6b8..da8bb34287 100644 --- a/packages/ai/test/provider/anthropic-messages.test.ts +++ b/packages/ai/test/provider/anthropic-messages.test.ts @@ -235,6 +235,34 @@ describe("Anthropic Messages route", () => { }), ) + it.effect("keeps tools and sends tool_choice none", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare( + LLM.request({ + id: "req_tool_choice_none", + model, + tools: [{ name: "lookup", description: "Look things up", inputSchema: { type: "object", properties: {} } }], + messages: [ + Message.user("What is the weather?"), + Message.assistant([ToolCallPart.make({ id: "call_1", name: "lookup", input: { query: "weather" } })]), + Message.tool({ id: "call_1", name: "lookup", result: { forecast: "sunny" } }), + ], + toolChoice: "none", + cache: "none", + }), + ) + + expect(prepared.body.tools).toEqual([ + { + name: "lookup", + description: "Look things up", + input_schema: { type: "object", properties: {} }, + }, + ]) + expect(prepared.body.tool_choice).toEqual({ type: "none" }) + }), + ) + // Regression: read tool results must stay structured so base64 media data is // not JSON-stringified into `tool_result.content`. it.effect("lowers media tool-result content as structured blocks", () => From 2a9f8e3a2cb78d28f345e5f45f9ba1c222068e8f Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" <219766164+opencode-agent[bot]@users.noreply.github.com> Date: Thu, 23 Jul 2026 18:58:02 +0000 Subject: [PATCH 06/11] fix(tui): manage focus in devtools panels (#38555) Co-authored-by: James Long <17031+jlongster@users.noreply.github.com> --- packages/tui/src/component/devtools-bar.tsx | 25 +++++++++++++++++---- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/packages/tui/src/component/devtools-bar.tsx b/packages/tui/src/component/devtools-bar.tsx index 03373110e2..45c031069c 100644 --- a/packages/tui/src/component/devtools-bar.tsx +++ b/packages/tui/src/component/devtools-bar.tsx @@ -1,4 +1,4 @@ -import { TextAttributes } from "@opentui/core" +import { TextAttributes, type Renderable } from "@opentui/core" import { TimeToFirstDraw, useRenderer, useTerminalDimensions } from "@opentui/solid" import { open } from "node:fs/promises" import { tmpdir } from "node:os" @@ -33,6 +33,7 @@ export function DevToolsBar() { const plugins = usePlugin() const theme = useTheme() const keymap = Keymap.use() + const renderer = useRenderer() const dimensions = useTerminalDimensions() const { themeV2, mode, supports, setMode } = theme const elevatedTheme = theme.contextual("elevated").themeV2 @@ -41,6 +42,7 @@ export function DevToolsBar() { const [dumpPath, setDumpPath] = createSignal() const [dumpError, setDumpError] = createSignal() const [frontendSamples, setFrontendSamples] = createSignal([]) + let focus: Renderable | null const connected = createMemo(() => client.connection.status() === "connected") const serverIndicator = createMemo(() => connectionIndicator(client.connection.status(), client.connection.attempt())) const themePerformance = createMemo( @@ -54,7 +56,22 @@ export function DevToolsBar() { address: info.urls[0] ? new URL(info.urls[0]).host : "Unknown", } }) - const toggle = (next: Panel) => setPanel((current) => (current === next ? undefined : next)) + const close = () => { + setPanel() + setTimeout(() => { + if (panel() || !focus || focus.isDestroyed) return + focus.focus() + focus = null + }, 1) + } + const toggle = (next: Panel) => { + if (panel() === next) return close() + if (!panel()) { + focus = renderer.currentFocusedRenderable + focus?.blur() + } + setPanel(next) + } const nextMode = () => (mode() === "dark" ? "light" : "dark") const canSwitchMode = () => supports(nextMode()) const runtime = createMemo(() => runtimeStatus(frontendSamples())) @@ -67,7 +84,7 @@ export function DevToolsBar() { if (!panel() || event.name !== "escape") return event.preventDefault() event.stopPropagation() - setPanel() + close() }, { priority: 10 }, ) @@ -205,7 +222,7 @@ export function DevToolsBar() { width={dimensions().width} height={Math.max(0, dimensions().height - 1)} backgroundColor="transparent" - onMouseUp={() => setPanel()} + onMouseUp={close} /> toggle("server")}> From 193f6be99ca3e70f8ca001a7eb4f66cf78383fe9 Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Thu, 23 Jul 2026 14:09:25 -0500 Subject: [PATCH 07/11] fix(ai): keep tools when Gemini tool choice is none (#38556) --- packages/ai/src/protocols/gemini.ts | 6 +++--- packages/ai/test/provider/gemini.test.ts | 8 +++++--- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/packages/ai/src/protocols/gemini.ts b/packages/ai/src/protocols/gemini.ts index 6cba2bc7f9..57ad066037 100644 --- a/packages/ai/src/protocols/gemini.ts +++ b/packages/ai/src/protocols/gemini.ts @@ -313,7 +313,7 @@ const thinkingConfig = (request: LLMRequest) => { } const fromRequest = Effect.fn("Gemini.fromRequest")(function* (request: LLMRequest) { - const toolsEnabled = request.tools.length > 0 && request.toolChoice?.type !== "none" + const hasTools = request.tools.length > 0 const generation = request.generation const toolSchemaCompatibility = request.model.compatibility?.toolSchema const generationConfig = { @@ -329,7 +329,7 @@ const fromRequest = Effect.fn("Gemini.fromRequest")(function* (request: LLMReque contents: yield* lowerMessages(request), systemInstruction: request.system.length === 0 ? undefined : { parts: [{ text: ProviderShared.joinText(request.system) }] }, - tools: toolsEnabled + tools: hasTools ? [ { functionDeclarations: request.tools.map((tool) => @@ -338,7 +338,7 @@ const fromRequest = Effect.fn("Gemini.fromRequest")(function* (request: LLMReque }, ] : undefined, - toolConfig: toolsEnabled && request.toolChoice ? yield* lowerToolConfig(request.toolChoice) : undefined, + toolConfig: hasTools && request.toolChoice ? yield* lowerToolConfig(request.toolChoice) : undefined, generationConfig: Object.values(generationConfig).some((value) => value !== undefined) ? generationConfig : undefined, diff --git a/packages/ai/test/provider/gemini.test.ts b/packages/ai/test/provider/gemini.test.ts index 5195d372c9..50b5dbb1d2 100644 --- a/packages/ai/test/provider/gemini.test.ts +++ b/packages/ai/test/provider/gemini.test.ts @@ -233,11 +233,11 @@ describe("Gemini route", () => { }), ) - it.effect("omits tools when tool choice is none", () => + it.effect("keeps tools and sends function calling mode NONE", () => Effect.gen(function* () { const prepared = yield* LLMClient.prepare( LLM.request({ - id: "req_no_tools", + id: "req_tool_choice_none", model, prompt: "Say hello.", tools: [{ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }], @@ -245,8 +245,10 @@ describe("Gemini route", () => { }), ) - expect(prepared.body).toEqual({ + expect(prepared.body).toMatchObject({ contents: [{ role: "user", parts: [{ text: "Say hello." }] }], + tools: [{ functionDeclarations: [{ name: "lookup", description: "Lookup data" }] }], + toolConfig: { functionCallingConfig: { mode: "NONE" } }, }) }), ) From 8cac010bacc9ddd374c900d6a7c98aafc1113f1f Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Thu, 23 Jul 2026 14:12:23 -0500 Subject: [PATCH 08/11] fix(core): stop forcing toolChoice none on session.generate (#38557) --- packages/core/src/session/generate-node.ts | 1 - packages/core/test/session-generate.test.ts | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/core/src/session/generate-node.ts b/packages/core/src/session/generate-node.ts index f26e2847c2..96804fc57f 100644 --- a/packages/core/src/session/generate-node.ts +++ b/packages/core/src/session/generate-node.ts @@ -74,7 +74,6 @@ export const layer = Layer.effect( system: contextEvent.system, messages: contextEvent.messages, tools: hookedTools, - toolChoice: "none", }), ) yield* Effect.logInfo("session generation usage diagnostic", { usage: response.usage }) diff --git a/packages/core/test/session-generate.test.ts b/packages/core/test/session-generate.test.ts index 98e7aedc0c..e78f41bdac 100644 --- a/packages/core/test/session-generate.test.ts +++ b/packages/core/test/session-generate.test.ts @@ -301,7 +301,7 @@ it.effect("generates from fresh settled Session context without durable mutation ), ).toEqual(["Settled partial answer"]) expect(requests[0]?.tools).toMatchObject([{ name: "lookup", description: "Hooked lookup" }]) - expect(requests[0]?.toolChoice).toMatchObject({ type: "none" }) + expect(requests[0]?.toolChoice).toBeUndefined() expect(yield* durableState(db, sessionID)).toEqual(before) }), ) From 79c1544072739f58d2e80f8c3e83b203aa92d827 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 23 Jul 2026 17:13:31 -0400 Subject: [PATCH 09/11] refactor(tools): unify tool APIs and result handling (#38367) --- .changeset/canonical-tool-results.md | 8 + .../ai/src/protocols/anthropic-messages.ts | 9 +- packages/ai/src/protocols/bedrock-converse.ts | 9 +- .../test/provider/anthropic-messages.test.ts | 9 +- .../ai/test/provider/bedrock-converse.test.ts | 30 ++ packages/cli/src/acp/event.ts | 23 +- packages/cli/src/acp/permission.ts | 6 +- packages/cli/src/acp/tool.ts | 37 +- .../cli/src/node/plugin-runtime.promise.ts | 2 + packages/cli/src/run/noninteractive.ts | 48 +-- packages/cli/test/acp/event-behavior.test.ts | 29 +- .../cli/test/acp/permission-behavior.test.ts | 6 +- packages/cli/test/acp/tool.test.ts | 63 +-- packages/cli/test/import-boundaries.test.ts | 7 +- packages/cli/test/run/noninteractive.test.ts | 25 +- packages/cli/vite.node.config.ts | 12 +- .../client/src/promise/generated/types.ts | 59 ++- packages/codemode/README.md | 8 +- packages/codemode/interpreter-support.md | 2 +- packages/codemode/src/interpreter/execute.ts | 2 +- packages/codemode/src/interpreter/runtime.ts | 13 +- packages/codemode/src/openapi/index.ts | 10 +- packages/codemode/src/openapi/types.ts | 4 +- packages/codemode/src/tool-runtime.ts | 72 ++-- packages/codemode/src/tool-schema.ts | 44 +- packages/codemode/src/tool.ts | 24 +- packages/codemode/src/tools.ts | 4 +- packages/codemode/test/callbacks.test.ts | 2 +- packages/codemode/test/codemode.test.ts | 74 ++-- packages/codemode/test/enumeration.test.ts | 2 +- packages/codemode/test/openapi.test.ts | 149 ++++--- packages/codemode/test/promise.test.ts | 16 +- packages/codemode/test/signature.test.ts | 14 +- packages/codemode/test/stdlib.test.ts | 4 +- packages/codemode/test/tool-paths.test.ts | 10 +- packages/core/src/codemode.ts | 39 +- packages/core/src/database/migration.gen.ts | 1 + .../20260722170000_canonical_tool_results.ts | 123 ++++++ packages/core/src/plugin/host.ts | 42 +- packages/core/src/plugin/promise.ts | 22 +- packages/core/src/session/generate-node.ts | 9 +- packages/core/src/session/message-updater.ts | 18 +- packages/core/src/session/model-request.ts | 43 +- packages/core/src/session/runner/llm.ts | 25 +- .../src/session/runner/publish-llm-event.ts | 103 +++-- .../core/src/session/runner/to-llm-message.ts | 40 +- packages/core/src/session/to-session-error.ts | 9 +- packages/core/src/tool-output-store.ts | 36 +- packages/core/src/tool/AGENTS.md | 18 +- packages/core/src/tool/edit.ts | 18 +- packages/core/src/tool/execute.ts | 101 +++-- packages/core/src/tool/glob.ts | 22 +- packages/core/src/tool/grep.ts | 28 +- packages/core/src/tool/hooks.ts | 27 +- packages/core/src/tool/mcp.ts | 146 +++---- packages/core/src/tool/patch.ts | 17 +- packages/core/src/tool/question.ts | 10 +- packages/core/src/tool/read.ts | 28 +- packages/core/src/tool/registry.ts | 276 +++++++------ packages/core/src/tool/shell.ts | 62 +-- packages/core/src/tool/skill.ts | 16 +- packages/core/src/tool/subagent.ts | 18 +- packages/core/src/tool/tool.ts | 90 +++- packages/core/src/tool/tools.ts | 4 +- packages/core/src/tool/webfetch.ts | 10 +- packages/core/src/tool/websearch.ts | 10 +- packages/core/src/tool/write.ts | 9 +- packages/core/test/codemode.test.ts | 16 +- packages/core/test/database-migration.test.ts | 203 +++++++++ packages/core/test/lib/tool.ts | 11 +- packages/core/test/mcp.test.ts | 94 ++++- packages/core/test/plugin.test.ts | 53 ++- packages/core/test/plugin/promise.test.ts | 41 +- packages/core/test/session-generate.test.ts | 4 +- .../core/test/session-instructions.test.ts | 14 +- .../core/test/session-runner-message.test.ts | 41 +- .../test/session-runner-tool-events.test.ts | 90 ++-- .../test/session-runner-tool-registry.test.ts | 330 ++++++++------- packages/core/test/session-runner.test.ts | 386 ++++++++++-------- .../core/test/session-tool-progress.test.ts | 20 +- packages/core/test/tool-edit.test.ts | 86 ++-- packages/core/test/tool-execute.test.ts | 116 ++++-- packages/core/test/tool-output-store.test.ts | 76 +--- packages/core/test/tool-patch.test.ts | 171 ++++---- packages/core/test/tool-question.test.ts | 31 +- packages/core/test/tool-read.test.ts | 145 ++++--- packages/core/test/tool-search.test.ts | 25 +- packages/core/test/tool-shell.test.ts | 120 +++--- packages/core/test/tool-skill.test.ts | 34 +- packages/core/test/tool-subagent.test.ts | 72 ++-- packages/core/test/tool-webfetch.test.ts | 83 ++-- packages/core/test/tool-websearch.test.ts | 32 +- packages/core/test/tool-write.test.ts | 58 +-- packages/docs/build/plugins.mdx | 75 ++-- .../plugin/src/v2/effect/internal/tool.ts | 315 ++++++++++++++ packages/plugin/src/v2/effect/tool.ts | 316 +------------- packages/plugin/src/v2/promise/README.md | 20 +- .../plugin/src/v2/promise/internal/tool.ts | 64 +++ packages/plugin/src/v2/promise/tool.ts | 50 +-- packages/plugin/test/tool.test.ts | 69 ++-- packages/schema/src/session-event.ts | 29 +- packages/schema/src/session-message.ts | 13 +- packages/schema/test/event-manifest.test.ts | 4 +- packages/sdk-next/src/tool.ts | 2 +- packages/sdk-next/test/embedded.test.ts | 6 +- .../src/backend/simulated-provider.ts | 24 +- packages/simulation/src/protocol/index.ts | 5 +- packages/simulation/test/protocol.test.ts | 2 +- .../test/simulated-provider.test.ts | 110 ++--- packages/tui/src/context/data.tsx | 14 +- packages/tui/src/mini/demo.ts | 13 +- packages/tui/src/mini/permission.shared.ts | 2 +- packages/tui/src/mini/stream-v2.subagent.ts | 45 +- packages/tui/src/mini/stream-v2.transport.ts | 20 +- packages/tui/src/mini/stream.ts | 2 +- packages/tui/src/mini/tool.public.ts | 3 +- packages/tui/src/mini/tool.ts | 49 ++- packages/tui/src/routes/session/index.tsx | 80 +++- .../tui/src/routes/session/permission.tsx | 10 +- packages/tui/src/util/permission.ts | 4 +- packages/tui/src/util/tool-display.ts | 18 +- packages/tui/test/cli/tui/data.test.tsx | 11 +- packages/tui/test/mini/entry.body.test.ts | 41 +- .../tui/test/mini/permission.shared.test.ts | 9 +- .../tui/test/mini/scrollback.surface.test.ts | 30 +- .../tui/test/mini/stream-v2.transport.test.ts | 82 ++-- packages/tui/test/mini/tool.test.ts | 40 +- packages/tui/test/util/tool-display.test.ts | 16 +- packages/www/content/docs/build/plugins.mdx | 73 ++-- specs/v2/README.md | 2 +- specs/v2/schema-changelog.md | 15 +- specs/v2/session.md | 4 +- specs/v2/tools.md | 71 ++-- 133 files changed, 3599 insertions(+), 2767 deletions(-) create mode 100644 .changeset/canonical-tool-results.md create mode 100644 packages/core/src/database/migration/20260722170000_canonical_tool_results.ts create mode 100644 packages/plugin/src/v2/effect/internal/tool.ts create mode 100644 packages/plugin/src/v2/promise/internal/tool.ts diff --git a/.changeset/canonical-tool-results.md b/.changeset/canonical-tool-results.md new file mode 100644 index 0000000000..b484966033 --- /dev/null +++ b/.changeset/canonical-tool-results.md @@ -0,0 +1,8 @@ +--- +"@opencode-ai/plugin": minor +"@opencode-ai/sdk": minor +"@opencode-ai/client": minor +"@opencode-ai/protocol": minor +--- + +Replace the V2 tool result model with one canonical representation per fact. Tools lose `structured`, projection callbacks, the `Structured` generic, and the exported `Tool.settle` interpreter; tool responses carry schema-validated `output`, model-visible `content`, and optional compact JSON `metadata`. Code Mode receives the validated encoded output. Durable tool success stores non-empty model content plus optional metadata; failure stores one error plus the final bounded partial snapshot. Progress carries metadata only, while `execute.after` hooks receive the canonical terminal outcome and managed `outputPaths`. A one-time migration rewrites existing projected tool rows and moves provider-hosted result payloads into provider-owned result state. diff --git a/packages/ai/src/protocols/anthropic-messages.ts b/packages/ai/src/protocols/anthropic-messages.ts index cc03dfa65b..fc4ce9ab53 100644 --- a/packages/ai/src/protocols/anthropic-messages.ts +++ b/packages/ai/src/protocols/anthropic-messages.ts @@ -330,7 +330,10 @@ const lowerServerToolResult = Effect.fn("AnthropicMessages.lowerServerToolResult const wireType = serverToolResultType(part.name) if (!wireType) return yield* invalid(`Anthropic Messages does not know how to round-trip server tool result for ${part.name}`) - return { type: wireType, tool_use_id: part.id, content: part.result.value } satisfies AnthropicServerToolResultBlock + // Prefer the provider-owned replay payload; fall back to the result value for + // histories constructed directly from provider events. + const payload = part.providerMetadata?.anthropic?.["result"] ?? part.result.value + return { type: wireType, tool_use_id: part.id, content: payload } satisfies AnthropicServerToolResultBlock }) const lowerMedia = Effect.fn("AnthropicMessages.lowerMedia")(function* (part: MediaPart) { @@ -682,7 +685,9 @@ const serverToolResultEvent = (block: NonNullable 0 && request.toolChoice?.type !== "none" - ? { tools: lowerTools(request.model.compatibility?.toolSchema, breakpoints, request.tools), toolChoice } + request.tools.length > 0 + ? { + tools: lowerTools(request.model.compatibility?.toolSchema, breakpoints, request.tools), + // Converse has no native "none". Keep definitions stable for prompt + // caching and omit only the unsupported choice. + toolChoice, + } : undefined const system = request.system.length === 0 ? undefined : lowerSystem(breakpoints, request.system) const messages = yield* lowerMessages(request, breakpoints) diff --git a/packages/ai/test/provider/anthropic-messages.test.ts b/packages/ai/test/provider/anthropic-messages.test.ts index da8bb34287..0b319f573b 100644 --- a/packages/ai/test/provider/anthropic-messages.test.ts +++ b/packages/ai/test/provider/anthropic-messages.test.ts @@ -664,7 +664,14 @@ describe("Anthropic Messages route", () => { name: "web_search", result: { type: "json", value: [{ type: "web_search_result", url: "https://example.com", title: "Example" }] }, providerExecuted: true, - providerMetadata: { anthropic: { blockType: "web_search_tool_result" } }, + // The complete payload rides in provider metadata as irreducible replay + // state for later stateless requests. + providerMetadata: { + anthropic: { + blockType: "web_search_tool_result", + result: [{ type: "web_search_result", url: "https://example.com", title: "Example" }], + }, + }, }) expect(response.text).toBe("Found it.") expect(response.events.at(-1)).toMatchObject({ type: "finish", reason: "stop" }) diff --git a/packages/ai/test/provider/bedrock-converse.test.ts b/packages/ai/test/provider/bedrock-converse.test.ts index 776032966c..a7280e09cd 100644 --- a/packages/ai/test/provider/bedrock-converse.test.ts +++ b/packages/ai/test/provider/bedrock-converse.test.ts @@ -154,6 +154,36 @@ describe("Bedrock Converse route", () => { }), ) + it.effect("keeps tools and omits the unsupported choice when tool choice is none", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare( + LLM.updateRequest(baseRequest, { + tools: [ + { + name: "lookup", + description: "Lookup data", + inputSchema: { type: "object", properties: { query: { type: "string" } } }, + }, + ], + toolChoice: ToolChoice.make({ type: "none" }), + }), + ) + + expect(prepared.body.toolConfig).toMatchObject({ + tools: [ + { + toolSpec: { + name: "lookup", + description: "Lookup data", + inputSchema: { json: { type: "object", properties: { query: { type: "string" } } } }, + }, + }, + ], + }) + expect(prepared.body.toolConfig?.toolChoice).toBeUndefined() + }), + ) + it.effect("lowers assistant tool-call + tool-result message history", () => Effect.gen(function* () { const prepared = yield* LLMClient.prepare( diff --git a/packages/cli/src/acp/event.ts b/packages/cli/src/acp/event.ts index 1551353830..187b660141 100644 --- a/packages/cli/src/acp/event.ts +++ b/packages/cli/src/acp/event.ts @@ -28,7 +28,7 @@ export type TurnControl = { type ToolState = { readonly name: string input: ToolInput - structured: Record + metadata: Record content: ToolContent } @@ -38,7 +38,7 @@ export type TurnStart = | { readonly type: "compaction"; readonly id: string } function emptyToolState(): ToolState { - return { name: "tool", input: {}, structured: {}, content: [] } + return { name: "tool", input: {}, metadata: {}, content: [] } } export async function streamTurn(input: { @@ -120,7 +120,7 @@ export async function streamTurn(input: { } if (event.type === "session.tool.input.started") { assistantMessageID = event.data.assistantMessageID - tools.set(event.data.callID, { name: event.data.name, input: {}, structured: {}, content: [] }) + tools.set(event.data.callID, { name: event.data.name, input: {}, metadata: {}, content: [] }) await update({ sessionUpdate: "tool_call", ...pendingToolCall({ @@ -151,15 +151,13 @@ export async function streamTurn(input: { if (event.type === "session.tool.progress") { const current = tools.get(event.data.callID) if (!current) continue - current.structured = event.data.structured - current.content = event.data.content + current.metadata = event.data.metadata await update({ sessionUpdate: "tool_call_update", ...runningToolUpdate({ toolCallId: event.data.callID, toolName: current.name, state: { input: current.input }, - content: current.content, cwd: input.cwd, }), }) @@ -175,7 +173,7 @@ export async function streamTurn(input: { cwd: input.cwd, toolName: current.name, toolInput: current.input, - structured: event.data.structured, + metadata: event.data.metadata ?? {}, }).catch(() => {}) await update({ sessionUpdate: "tool_call_update", @@ -183,9 +181,8 @@ export async function streamTurn(input: { toolCallId: event.data.callID, toolName: current.name, input: current.input, - structured: event.data.structured, + metadata: event.data.metadata, content: event.data.content, - result: event.data.result, }), }) continue @@ -199,7 +196,7 @@ export async function streamTurn(input: { toolCallId: event.data.callID, toolName: current.name, input: current.input, - structured: event.data.metadata ?? current.structured, + metadata: event.data.metadata ?? current.metadata, content: event.data.content ?? current.content, error: event.data.error.message, cwd: input.cwd, @@ -342,9 +339,8 @@ async function replayMessage( toolCallId: part.id, toolName: part.name, input: part.state.input, - structured: part.state.structured, + metadata: part.state.metadata, content: part.state.content, - result: part.state.result, }), }, }) @@ -358,7 +354,6 @@ async function replayMessage( toolCallId: part.id, toolName: part.name, state: { input: part.state.input }, - content: part.state.content, cwd, }), }, @@ -373,7 +368,7 @@ async function replayMessage( toolCallId: part.id, toolName: part.name, input: part.state.input, - structured: part.state.structured, + metadata: part.state.metadata, content: part.state.content, error: part.state.error.message, cwd, diff --git a/packages/cli/src/acp/permission.ts b/packages/cli/src/acp/permission.ts index 3c1d92957e..87f6e68d06 100644 --- a/packages/cli/src/acp/permission.ts +++ b/packages/cli/src/acp/permission.ts @@ -58,11 +58,11 @@ export async function syncEditedFiles(input: { readonly cwd: string readonly toolName: string readonly toolInput: ToolInput - readonly structured: Readonly> + readonly metadata: Readonly> }) { if (!input.writeTextFile || !input.connection.writeTextFile || toToolKind(input.toolName) !== "edit") return - const files = Array.isArray(input.structured.files) - ? input.structured.files.flatMap((file): string[] => { + const files = Array.isArray(input.metadata.files) + ? input.metadata.files.flatMap((file): string[] => { if (!file || typeof file !== "object") return [] const path = Reflect.get(file, "file") return typeof path === "string" ? [path] : [] diff --git a/packages/cli/src/acp/tool.ts b/packages/cli/src/acp/tool.ts index 468512f892..8937712f11 100644 --- a/packages/cli/src/acp/tool.ts +++ b/packages/cli/src/acp/tool.ts @@ -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 export type ToolContent = ReadonlyArray< @@ -100,11 +101,12 @@ export function completedToolUpdate(input: { readonly toolName: string readonly input: ToolInput readonly content: ToolContent - readonly structured: Readonly> - readonly result?: unknown + readonly metadata?: Readonly> }): 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> + readonly content?: ToolContent + readonly metadata?: Readonly> 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>) { - 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 diff --git a/packages/cli/src/node/plugin-runtime.promise.ts b/packages/cli/src/node/plugin-runtime.promise.ts index 93e20b87bb..49c4651232 100644 --- a/packages/cli/src/node/plugin-runtime.promise.ts +++ b/packages/cli/src/node/plugin-runtime.promise.ts @@ -10,6 +10,7 @@ import { Reference, Skill, } from "@opencode-ai/plugin/v2" +import { Tool } from "@opencode-ai/plugin/v2/tool" const key = Symbol.for("opencode.plugin.v2.promise") ;(globalThis as typeof globalThis & { [key]?: unknown })[key] = { @@ -23,4 +24,5 @@ const key = Symbol.for("opencode.plugin.v2.promise") Provider, Reference, Skill, + Tool, } diff --git a/packages/cli/src/run/noninteractive.ts b/packages/cli/src/run/noninteractive.ts index 41859d3b64..824d228c6a 100644 --- a/packages/cli/src/run/noninteractive.ts +++ b/packages/cli/src/run/noninteractive.ts @@ -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 + metadata: Record 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: [], } } diff --git a/packages/cli/test/acp/event-behavior.test.ts b/packages/cli/test/acp/event-behavior.test.ts index ded7b7020a..42be5c4520 100644 --- a/packages/cli/test/acp/event-behavior.test.ts +++ b/packages/cli/test/acp/event-behavior.test.ts @@ -218,8 +218,7 @@ describe("acp event behavior", () => { sessionID: "ses_tools", assistantMessageID: "msg_tools", callID: "call_ok", - structured: { phase: 1 }, - content: [{ type: "text", text: "working" }], + metadata: { phase: 1 }, }), ) send( @@ -227,9 +226,8 @@ describe("acp event behavior", () => { sessionID: "ses_tools", assistantMessageID: "msg_tools", callID: "call_ok", - structured: { exit: 0 }, + metadata: { exit: 0 }, content: [{ type: "text", text: "done" }], - result: { code: 0 }, executed: true, }), ) @@ -255,8 +253,7 @@ describe("acp event behavior", () => { sessionID: "ses_tools", assistantMessageID: "msg_tools", callID: "call_fail", - structured: { bytes: 0 }, - content: [{ type: "text", text: "opening" }], + metadata: { bytes: 0 }, }), ) send( @@ -313,12 +310,10 @@ describe("acp event behavior", () => { locations: [{ path: resolve("/workspace", "sub") }], rawInput: { command: "printf done", workdir: "sub" }, }) - expect(updates[2]?.update).toMatchObject({ - content: [{ type: "content", content: { type: "text", text: "working" } }], - }) + expect(updates[2]?.update).not.toHaveProperty("content") expect(updates[3]?.update).toMatchObject({ content: [{ type: "content", content: { type: "text", text: "done" } }], - rawOutput: { structured: { exit: 0 }, result: { code: 0 } }, + rawOutput: { metadata: { exit: 0 } }, }) expect(updates[7]?.update).toMatchObject({ kind: "read", @@ -327,7 +322,7 @@ describe("acp event behavior", () => { { type: "content", content: { type: "text", text: "opening" } }, { type: "content", content: { type: "text", text: "not found" } }, ], - rawOutput: { structured: { bytes: 0 }, error: "not found" }, + rawOutput: { metadata: { bytes: 0 }, error: "not found" }, }) expect(response.stopReason).toBe("end_turn") } finally { @@ -379,7 +374,7 @@ describe("acp event behavior", () => { { type: "content", content: { type: "text", text: "done" } }, { type: "content", content: { type: "image", mimeType: "image/png", data: "AAAA" } }, ], - rawOutput: { structured: { exit: 0 }, result: { code: 0 } }, + rawOutput: { metadata: { exit: 0 } }, }) expect(updates[8]?.update).toMatchObject({ toolCallId: "call_running", @@ -618,12 +613,11 @@ function replayFixtureMessages(): SessionMessageInfo[] { state: { status: "completed", input: { command: "printf done" }, - structured: { exit: 0 }, + metadata: { exit: 0 }, content: [ { type: "text", text: "done" }, { type: "file", uri: "data:image/png;base64,AAAA", mime: "image/png", name: "image.png" }, ], - result: { code: 0 }, }, }, { @@ -634,8 +628,7 @@ function replayFixtureMessages(): SessionMessageInfo[] { state: { status: "running", input: { command: "pwd" }, - structured: {}, - content: [{ type: "text", text: "/workspace" }], + metadata: {}, }, }, { @@ -646,7 +639,7 @@ function replayFixtureMessages(): SessionMessageInfo[] { state: { status: "error", input: { filePath: "/workspace/missing.ts" }, - structured: { bytes: 0 }, + metadata: { bytes: 0 }, content: [{ type: "text", text: "partial" }], error: { type: "tool.error", message: "failed hard" }, }, @@ -679,7 +672,7 @@ function replayToolMessage(id: string) { state: { status: "completed", input: { command: "printf done" }, - structured: { exit: 0 }, + metadata: { exit: 0 }, content: [{ type: "text", text: "done" }], }, }, diff --git a/packages/cli/test/acp/permission-behavior.test.ts b/packages/cli/test/acp/permission-behavior.test.ts index 355ff5bb41..df7de586fd 100644 --- a/packages/cli/test/acp/permission-behavior.test.ts +++ b/packages/cli/test/acp/permission-behavior.test.ts @@ -28,7 +28,7 @@ describe("acp permission behavior", () => { cwd: "/workspace", toolName: "edit", toolInput: { filePath: "/workspace/file.ts" }, - structured: {}, + metadata: {}, }) expect(writes).toEqual([]) @@ -193,7 +193,7 @@ describe("acp permission behavior", () => { sessionID: "ses_edit", assistantMessageID: "msg_edit", callID: "call_edit", - structured: { files: [{ file: "file.ts" }], replacements: 1 }, + metadata: { files: [{ file: "file.ts" }], replacements: 1 }, content: [{ type: "text", text: "edited" }], executed: true, }), @@ -286,7 +286,7 @@ describe("acp permission behavior", () => { sessionID: "ses_patch", assistantMessageID: "msg_patch", callID: "call_patch", - structured: { files: [{ file: "first.ts" }, { file: "second.ts" }] }, + metadata: { files: [{ file: "first.ts" }, { file: "second.ts" }] }, content: [{ type: "text", text: "patched" }], executed: true, }), diff --git a/packages/cli/test/acp/tool.test.ts b/packages/cli/test/acp/tool.test.ts index da90fb7c05..0095b57ce1 100644 --- a/packages/cli/test/acp/tool.test.ts +++ b/packages/cli/test/acp/tool.test.ts @@ -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: "1: first\n2: second" }], - 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" }, }) }) }) diff --git a/packages/cli/test/import-boundaries.test.ts b/packages/cli/test/import-boundaries.test.ts index 8b189ce2c0..3634aa5aed 100644 --- a/packages/cli/test/import-boundaries.test.ts +++ b/packages/cli/test/import-boundaries.test.ts @@ -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([]) }) diff --git a/packages/cli/test/run/noninteractive.test.ts b/packages/cli/test/run/noninteractive.test.ts index f0a680061f..8527bcaa57 100644 --- a/packages/cli/test/run/noninteractive.test.ts +++ b/packages/cli/test/run/noninteractive.test.ts @@ -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("") }) diff --git a/packages/cli/vite.node.config.ts b/packages/cli/vite.node.config.ts index 791a0131ac..15bcc8490a 100644 --- a/packages/cli/vite.node.config.ts +++ b/packages/cli/vite.node.config.ts @@ -75,6 +75,10 @@ export const define = sdk.Plugin.define` const effectPluginModule = promisePluginModule .replace("opencode.plugin.v2.promise", "opencode.plugin.v2.effect") .replace("Promise plugin", "Effect plugin") + const promiseToolModule = `const sdk = globalThis[Symbol.for("opencode.plugin.v2.promise")] +if (!sdk) throw new Error("OpenCode Promise plugin SDK is unavailable") +export const Tool = sdk.Tool +export const make = sdk.Tool.make` const effectToolModule = `const sdk = globalThis[Symbol.for("opencode.plugin.v2.effect")] if (!sdk) throw new Error("OpenCode Effect plugin SDK is unavailable") export const Tool = sdk.Tool @@ -83,10 +87,8 @@ export const RegistrationError = sdk.Tool.RegistrationError export const make = sdk.Tool.make export const validateName = sdk.Tool.validateName export const registrationEntries = sdk.Tool.registrationEntries -export const withPermission = sdk.Tool.withPermission -export const permission = sdk.Tool.permission -export const definition = sdk.Tool.definition -export const settle = sdk.Tool.settle` +export const validateNamespace = sdk.Tool.validateNamespace +export const toLLMDefinition = sdk.Tool.toLLMDefinition` return `#!/usr/bin/env -S node ${nodeExecArgv.join(" ")} import __cjs_mod__ from "node:module" import { chmodSync as __ocChmod, existsSync as __ocExists, lstatSync as __ocLstat, mkdirSync as __ocMkdir, renameSync as __ocRename, rmSync as __ocRm, writeFileSync as __ocWrite } from "node:fs" @@ -100,6 +102,7 @@ const require = __cjs_mod__.createRequire(import.meta.url) const __ocPluginModules = ${JSON.stringify({ "@opencode-ai/plugin/v2": "opencode:plugin-v2", "@opencode-ai/plugin/v2/plugin": "opencode:plugin-v2-plugin", + "@opencode-ai/plugin/v2/tool": "opencode:plugin-v2-tool", "@opencode-ai/plugin/v2/effect": "opencode:plugin-v2-effect", "@opencode-ai/plugin/v2/effect/plugin": "opencode:plugin-v2-effect-plugin", "@opencode-ai/plugin/v2/effect/tool": "opencode:plugin-v2-effect-tool", @@ -107,6 +110,7 @@ const __ocPluginModules = ${JSON.stringify({ const __ocPluginSources = ${JSON.stringify({ "opencode:plugin-v2": promiseModule, "opencode:plugin-v2-plugin": promisePluginModule, + "opencode:plugin-v2-tool": promiseToolModule, "opencode:plugin-v2-effect": effectModule, "opencode:plugin-v2-effect-plugin": effectPluginModule, "opencode:plugin-v2-effect-tool": effectToolModule, diff --git a/packages/client/src/promise/generated/types.ts b/packages/client/src/promise/generated/types.ts index a08c7b3246..a1b2367c8b 100644 --- a/packages/client/src/promise/generated/types.ts +++ b/packages/client/src/promise/generated/types.ts @@ -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 -} - export type SessionMessageToolStateCompleted = { status: "completed" input: { [x: string]: JsonValue } - content: Array - structured: { [x: string]: JsonValue } - result?: JsonValue + content: [LLMToolContent, ...Array] + metadata?: { [x: string]: JsonValue } } export type SessionMessageToolStateError = { status: "error" input: { [x: string]: JsonValue } - content: Array - structured: { [x: string]: JsonValue } error: SessionStructuredError - result?: JsonValue + content?: [LLMToolContent, ...Array] + 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 - result?: any + content: [LLMToolContent, ...Array] + 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] - 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 - } -} - export type SessionMessageCompaction = | SessionMessageCompactionRunning | SessionMessageCompactionCompleted diff --git a/packages/codemode/README.md b/packages/codemode/README.md index 09ba57346e..923da8d441 100644 --- a/packages/codemode/README.md +++ b/packages/codemode/README.md @@ -33,7 +33,7 @@ const lookupOrder = Tool.make({ description: "Look up an order by ID", input: Schema.Struct({ id: Schema.String }), output: Schema.Struct({ id: Schema.String, status: Schema.String }), - run: ({ id }) => Effect.succeed({ id, status: "open" }), + execute: ({ id }) => Effect.succeed({ id, status: "open" }), }) const runtime = CodeMode.make({ @@ -55,10 +55,10 @@ const result = await Effect.runPromise( ### `Tool.make` `input` and `output` accept either an Effect Schema or a render-only JSON Schema document. Effect Schema input is -decoded before `run`; Effect Schema output is decoded and safely copied before the program sees it. JSON Schemas only -shape the model-visible signature. Without `output`, the signature uses `Promise`. +decoded before `execute`; Effect Schema output is decoded and safely copied before the program sees it. JSON Schemas +only shape the model-visible signature. Without `output`, the signature uses `Promise`. -Descriptions and schemas are model-visible contracts. Authorization belongs in `run`. +Descriptions and schemas are model-visible contracts. Authorization belongs in `execute`. Dots in tool names create namespaces: `{ "issues.list": tool }` and `{ issues: { list: tool } }` both expose `tools.issues.list(...)`. Other characters use bracket notation, such as diff --git a/packages/codemode/interpreter-support.md b/packages/codemode/interpreter-support.md index 0d989595ec..4b8b900110 100644 --- a/packages/codemode/interpreter-support.md +++ b/packages/codemode/interpreter-support.md @@ -188,7 +188,7 @@ ultimate source of truth. first-call-wins resolve/reject functions, and ignore throws after settlement. Inherited/accessor `then` fields and a JavaScript `this` receiver remain outside the supported object/function model. - [x] Dotted tool names are canonicalized into namespace paths; a path can be both callable and a namespace, and the - last definition supplied for a canonical path wins. + last tool supplied for a canonical path wins. - [x] Tool path segments may be named `constructor`, `prototype`, or `__proto__` because paths use inert Map keys. - [x] Outbound tool arguments follow JSON serialization semantics, like `JSON.stringify`: object properties with `undefined` values are dropped, `undefined` array elements and non-finite numbers become `null`, and sparse diff --git a/packages/codemode/src/interpreter/execute.ts b/packages/codemode/src/interpreter/execute.ts index af2eccf9b8..a3f789868e 100644 --- a/packages/codemode/src/interpreter/execute.ts +++ b/packages/codemode/src/interpreter/execute.ts @@ -45,7 +45,7 @@ export const executeWithLimits = const program = parseProgram(options.code) const promises = new PromiseRuntime>(scope) const interpreter = new Interpreter>( - tools.invoke, + tools.execute, tools.search, tools.keys, promises, diff --git a/packages/codemode/src/interpreter/runtime.ts b/packages/codemode/src/interpreter/runtime.ts index 3af385d513..2659533346 100644 --- a/packages/codemode/src/interpreter/runtime.ts +++ b/packages/codemode/src/interpreter/runtime.ts @@ -271,7 +271,10 @@ const promiseResolutionNode: AstNode = { type: "PromiseResolution" } export class Interpreter { private scopes: ScopeStack - private readonly invokeTool: (path: ReadonlyArray, args: Array) => Effect.Effect + private readonly executeTool: ( + path: ReadonlyArray, + args: Array, + ) => Effect.Effect private readonly invokeSearch: (args: Array) => Effect.Effect private readonly toolKeys: (path: ReadonlyArray) => ReadonlyArray private readonly logs: Array @@ -286,7 +289,7 @@ export class Interpreter { } constructor( - invokeTool: (path: ReadonlyArray, args: Array) => Effect.Effect, + executeTool: (path: ReadonlyArray, args: Array) => Effect.Effect, invokeSearch: (args: Array) => Effect.Effect, toolKeys: (path: ReadonlyArray) => ReadonlyArray, promises: PromiseRuntime, @@ -294,7 +297,7 @@ export class Interpreter { ) { const globalScope = new Map() this.scopes = new ScopeStack([globalScope]) - this.invokeTool = invokeTool + this.executeTool = executeTool this.invokeSearch = invokeSearch this.toolKeys = toolKeys this.logs = logs @@ -369,7 +372,7 @@ export class Interpreter { path: ReadonlyArray, args: Array, ): Effect.Effect { - return this.createPromise(Effect.suspend(() => this.invokeTool(path, args))) + return this.createPromise(Effect.suspend(() => this.executeTool(path, args))) } private createPromise(effect: Effect.Effect): Effect.Effect { @@ -2079,7 +2082,7 @@ export class Interpreter { } private invokeFunction(fn: CodeModeFunction, args: Array): Effect.Effect { - const invocation = new Interpreter(this.invokeTool, this.invokeSearch, this.toolKeys, this.promises, this.logs) + const invocation = new Interpreter(this.executeTool, this.invokeSearch, this.toolKeys, this.promises, this.logs) invocation.scopes = new ScopeStack([...fn.capturedScopes, new Map()]) const run = Effect.gen(function* () { // Seed all parameters first so defaults cannot fall through to same-named outer bindings. diff --git a/packages/codemode/src/openapi/index.ts b/packages/codemode/src/openapi/index.ts index 5ea688129e..1da5779cb4 100644 --- a/packages/codemode/src/openapi/index.ts +++ b/packages/codemode/src/openapi/index.ts @@ -1,5 +1,5 @@ import { HttpClient } from "effect/unstable/http" -import { make, type Definition } from "../tool.js" +import { make, type Tool } from "../tool.js" import { invoke } from "./runtime.js" import { componentDefinitions, @@ -108,7 +108,7 @@ export const fromSpec = (options: Options): Result => { description: operation.description ?? operation.summary ?? `${operation.method} ${path}`, input: inputSchema(input.fields, requestDefinitions), output: output.value, - run: (input) => invoke(plan, input), + execute: (input) => invoke(plan, input), }), ) } @@ -117,16 +117,16 @@ export const fromSpec = (options: Options): Result => { return { tools, skipped } } -const setTool = (tools: Tools, path: ReadonlyArray, definition: Definition): void => { +const setTool = (tools: Tools, path: ReadonlyArray, tool: Tool): void => { const [head, ...rest] = path if (head === undefined) return if (rest.length === 0) { - tools[head] = definition + tools[head] = tool return } const child = tools[head] if (child === undefined || !isRecord(child) || child._tag === "CodeModeTool") { tools[head] = Object.create(null) as Tools } - setTool(tools[head] as Tools, rest, definition) + setTool(tools[head] as Tools, rest, tool) } diff --git a/packages/codemode/src/openapi/types.ts b/packages/codemode/src/openapi/types.ts index 252f49d86c..d1d4e5b49a 100644 --- a/packages/codemode/src/openapi/types.ts +++ b/packages/codemode/src/openapi/types.ts @@ -1,6 +1,6 @@ import { Effect } from "effect" import { HttpClient } from "effect/unstable/http" -import type { Definition, JsonSchema } from "../tool.js" +import type { Tool, JsonSchema } from "../tool.js" /** A parsed OpenAPI 3.x document. YAML must be parsed by the host. */ export type Document = Record @@ -58,7 +58,7 @@ export type Skipped = { readonly reason: string } -export type Tools = { [name: string]: Definition | Tools } +export type Tools = { [name: string]: Tool | Tools } export type Result = { /** Namespaced tools; the host places them under a key in its `tools` object. */ diff --git a/packages/codemode/src/tool-runtime.ts b/packages/codemode/src/tool-runtime.ts index febc2ba586..389d3af276 100644 --- a/packages/codemode/src/tool-runtime.ts +++ b/packages/codemode/src/tool-runtime.ts @@ -8,7 +8,7 @@ import { inputTypeScript, outputTypeScript, } from "./tool-schema.js" -import { isDefinition as isToolDefinition, type Definition } from "./tool.js" +import { isTool, type Tool } from "./tool.js" import type { Tools } from "./tools.js" import { CodeModeDate, @@ -28,7 +28,7 @@ type ServicesOf> = Depth["length"] exten ? never : T extends { readonly _tag: "CodeModeTool" - readonly run: (input: unknown) => Effect.Effect + readonly execute: (input: unknown) => Effect.Effect } ? R : T extends object @@ -118,8 +118,6 @@ export class ToolRuntimeError extends Error { } } -const isDefinition = (value: Definition | Tools): value is Definition => isToolDefinition(value) - const runHost = (effect: Effect.Effect): Effect.Effect => effect.pipe( Effect.catchCause((cause) => { @@ -286,9 +284,9 @@ export const copyOut = (value: unknown, mode: CopyOutMode): unknown => { return value } -// Dots in tool names are namespace separators; the last definition for a canonical path wins. +// Dots in tool names are namespace separators; the last tool for a canonical path wins. type ToolNode = { - definition?: Definition + tool?: Tool readonly children: Map> } @@ -303,7 +301,7 @@ const toolTrie = (tools: Tools): ToolNode => { current.children.set(segment, child) current = child } - if (isDefinition(value)) current.definition = value + if (isTool(value)) current.tool = value else insert(current, value) } } @@ -314,25 +312,25 @@ const toolTrie = (tools: Tools): ToolNode => { const canonicalSegments = (path: ReadonlyArray): ReadonlyArray => path.flatMap((segment) => segment.split(".")) -const definitions = ( +const flattenTools = ( node: ToolNode, path: ReadonlyArray = [], -): Array<{ path: string; definition: Definition }> => [ - ...(node.definition === undefined ? [] : [{ path: path.join("."), definition: node.definition }]), - ...Array.from(node.children, ([name, child]) => definitions(child, [...path, name])).flat(), +): Array<{ path: string; tool: Tool }> => [ + ...(node.tool === undefined ? [] : [{ path: path.join("."), tool: node.tool }]), + ...Array.from(node.children, ([name, child]) => flattenTools(child, [...path, name])).flat(), ] -const describeDefinition = (path: string, definition: Definition): ToolDescription => ({ +const describeTool = (path: string, tool: Tool): ToolDescription => ({ path, - description: definition.description, - signature: `${toolExpression(path)}(input: ${inputTypeScript(definition, true)}): Promise<${outputTypeScript(definition, true)}>`, + description: tool.description, + signature: `${toolExpression(path)}(input: ${inputTypeScript(tool, true)}): Promise<${outputTypeScript(tool, true)}>`, }) -const visibleDefinitions = (tools: Tools) => - definitions(toolTrie(tools)).map(({ path, definition }) => ({ +const visibleTools = (tools: Tools) => + flattenTools(toolTrie(tools)).map(({ path, tool }) => ({ path, - definition, - description: describeDefinition(path, definition), + tool, + description: describeTool(path, tool), })) export type DiscoveryPlan = { @@ -361,12 +359,12 @@ const termForms = (term: string): Array => { return forms } -const makeSearchTool = (searchIndex: ReadonlyArray): Definition => ({ +const makeSearchTool = (searchIndex: ReadonlyArray): Tool => ({ _tag: "CodeModeTool", description: "Search available tools", input: SearchInput, output: SearchOutput, - run: (input) => + execute: (input) => Effect.sync(() => { const request = input as typeof SearchInput.Type const query = request.query ?? "" @@ -422,8 +420,8 @@ const makeSearchTool = (searchIndex: ReadonlyArray): Definition => }) const searchSignature = (() => { - const definition = makeSearchTool([]) - return `search(input: ${inputTypeScript(definition, true)}): ${outputTypeScript(definition, true)}` + const tool = makeSearchTool([]) + return `search(input: ${inputTypeScript(tool, true)}): ${outputTypeScript(tool, true)}` })() const catalogLine = (tool: ToolDescription) => { @@ -432,13 +430,13 @@ const catalogLine = (tool: ToolDescription) => { return description === "" ? ` - ${tool.signature}` : ` - ${tool.signature} // ${description}` } -const toSearchEntry = (path: string, definition: Definition, description: ToolDescription): SearchEntry => ({ +const toSearchEntry = (path: string, tool: Tool, description: ToolDescription): SearchEntry => ({ description, namespace: path.split(".", 1)[0]!, searchText: [ path, - definition.description, - ...inputProperties(definition).flatMap(({ name, description: property }) => + tool.description, + ...inputProperties(tool).flatMap(({ name, description: property }) => property === undefined ? [name] : [name, property], ), ] @@ -447,14 +445,14 @@ const toSearchEntry = (path: string, definition: Definition, description: }) export const searchIndex = (tools: Tools): ReadonlyArray => - visibleDefinitions(tools).map(({ path, definition, description }) => toSearchEntry(path, definition, description)) + visibleTools(tools).map(({ path, tool, description }) => toSearchEntry(path, tool, description)) // Budget signatures round-robin so every namespace remains visible. export const prepare = (tools: Tools, catalogBudget = defaultCatalogBudget): DiscoveryPlan => { if (!Number.isSafeInteger(catalogBudget) || catalogBudget < 0) { throw new RangeError("discovery.catalogBudget must be a non-negative safe integer") } - const visible = visibleDefinitions(tools) + const visible = visibleTools(tools) const described = visible.map(({ description }) => description) const namespaces = new Map>() @@ -589,7 +587,7 @@ export const prepare = (tools: Tools, catalogBudget = defaultCatalogBudget return { catalog: described, instructions: lines.join("\n"), - searchIndex: visible.map(({ path, definition, description }) => toSearchEntry(path, definition, description)), + searchIndex: visible.map(({ path, tool, description }) => toSearchEntry(path, tool, description)), } } @@ -605,7 +603,7 @@ const namespaceKeys = (root: ToolNode, path: ReadonlyArray): Reado return Array.from(node.children.keys()) } -const resolve = (root: ToolNode, path: ReadonlyArray): Definition => { +const resolve = (root: ToolNode, path: ReadonlyArray): Tool => { const segments = canonicalSegments(path) const node = lookup(root, segments) if (node === undefined) { @@ -613,16 +611,16 @@ const resolve = (root: ToolNode, path: ReadonlyArray): Definition< "Use search({ query }) to find available described tools.", ]) } - if (node.definition === undefined) { + if (node.tool === undefined) { throw new ToolRuntimeError("UnknownTool", `Tool '${segments.join(".")}' is not callable.`) } - return node.definition + return node.tool } export type ToolRuntime = { readonly root: ToolReference readonly calls: Array - readonly invoke: (path: ReadonlyArray, args: Array) => Effect.Effect + readonly execute: (path: ReadonlyArray, args: Array) => Effect.Effect readonly search: (args: Array) => Effect.Effect readonly keys: (path: ReadonlyArray) => ReadonlyArray } @@ -676,7 +674,7 @@ export const make = ( return calls.length - 1 }).pipe(Effect.tap((index) => hooks?.onToolCallStart?.({ index, name, input }) ?? Effect.void)) - const invokeDefinition = (name: string, tool: Definition, externalArgs: Array) => + const executeTool = (name: string, tool: Tool, externalArgs: Array) => Effect.gen(function* () { if (externalArgs.length !== 1) throw new ToolRuntimeError("InvalidToolInput", `Tool '${name}' expects exactly one input object.`) @@ -688,7 +686,7 @@ export const make = ( const index = yield* recordAndObserve(name, input) return yield* observeEnd( Effect.gen(function* () { - const raw = yield* runHost(Effect.suspend(() => tool.run(input))) + const raw = yield* runHost(Effect.suspend(() => tool.execute(input))) const result = yield* Effect.try({ try: () => decodeToolOutput(tool, raw), catch: () => new ToolRuntimeError("InvalidToolOutput", `Invalid output from tool '${name}'.`), @@ -705,18 +703,18 @@ export const make = ( keys: (path) => namespaceKeys(root, path), search: (args) => Effect.suspend(() => - invokeDefinition( + executeTool( "search", searchTool, args.map((arg) => copyOut(copyIn(arg, "Arguments for tool 'search'"), "json")), ), ), - invoke: (path, args) => + execute: (path, args) => Effect.gen(function* () { const name = canonicalSegments(path).join(".") const externalArgs = args.map((arg) => copyOut(copyIn(arg, `Arguments for tool '${name}'`), "json")) const tool = resolve(root, path) - return yield* invokeDefinition(name, tool, externalArgs) + return yield* executeTool(name, tool, externalArgs) }), } } diff --git a/packages/codemode/src/tool-schema.ts b/packages/codemode/src/tool-schema.ts index d8b48dc548..52e1cd4033 100644 --- a/packages/codemode/src/tool-schema.ts +++ b/packages/codemode/src/tool-schema.ts @@ -1,5 +1,5 @@ import { JsonPointer, Schema } from "effect" -import type { Definition, JsonSchema, SchemaType } from "./tool.js" +import type { Tool, JsonSchema, SchemaType } from "./tool.js" const isEffectSchema = (schema: SchemaType): schema is Schema.Decoder & Schema.Top => Schema.isSchema(schema) @@ -192,16 +192,16 @@ export type InputProperty = { readonly required: boolean } -export const inputProperties = (definition: Definition): Array => { +export const inputProperties = (tool: Tool): Array => { try { - const document = isEffectSchema(definition.input) - ? (Schema.toJsonSchemaDocument(definition.input) as { + const document = isEffectSchema(tool.input) + ? (Schema.toJsonSchemaDocument(tool.input) as { readonly schema: JsonSchema readonly definitions?: Readonly> }) : { - schema: definition.input, - definitions: { ...(definition.input.definitions ?? {}), ...(definition.input.$defs ?? {}) }, + schema: tool.input, + definitions: { ...(tool.input.definitions ?? {}), ...(tool.input.$defs ?? {}) }, } const definitions = document.definitions ?? {} let schema = document.schema @@ -223,22 +223,22 @@ export const inputProperties = (definition: Definition): Array(definition: Definition, pretty = false): string => - isEffectSchema(definition.input) - ? toTypeScript(definition.input, false, pretty) - : jsonSchemaToTypeScript(definition.input, pretty) +export const inputTypeScript = (tool: Tool, pretty = false): string => + isEffectSchema(tool.input) ? toTypeScript(tool.input, false, pretty) : jsonSchemaToTypeScript(tool.input, pretty) -export const outputTypeScript = (definition: Definition, pretty = false): string => - definition.output === undefined - ? "unknown" - : isEffectSchema(definition.output) - ? toTypeScript(definition.output, true, pretty) - : jsonSchemaToTypeScript(definition.output, pretty) +export const outputTypeScript = (tool: Tool, pretty = false): string => + tool.output === undefined + ? "void" + : isEffectSchema(tool.output) + ? toTypeScript(tool.output, true, pretty) + : jsonSchemaToTypeScript(tool.output, pretty) -export const decodeInput = (definition: Definition, value: unknown): unknown => - isEffectSchema(definition.input) ? Schema.decodeUnknownSync(definition.input)(value) : value +export const decodeInput = (tool: Tool, value: unknown): unknown => + isEffectSchema(tool.input) ? Schema.decodeUnknownSync(tool.input)(value) : value -export const decodeOutput = (definition: Definition, value: unknown): unknown => - definition.output !== undefined && isEffectSchema(definition.output) - ? Schema.decodeUnknownSync(definition.output)(value) - : value +export const decodeOutput = (tool: Tool, value: unknown): unknown => + tool.output === undefined + ? undefined + : isEffectSchema(tool.output) + ? Schema.decodeUnknownSync(tool.output)(value) + : value diff --git a/packages/codemode/src/tool.ts b/packages/codemode/src/tool.ts index e75fa7ba26..d44e3d0f4e 100644 --- a/packages/codemode/src/tool.ts +++ b/packages/codemode/src/tool.ts @@ -29,29 +29,29 @@ export type JsonSchema = { /** Either a validating Effect Schema or a render-only JSON Schema document. */ export type SchemaType = Schema.Decoder | JsonSchema -/** Schema-backed tool definition exposed through CodeMode's `tools` object. */ -export type Definition = { +/** Executable tool tool exposed through CodeMode's `tools` object. */ +export type Tool = { readonly _tag: "CodeModeTool" readonly description: string readonly input: SchemaType readonly output: SchemaType | undefined - readonly run: (input: unknown) => Effect.Effect + readonly execute: (input: unknown) => Effect.Effect } type InputType = S extends Schema.Decoder ? S["Type"] : unknown -type ResultType = S extends Schema.Decoder ? S["Encoded"] : unknown +type ResultType = S extends undefined ? void : S extends Schema.Decoder ? S["Encoded"] : unknown -/** Options for defining one CodeMode tool. */ +/** Options for declaring one CodeMode tool. */ export type Options = { readonly description: string readonly input: I readonly output?: O - readonly run: (input: InputType) => Effect.Effect, unknown, R> + readonly execute: (input: InputType) => Effect.Effect, unknown, R> } -// Object.hasOwn: an inherited _tag must not classify a namespace as a Definition. -export const isDefinition = (value: unknown): value is Definition => +// Object.hasOwn: an inherited _tag must not classify a namespace as a Tool. +export const isTool = (value: unknown): value is Tool => typeof value === "object" && value !== null && "_tag" in value && @@ -59,18 +59,18 @@ export const isDefinition = (value: unknown): value is Definition value._tag === "CodeModeTool" /** - * Defines one schema-described tool available to a CodeMode program through `tools.*`. + * Declares one schema-described tool available to a CodeMode program through `tools.*`. * * Effect Schemas validate values; JSON Schemas only shape the model-visible signature. - * Without `output`, results are exposed as `unknown`. Hosts remain responsible for authorization + * Without `output`, results are exposed as `void`. Hosts remain responsible for authorization * and durable side effects. */ export const make = ( options: Options, -): Definition => ({ +): Tool => ({ _tag: "CodeModeTool", description: options.description, input: options.input, output: options.output, - run: (input) => options.run(input as InputType), + execute: (input) => options.execute(input as InputType), }) diff --git a/packages/codemode/src/tools.ts b/packages/codemode/src/tools.ts index 04e36dcef2..8c9759fb33 100644 --- a/packages/codemode/src/tools.ts +++ b/packages/codemode/src/tools.ts @@ -1,5 +1,5 @@ -import type { Definition } from "./tool.js" +import type { Tool } from "./tool.js" export type Tools = { - readonly [name: string]: Definition | Tools + readonly [name: string]: Tool | Tools } diff --git a/packages/codemode/test/callbacks.test.ts b/packages/codemode/test/callbacks.test.ts index 3c64391718..f257b732c0 100644 --- a/packages/codemode/test/callbacks.test.ts +++ b/packages/codemode/test/callbacks.test.ts @@ -27,7 +27,7 @@ const echo = Tool.make({ description: "Echo the input", input: Schema.Struct({ id: Schema.Number }), output: Schema.Number, - run: (input: { id: number }) => Effect.succeed(input.id), + execute: (input: { id: number }) => Effect.succeed(input.id), }) const withTool = (code: string) => Effect.runPromise(CodeMode.make({ tools: { host: { echo } } }).execute(code)) const toolError = async (code: string) => { diff --git a/packages/codemode/test/codemode.test.ts b/packages/codemode/test/codemode.test.ts index 8699daf07d..9d5db0ba58 100644 --- a/packages/codemode/test/codemode.test.ts +++ b/packages/codemode/test/codemode.test.ts @@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test" import { Cause, Effect, Schema } from "effect" import { CodeMode, Tool, toolError } from "../src/index.js" -const run = (tool: Tool.Definition) => +const run = (tool: Tool.Tool) => Effect.runPromise(CodeMode.make({ tools: { host: { call: tool } } }).execute("return await tools.host.call({})")) class UnsafeHostError extends Schema.TaggedErrorClass()("UnsafeHostError", { @@ -16,7 +16,7 @@ describe("CodeMode host failure boundary", () => { description: "Fail safely", input: Schema.Struct({}), output: Schema.String, - run: () => Effect.fail(toolError("Authorized request was refused")), + execute: () => Effect.fail(toolError("Authorized request was refused")), }), ) @@ -32,7 +32,7 @@ describe("CodeMode host failure boundary", () => { description: "Fail safely", input: Schema.Struct({}), output: Schema.String, - run: () => Effect.fail(toolError("File not found: /tmp/report.json")), + execute: () => Effect.fail(toolError("File not found: /tmp/report.json")), }), ) @@ -52,7 +52,7 @@ describe("CodeMode host failure boundary", () => { description: "Fail internally", input: Schema.Struct({}), output: Schema.String, - run: () => failure, + execute: () => failure, }), ) @@ -71,7 +71,7 @@ describe("CodeMode host failure boundary", () => { description: "Return invalid output", input: Schema.Struct({}), output: Schema.Struct({ safe: Schema.String }), - run: () => Effect.succeed({ safe: 1, secret } as unknown as { readonly safe: string }), + execute: () => Effect.succeed({ safe: 1, secret } as unknown as { readonly safe: string }), }), ) @@ -88,7 +88,7 @@ describe("CodeMode host failure boundary", () => { description: "Return hostile output", input: Schema.Struct({}), output: Schema.Unknown, - run: () => + execute: () => Effect.succeed( new Proxy( {}, @@ -118,7 +118,7 @@ describe("CodeMode host failure boundary", () => { description: "Refuse", input: Schema.Struct({}), output: Schema.String, - run: () => Effect.fail(toolError("Refused")), + execute: () => Effect.fail(toolError("Refused")), }), }, }, @@ -145,7 +145,7 @@ describe("CodeMode host failure boundary", () => { description: "Interrupt", input: Schema.Struct({}), output: Schema.String, - run: () => Effect.interrupt, + execute: () => Effect.interrupt, }), }, }, @@ -166,7 +166,7 @@ describe("CodeMode tool-call observation", () => { description: "Look up a value", input: Schema.Struct({ query: Schema.String }), output: Schema.String, - run: ({ query }) => Effect.succeed(query), + execute: ({ query }) => Effect.succeed(query), }) const result = await Effect.runPromise( @@ -189,7 +189,7 @@ describe("CodeMode tool-call observation", () => { description: "Look up a value", input: Schema.Struct({ query: Schema.String }), output: Schema.String, - run: ({ query }) => (query === "boom" ? Effect.fail(toolError("Lookup refused")) : Effect.succeed(query)), + execute: ({ query }) => (query === "boom" ? Effect.fail(toolError("Lookup refused")) : Effect.succeed(query)), }) const runtime = CodeMode.make({ @@ -430,7 +430,7 @@ describe("CodeMode schema flexibility", () => { properties: { id: { type: "string" }, count: { type: "number" } }, required: ["id"], }, - run: (input) => + execute: (input) => Effect.sync(() => { observed.push(input) return { echoed: input } @@ -442,14 +442,14 @@ describe("CodeMode schema flexibility", () => { { path: "adapter.call", description: "Call an adapter-described tool", - signature: "tools.adapter.call(input: {\n id: string,\n count?: number,\n}): Promise", + signature: "tools.adapter.call(input: {\n id: string,\n count?: number,\n}): Promise", }, ]) // JSON Schema is render-only: mistyped input passes through unvalidated. const result = await Effect.runPromise(runtime.execute(`return await tools.adapter.call({ id: 42 })`)) expect(result.ok).toBe(true) - if (result.ok) expect(result.value).toStrictEqual({ echoed: { id: 42 } }) + if (result.ok) expect(result.value).toBeNull() expect(observed).toStrictEqual([{ id: 42 }]) }) @@ -458,7 +458,7 @@ describe("CodeMode schema flexibility", () => { const call = Tool.make({ description: "Observe raw input", input: { type: "object" }, - run: (input) => + execute: (input) => Effect.sync(() => { observed.push(input) return "ok" @@ -483,7 +483,7 @@ describe("CodeMode schema flexibility", () => { const find = Tool.make({ description: "Find things", input: Schema.Struct({ query: Schema.optionalKey(Schema.String), limit: Schema.optionalKey(Schema.Number) }), - run: (input) => + execute: (input) => Effect.sync(() => { observed.push(input) return "ok" @@ -517,7 +517,7 @@ describe("CodeMode schema flexibility", () => { }, }, }, - run: () => Effect.succeed({ login: "kit", id: 7 }), + execute: () => Effect.succeed({ login: "kit", id: 7 }), }) const runtime = CodeMode.make({ tools: { users: { lookup } } }) @@ -534,18 +534,18 @@ describe("CodeMode schema flexibility", () => { if (result.ok) expect(result.value).toStrictEqual({ login: "kit", id: 7 }) }) - test("Effect Schema output without an input transform still renders unknown when omitted", async () => { + test("Effect Schema output without an input transform renders void when omitted", async () => { const ping = Tool.make({ description: "Ping", input: Schema.Struct({ host: Schema.String }), - run: () => Effect.succeed("pong"), + execute: () => Effect.succeed("pong"), }) const runtime = CodeMode.make({ tools: { net: { ping } } }) - expect(runtime.catalog()[0]?.signature).toBe("tools.net.ping(input: {\n host: string,\n}): Promise") + expect(runtime.catalog()[0]?.signature).toBe("tools.net.ping(input: {\n host: string,\n}): Promise") const result = await Effect.runPromise(runtime.execute(`return await tools.net.ping({ host: "example.test" })`)) expect(result.ok).toBe(true) - if (result.ok) expect(result.value).toBe("pong") + if (result.ok) expect(result.value).toBeNull() }) }) @@ -554,7 +554,7 @@ describe("CodeMode public contract", () => { description: "Look up an order by ID", input: Schema.Struct({ id: Schema.String }), output: Schema.Struct({ id: Schema.String, status: Schema.String }), - run: ({ id }) => Effect.succeed({ id, status: "open" }), + execute: ({ id }) => Effect.succeed({ id, status: "open" }), }) const tools = { orders: { lookup } } const source = `return await tools.orders.lookup({ id: "order_42" })` @@ -577,7 +577,7 @@ describe("CodeMode public contract", () => { description: "echo", input: Schema.Struct({}), output: Schema.Number, - run: () => Effect.succeed(1), + execute: () => Effect.succeed(1), }) const effect = CodeMode.execute({ tools: { host: { echo } }, @@ -634,7 +634,7 @@ describe("CodeMode public contract", () => { description: "Resolve a library ID", input: Schema.Struct({ libraryName: Schema.String }), output: Schema.String, - run: ({ libraryName }) => Effect.succeed(`/resolved/${libraryName}`), + execute: ({ libraryName }) => Effect.succeed(`/resolved/${libraryName}`), }) const runtime = CodeMode.make({ tools: { context7: { "resolve-library-id": resolveLibrary } } }) @@ -760,18 +760,18 @@ describe("CodeMode public contract", () => { expect(instructions).not.toContain("search(") }) - test("uses one ranked search returning complete definitions for large catalogs", async () => { + test("uses one ranked search returning complete tools for large catalogs", async () => { const upload = Tool.make({ description: "Upload one readable local file to the current Discord thread", input: Schema.Struct({ path: Schema.String }), output: Schema.Struct({ sent: Schema.Boolean }), - run: () => Effect.succeed({ sent: true }), + execute: () => Effect.succeed({ sent: true }), }) const generate = Tool.make({ description: "Generate an image and upload it to the current Discord thread", input: Schema.Struct({ prompt: Schema.String }), output: Schema.Struct({ sent: Schema.Boolean }), - run: () => Effect.succeed({ sent: true }), + execute: () => Effect.succeed({ sent: true }), }) const runtime = CodeMode.make({ tools: { thread: { uploadFile: upload, generateImage: generate }, orders: { lookup } }, @@ -865,7 +865,7 @@ describe("CodeMode public contract", () => { description: `Numbered tool ${index}`, input: Schema.Struct({ id: Schema.String }), output: Schema.String, - run: () => Effect.succeed("ok"), + execute: () => Effect.succeed("ok"), }) const runtime = CodeMode.make({ tools: { @@ -911,7 +911,7 @@ describe("CodeMode public contract", () => { description, input: Schema.Struct({ id: Schema.String }), output: Schema.String, - run: () => Effect.succeed("ok"), + execute: () => Effect.succeed("ok"), }) const runtime = CodeMode.make({ tools: { @@ -954,13 +954,13 @@ describe("CodeMode public contract", () => { properties: { attachment: { type: "string", description: "Local path of the payload to send" } }, required: ["attachment"], }, - run: () => Effect.succeed("ok"), + execute: () => Effect.succeed("ok"), }) const other = Tool.make({ description: "Rename the workspace", input: Schema.Struct({ name: Schema.String }), output: Schema.String, - run: () => Effect.succeed("ok"), + execute: () => Effect.succeed("ok"), }) const runtime = CodeMode.make({ tools: { files: { upload, other } } }) @@ -990,7 +990,7 @@ describe("CodeMode public contract", () => { description, input: Schema.Struct({ id: Schema.String }), output: Schema.String, - run: () => Effect.succeed("ok"), + execute: () => Effect.succeed("ok"), }) const runtime = CodeMode.make({ tools: { @@ -1029,7 +1029,7 @@ describe("CodeMode public contract", () => { description, input: Schema.Struct({}), output: Schema.String, - run: () => Effect.succeed("ok"), + execute: () => Effect.succeed("ok"), }) // Deliberately declared out of alphabetical order. const runtime = CodeMode.make({ @@ -1071,7 +1071,7 @@ describe("CodeMode public contract", () => { description: "Cheap", input: Schema.Struct({ q: Schema.String }), output: Schema.String, - run: () => Effect.succeed("ok"), + execute: () => Effect.succeed("ok"), }) const expensive = Tool.make({ description: @@ -1081,7 +1081,7 @@ describe("CodeMode public contract", () => { anotherEvenLongerParameterName: Schema.Number, }), output: Schema.String, - run: () => Effect.succeed("ok"), + execute: () => Effect.succeed("ok"), }) // Round 1 places alpha.cheap (~17 estimated tokens) and beta.cheap (~17); in round 2 // alpha.expensive does not fit, which marks only alpha done - it must NOT prevent @@ -1112,7 +1112,7 @@ describe("CodeMode public contract", () => { }, required: ["id"], } as const, - run: () => Effect.succeed("ok"), + execute: () => Effect.succeed("ok"), }) const runtime = CodeMode.make({ tools: { records: { lookup: documented } }, @@ -1130,7 +1130,7 @@ describe("CodeMode public contract", () => { description: "Double a number", input: Schema.Struct({ value: Schema.NumberFromString }), output: Schema.NumberFromString, - run: ({ value }) => + execute: ({ value }) => Effect.sync(() => { observed.push(value) return String(value * 2) @@ -1226,7 +1226,7 @@ describe("CodeMode public contract", () => { description: "Count invocations", input: Schema.Struct({}), output: Schema.Number, - run: () => Effect.succeed(1), + execute: () => Effect.succeed(1), }) const result = await Effect.runPromise( CodeMode.execute({ diff --git a/packages/codemode/test/enumeration.test.ts b/packages/codemode/test/enumeration.test.ts index 116fbab0d9..981297c7f8 100644 --- a/packages/codemode/test/enumeration.test.ts +++ b/packages/codemode/test/enumeration.test.ts @@ -13,7 +13,7 @@ const echo = (description: string) => description, input: Schema.Struct({ value: Schema.String }), output: Schema.String, - run: ({ value }) => Effect.succeed(value), + execute: ({ value }) => Effect.succeed(value), }) const tools = { diff --git a/packages/codemode/test/openapi.test.ts b/packages/codemode/test/openapi.test.ts index f4da5c31e0..da705977bf 100644 --- a/packages/codemode/test/openapi.test.ts +++ b/packages/codemode/test/openapi.test.ts @@ -143,12 +143,7 @@ describe("OpenAPI.fromSpec", () => { const remove = toolAt(api.tools, "users.remove") expect(api.skipped).toEqual([]) - if ( - !Tool.isDefinition(get) || - !Tool.isDefinition(create) || - !Tool.isDefinition(search) || - !Tool.isDefinition(remove) - ) { + if (!Tool.isTool(get) || !Tool.isTool(create) || !Tool.isTool(search) || !Tool.isTool(remove)) { throw new Error("happy-path fixture did not generate every operation") } expect(inputTypeScript(get)).toBe( @@ -241,23 +236,23 @@ describe("OpenAPI.fromSpec", () => { expect(toolAt(result.tools, "v2.session.create")).not.toBeUndefined() const sessionGet = toolAt(result.tools, "v2.session.get") - expect(Tool.isDefinition(sessionGet)).toBe(true) - if (!Tool.isDefinition(sessionGet)) throw new Error("v2.session.get was not generated") + expect(Tool.isTool(sessionGet)).toBe(true) + if (!Tool.isTool(sessionGet)) throw new Error("v2.session.get was not generated") expect(inputTypeScript(sessionGet)).toBe("{ sessionID: string }") expect(outputTypeScript(sessionGet)).toContain("id: string") expect(outputTypeScript(sessionGet)).toContain("additions: number") const switchAgent = toolAt(result.tools, "v2.session.switchAgent") - expect(Tool.isDefinition(switchAgent)).toBe(true) - if (!Tool.isDefinition(switchAgent)) throw new Error("v2.session.switchAgent was not generated") + expect(Tool.isTool(switchAgent)).toBe(true) + if (!Tool.isTool(switchAgent)) throw new Error("v2.session.switchAgent was not generated") expect(inputTypeScript(switchAgent)).toBe("{ sessionID: string; agent: string }") const instructionPut = toolAt(result.tools, "v2.session.instructions.entry.put") - expect(Tool.isDefinition(instructionPut)).toBe(true) - if (!Tool.isDefinition(instructionPut)) throw new Error("v2.session.instructions.entry.put was not generated") + expect(Tool.isTool(instructionPut)).toBe(true) + if (!Tool.isTool(instructionPut)) throw new Error("v2.session.instructions.entry.put was not generated") expect(inputTypeScript(instructionPut)).toBe("{ sessionID: string; key: string; value: unknown }") expect(toolAt(result.tools, "v2_session_instructions_entry_put_2")).toBeUndefined() - expect(Tool.isDefinition(toolAt(result.tools, "v2.pty.connect"))).toBe(false) + expect(Tool.isTool(toolAt(result.tools, "v2.pty.connect"))).toBe(false) expect(toolAt(result.tools, "v2.session.log")).toBeUndefined() expect(toolAt(result.tools, "v2.event.subscribe")).toBeUndefined() expect(toolAt(result.tools, "v2.fs.read")).toBeUndefined() @@ -278,9 +273,9 @@ describe("OpenAPI.fromSpec", () => { }, }) - expect(Tool.isDefinition(toolAt(result.tools, "group.item"))).toBe(true) - expect(Tool.isDefinition(toolAt(result.tools, "group_item_2"))).toBe(true) - expect(Tool.isDefinition(toolAt(result.tools, "group.operation.other"))).toBe(true) + expect(Tool.isTool(toolAt(result.tools, "group.item"))).toBe(true) + expect(Tool.isTool(toolAt(result.tools, "group_item_2"))).toBe(true) + expect(Tool.isTool(toolAt(result.tools, "group.operation.other"))).toBe(true) }) test("synthesizes flat operation IDs from methods and paths", () => { @@ -305,7 +300,7 @@ describe("OpenAPI.fromSpec", () => { "deleteUsersById", "getOrganizationsByOrganizationidUsersById", ]) { - expect(Tool.isDefinition(toolAt(tools, path))).toBe(true) + expect(Tool.isTool(toolAt(tools, path))).toBe(true) } }) @@ -330,7 +325,7 @@ describe("OpenAPI.fromSpec", () => { "test", ) - if (!Tool.isDefinition(tool)) throw new Error("test was not generated") + if (!Tool.isTool(tool)) throw new Error("test was not generated") expect(inputTypeScript(tool)).toBe("{ limit: number }") }) @@ -358,8 +353,8 @@ describe("OpenAPI.fromSpec", () => { }) const search = toolAt(result.tools, "search") - expect(Tool.isDefinition(search)).toBe(true) - if (!Tool.isDefinition(search)) throw new Error("search was not generated") + expect(Tool.isTool(search)).toBe(true) + if (!Tool.isTool(search)) throw new Error("search was not generated") expect(inputTypeScript(search)).toBe("{ value?: string | null }") const schema: unknown = search.input const input = isRecord(schema) ? schema : {} @@ -397,14 +392,14 @@ describe("OpenAPI.fromSpec", () => { "test", ) - if (!Tool.isDefinition(tool) || !isRecord(tool.output)) throw new Error("test output was not generated") + if (!Tool.isTool(tool) || !isRecord(tool.output)) throw new Error("test output was not generated") expect(tool.output.$defs).toMatchObject({ Local: { type: "string" }, Global: { type: "number" } }) }) test("projects read-only and write-only properties by schema direction", () => { for (const version of ["3.0.3", "3.1.0"]) { const tool = toolAt(OpenAPI.fromSpec({ baseUrl, spec: directionalSpec(version) }).tools, "users.create") - if (!Tool.isDefinition(tool) || !isRecord(tool.input) || !isRecord(tool.output)) { + if (!Tool.isTool(tool) || !isRecord(tool.input) || !isRecord(tool.output)) { throw new Error(`users.create was not generated for OpenAPI ${version}`) } @@ -467,7 +462,7 @@ describe("OpenAPI.fromSpec", () => { }).tools, "test", ) - if (!Tool.isDefinition(tool)) throw new Error("test was not generated") + if (!Tool.isTool(tool)) throw new Error("test was not generated") expect(inputTypeScript(tool)).toBe("{ name: string }") }) @@ -518,7 +513,7 @@ describe("OpenAPI.fromSpec", () => { }).tools, "test", ) - if (!Tool.isDefinition(tool) || !isRecord(tool.input)) throw new Error("test was not generated") + if (!Tool.isTool(tool) || !isRecord(tool.input)) throw new Error("test was not generated") const properties = isRecord(tool.input.properties) ? tool.input.properties : {} const record = isRecord(properties.record) ? properties.record : {} const definitions = isRecord(tool.input.$defs) ? tool.input.$defs : {} @@ -567,7 +562,7 @@ describe("OpenAPI.fromSpec", () => { }).tools, "test", ) - if (!Tool.isDefinition(tool)) throw new Error("test was not generated") + if (!Tool.isTool(tool)) throw new Error("test was not generated") expect(inputTypeScript(tool)).toBe("{ name: string }") }) @@ -607,7 +602,7 @@ describe("OpenAPI.fromSpec", () => { }).tools, "test", ) - if (!Tool.isDefinition(tool) || !isRecord(tool.input)) throw new Error("test was not generated") + if (!Tool.isTool(tool) || !isRecord(tool.input)) throw new Error("test was not generated") const definitions = isRecord(tool.input.$defs) ? tool.input.$defs : {} const node = isRecord(definitions.Node) ? definitions.Node : {} @@ -648,7 +643,7 @@ describe("OpenAPI.fromSpec", () => { }).tools, "test", ) - if (!Tool.isDefinition(tool) || !isRecord(tool.input)) throw new Error("test was not generated") + if (!Tool.isTool(tool) || !isRecord(tool.input)) throw new Error("test was not generated") const definitions = isRecord(tool.input.$defs) ? tool.input.$defs : {} const leaf = isRecord(definitions[`C${depth - 1}`]) ? definitions[`C${depth - 1}`] : {} @@ -686,7 +681,7 @@ describe("OpenAPI.fromSpec", () => { }).tools, "test", ) - if (!Tool.isDefinition(tool)) throw new Error("test was not generated") + if (!Tool.isTool(tool)) throw new Error("test was not generated") expect(inputTypeScript(tool)).toBe("{ name: string }") } @@ -725,7 +720,7 @@ describe("OpenAPI.fromSpec", () => { }).tools, "test", ) - if (!Tool.isDefinition(tool) || !isRecord(tool.input)) throw new Error("test was not generated") + if (!Tool.isTool(tool) || !isRecord(tool.input)) throw new Error("test was not generated") const properties = isRecord(tool.input.properties) ? tool.input.properties : {} const record: Record = isRecord(properties.record) ? properties.record : {} @@ -763,7 +758,7 @@ describe("OpenAPI.fromSpec", () => { }).tools, "test", ) - if (!Tool.isDefinition(tool) || !isRecord(tool.input)) throw new Error("test was not generated") + if (!Tool.isTool(tool) || !isRecord(tool.input)) throw new Error("test was not generated") const properties = isRecord(tool.input.properties) ? tool.input.properties : {} const choice: Record = isRecord(properties.choice) ? properties.choice : {} const pick: Record = isRecord(properties.pick) ? properties.pick : {} @@ -807,7 +802,7 @@ describe("OpenAPI.fromSpec", () => { }).tools, "test", ) - if (!Tool.isDefinition(tool) || !isRecord(tool.input)) throw new Error("test was not generated") + if (!Tool.isTool(tool) || !isRecord(tool.input)) throw new Error("test was not generated") const properties = isRecord(tool.input.properties) ? tool.input.properties : {} const record = isRecord(properties.record) ? properties.record : {} @@ -836,7 +831,7 @@ describe("OpenAPI.fromSpec", () => { }).tools, "test", ) - if (!Tool.isDefinition(tool)) throw new Error("test was not generated") + if (!Tool.isTool(tool)) throw new Error("test was not generated") expect(inputTypeScript(tool)).toBe("{ filter: { state: string } }") }) @@ -866,7 +861,7 @@ describe("OpenAPI.fromSpec", () => { }).tools, "test", ) - if (!Tool.isDefinition(tool)) throw new Error("test was not generated") + if (!Tool.isTool(tool)) throw new Error("test was not generated") expect(inputTypeScript(tool)).toBe("{ filter: { value: string } }") }) @@ -901,7 +896,7 @@ describe("OpenAPI.fromSpec", () => { }).tools, "test", ) - if (!Tool.isDefinition(tool) || !isRecord(tool.input)) throw new Error("test was not generated") + if (!Tool.isTool(tool) || !isRecord(tool.input)) throw new Error("test was not generated") const properties = isRecord(tool.input.properties) ? tool.input.properties : {} const body = isRecord(properties.body) ? properties.body : {} const allOf = Array.isArray(body.allOf) ? body.allOf : [] @@ -923,11 +918,11 @@ describe("OpenAPI.fromSpec", () => { }), ) const tool = toolAt(OpenAPI.fromSpec({ baseUrl, spec: directionalSpec("3.1.0") }).tools, "users.create") - if (!Tool.isDefinition(tool)) throw new Error("users.create was not generated") + if (!Tool.isTool(tool)) throw new Error("users.create was not generated") const result = await Effect.runPromise( tool - .run({ + .execute({ id: "ignored-top-level", generated: "ignored-generated", name: "Ada", @@ -1022,10 +1017,12 @@ describe("OpenAPI.fromSpec", () => { test("serializes deep-object query parameters from the opencode fixture", async () => { const client = recordingClient(() => json({ directory: "/tmp" })) const location = toolAt(OpenAPI.fromSpec({ spec: await opencodeSpec(), baseUrl }).tools, "v2.location.get") - if (!Tool.isDefinition(location)) throw new Error("v2.location.get was not generated") + if (!Tool.isTool(location)) throw new Error("v2.location.get was not generated") await Effect.runPromise( - location.run({ location: { directory: "/tmp", workspace: "workspace-1" } }).pipe(Effect.provide(client.layer)), + location + .execute({ location: { directory: "/tmp", workspace: "workspace-1" } }) + .pipe(Effect.provide(client.layer)), ) const url = new URL(client.requests[0]!.url) @@ -1058,11 +1055,11 @@ describe("OpenAPI.fromSpec", () => { }, }) const tool = toolAt(result.tools, "items") - if (!Tool.isDefinition(tool)) throw new Error("items was not generated") + if (!Tool.isTool(tool)) throw new Error("items was not generated") await Effect.runPromise( tool - .run({ + .execute({ keys: ["a!", "b*"], tags: ["x", "y"], filter: { state: "open", page: 2 }, @@ -1081,9 +1078,9 @@ describe("OpenAPI.fromSpec", () => { expect(url.searchParams.get("nullable")).toBe("null") expect(url.searchParams.get("constructor")).toBe("safe") expect(client.requests[0]!.headers.meta).toBe("a=b,c=d") - await expect(Effect.runPromise(tool.run({ keys: [undefined] }).pipe(Effect.provide(client.layer)))).rejects.toThrow( - "unsupported nested value", - ) + await expect( + Effect.runPromise(tool.execute({ keys: [undefined] }).pipe(Effect.provide(client.layer))), + ).rejects.toThrow("unsupported nested value") }) test("preserves ordered exploded and deep-object query parameters", async () => { @@ -1101,11 +1098,11 @@ describe("OpenAPI.fromSpec", () => { }).tools, "test", ) - if (!Tool.isDefinition(tool)) throw new Error("test was not generated") + if (!Tool.isTool(tool)) throw new Error("test was not generated") await Effect.runPromise( tool - .run({ + .execute({ tags: ["first value", "second&value"], filter: { state: "open now", page: 2 }, location: { directory: "/tmp/a b", workspace: "work&1" }, @@ -1116,14 +1113,14 @@ describe("OpenAPI.fromSpec", () => { expect(client.requests[0]?.url).toBe( `${baseUrl}/test?tags=first+value&tags=second%26value&state=open+now&page=2&location%5Bdirectory%5D=%2Ftmp%2Fa+b&location%5Bworkspace%5D=work%261`, ) - await expect(Effect.runPromise(tool.run({ tags: [{}] }).pipe(Effect.provide(client.layer)))).rejects.toThrow( + await expect(Effect.runPromise(tool.execute({ tags: [{}] }).pipe(Effect.provide(client.layer)))).rejects.toThrow( "Parameter 'tags' contains an unsupported nested value.", ) await expect( - Effect.runPromise(tool.run({ filter: { state: {} } }).pipe(Effect.provide(client.layer))), + Effect.runPromise(tool.execute({ filter: { state: {} } }).pipe(Effect.provide(client.layer))), ).rejects.toThrow("Query parameter 'filter' contains an unsupported nested value.") await expect( - Effect.runPromise(tool.run({ location: { directory: [] } }).pipe(Effect.provide(client.layer))), + Effect.runPromise(tool.execute({ location: { directory: [] } }).pipe(Effect.provide(client.layer))), ).rejects.toThrow("Deep-object parameter 'location' contains an unsupported nested value.") expect(client.requests).toHaveLength(1) }) @@ -1203,9 +1200,9 @@ describe("OpenAPI.fromSpec", () => { }).tools, "getTest", ) - if (!Tool.isDefinition(tool)) throw new Error("test was not generated") + if (!Tool.isTool(tool)) throw new Error("test was not generated") - await Effect.runPromise(tool.run({}).pipe(Effect.provide(client.layer))) + await Effect.runPromise(tool.execute({}).pipe(Effect.provide(client.layer))) expect(inputTypeScript(tool)).toBe("{}") expect(client.requests[0]!.headers.authorization).toBe("Bearer secret") @@ -1240,9 +1237,9 @@ describe("OpenAPI.fromSpec", () => { authenticated([{ key: [] }], { key: { type: "apiKey", in: "query", name: "__proto__" } }).tools, "test", ) - if (!Tool.isDefinition(prototype)) throw new Error("prototype auth tool was not generated") + if (!Tool.isTool(prototype)) throw new Error("prototype auth tool was not generated") - await Effect.runPromise(prototype.run({}).pipe(Effect.provide(client.layer))) + await Effect.runPromise(prototype.execute({}).pipe(Effect.provide(client.layer))) expect(new URL(client.requests[0]!.url).searchParams.get("__proto__")).toBe("secret") const duplicate = toolAt( @@ -1252,8 +1249,8 @@ describe("OpenAPI.fromSpec", () => { }).tools, "test", ) - if (!Tool.isDefinition(duplicate)) throw new Error("duplicate auth tool was not generated") - await expect(Effect.runPromise(duplicate.run({}).pipe(Effect.provide(client.layer)))).rejects.toThrow( + if (!Tool.isTool(duplicate)) throw new Error("duplicate auth tool was not generated") + await expect(Effect.runPromise(duplicate.execute({}).pipe(Effect.provide(client.layer)))).rejects.toThrow( "multiple credentials", ) @@ -1278,8 +1275,8 @@ describe("OpenAPI.fromSpec", () => { }, }) const alternativeTool = toolAt(alternative.tools, "test") - if (!Tool.isDefinition(alternativeTool)) throw new Error("supported auth alternative was not generated") - await Effect.runPromise(alternativeTool.run({}).pipe(Effect.provide(client.layer))) + if (!Tool.isTool(alternativeTool)) throw new Error("supported auth alternative was not generated") + await Effect.runPromise(alternativeTool.execute({}).pipe(Effect.provide(client.layer))) expect(client.requests.at(-1)?.headers.authorization).toBe("Bearer secret") }) @@ -1290,9 +1287,9 @@ describe("OpenAPI.fromSpec", () => { servers: [{ url: "https://document.example" }], } satisfies Document const tool = toolAt(OpenAPI.fromSpec({ spec }).tools, "test") - if (!Tool.isDefinition(tool)) throw new Error("test was not generated") + if (!Tool.isTool(tool)) throw new Error("test was not generated") - await Effect.runPromise(tool.run({}).pipe(Effect.provide(client.layer))) + await Effect.runPromise(tool.execute({}).pipe(Effect.provide(client.layer))) expect(client.requests[0]?.url).toBe("https://operation.example/v1/test") const invalid = OpenAPI.fromSpec({ spec, baseUrl: "https://example.com/api?tenant=one" }) @@ -1363,10 +1360,10 @@ describe("OpenAPI.fromSpec", () => { }).tools, "test", ) - if (!Tool.isDefinition(tool)) throw new Error("test was not generated") + if (!Tool.isTool(tool)) throw new Error("test was not generated") await expect( - Effect.runPromise(tool.run({ filter: { value: undefined } }).pipe(Effect.provide(client.layer))), + Effect.runPromise(tool.execute({ filter: { value: undefined } }).pipe(Effect.provide(client.layer))), ).rejects.toThrow("unsupported nested value") expect(resolutions).toEqual([]) expect(client.requests).toEqual([]) @@ -1389,33 +1386,33 @@ describe("OpenAPI.fromSpec", () => { }).tools, "test", ) - if (!Tool.isDefinition(tool)) throw new Error("test was not generated") + if (!Tool.isTool(tool)) throw new Error("test was not generated") - await Effect.runPromise(tool.run({ body: { name: "updated" } }).pipe(Effect.provide(client.layer))) + await Effect.runPromise(tool.execute({ body: { name: "updated" } }).pipe(Effect.provide(client.layer))) expect(client.requests[0]!.headers["content-type"]).toBe("application/merge-patch+json") const cyclic: Record = {} cyclic.self = cyclic - await expect(Effect.runPromise(tool.run({ body: cyclic }).pipe(Effect.provide(client.layer)))).rejects.toThrow( + await expect(Effect.runPromise(tool.execute({ body: cyclic }).pipe(Effect.provide(client.layer)))).rejects.toThrow( "Invalid JSON body", ) }) test("rejects oversized and malformed JSON responses", async () => { const tool = toolAt(OpenAPI.fromSpec({ baseUrl, spec: singleOperation({}) }).tools, "test") - if (!Tool.isDefinition(tool)) throw new Error("test was not generated") + if (!Tool.isTool(tool)) throw new Error("test was not generated") const oversized = recordingClient( () => new Response(null, { headers: { "content-length": String(50 * 1024 * 1024 + 1) } }), ) const malformed = recordingClient(() => new Response("{", { headers: { "content-type": "application/json" } })) const chunked = recordingClient(() => new Response(new Uint8Array(50 * 1024 * 1024 + 1))) - await expect(Effect.runPromise(tool.run({}).pipe(Effect.provide(oversized.layer)))).rejects.toThrow( + await expect(Effect.runPromise(tool.execute({}).pipe(Effect.provide(oversized.layer)))).rejects.toThrow( "response exceeds 50 MiB", ) - await expect(Effect.runPromise(tool.run({}).pipe(Effect.provide(malformed.layer)))).rejects.toThrow( + await expect(Effect.runPromise(tool.execute({}).pipe(Effect.provide(malformed.layer)))).rejects.toThrow( "returned malformed JSON", ) - await expect(Effect.runPromise(tool.run({}).pipe(Effect.provide(chunked.layer)))).rejects.toThrow( + await expect(Effect.runPromise(tool.execute({}).pipe(Effect.provide(chunked.layer)))).rejects.toThrow( "response exceeds 50 MiB", ) }) @@ -1428,11 +1425,11 @@ describe("OpenAPI.fromSpec", () => { }, }) const tool = toolAt(OpenAPI.fromSpec({ baseUrl, spec }).tools, "test") - if (!Tool.isDefinition(tool)) throw new Error("test was not generated") + if (!Tool.isTool(tool)) throw new Error("test was not generated") const client = recordingClient(() => new Response("123", { headers: { "content-type": "text/plain" } })) expect(outputTypeScript(tool)).toBe("string | null") - await expect(Effect.runPromise(tool.run({}).pipe(Effect.provide(client.layer)))).resolves.toBe("123") + await expect(Effect.runPromise(tool.execute({}).pipe(Effect.provide(client.layer)))).resolves.toBe("123") }) test("fails missing required parameters before auth and network", async () => { @@ -1497,13 +1494,13 @@ describe("OpenAPI.fromSpec", () => { const update = toolAt(tools, "things.update") const echo = toolAt(tools, "echo") - expect(Tool.isDefinition(update)).toBe(true) - if (!Tool.isDefinition(update)) throw new Error("things.update was not generated") + expect(Tool.isTool(update)).toBe(true) + if (!Tool.isTool(update)) throw new Error("things.update was not generated") expect(inputTypeScript(update)).toBe( "{ path_id: string; query_id: string; path_id_2?: string; header_id: string; body_id: string }", ) - expect(Tool.isDefinition(echo)).toBe(true) - if (!Tool.isDefinition(echo)) throw new Error("echo was not generated") + expect(Tool.isTool(echo)).toBe(true) + if (!Tool.isTool(echo)) throw new Error("echo was not generated") expect(inputTypeScript(echo)).toBe("{ body: string }") const runtime = CodeMode.make({ tools }) @@ -1584,13 +1581,13 @@ describe("OpenAPI.fromSpec", () => { for (const name of ["optional", "dictionary", "composed", "nullable"]) { const tool = toolAt(tools, `body.${name}`) - expect(Tool.isDefinition(tool)).toBe(true) - if (!Tool.isDefinition(tool)) throw new Error(`body.${name} was not generated`) + expect(Tool.isTool(tool)).toBe(true) + if (!Tool.isTool(tool)) throw new Error(`body.${name} was not generated`) const input = isRecord(tool.input) ? tool.input : {} expect(Object.keys(isRecord(input.properties) ? input.properties : {})).toStrictEqual(["body"]) } const optional = toolAt(tools, "body.optional") - if (!Tool.isDefinition(optional)) throw new Error("body.optional was not generated") + if (!Tool.isTool(optional)) throw new Error("body.optional was not generated") expect(inputTypeScript(optional)).toBe("{ body?: { name: string } }") }) }) diff --git a/packages/codemode/test/promise.test.ts b/packages/codemode/test/promise.test.ts index f5b52eeefd..63d8540e90 100644 --- a/packages/codemode/test/promise.test.ts +++ b/packages/codemode/test/promise.test.ts @@ -33,7 +33,7 @@ const echoTool = (trace: Trace) => description: "Echo an id immediately", input: Schema.Struct({ id: Schema.Number }), output: Schema.Number, - run: ({ id }) => + execute: ({ id }) => Effect.sync(() => { trace.starts.push(id) trace.completed += 1 @@ -46,7 +46,7 @@ const gatedTool = (trace: Trace, gate: (id: number) => Deferred.Deferred) description: "Echo an id once its gate opens", input: Schema.Struct({ id: Schema.Number }), output: Schema.Number, - run: ({ id }) => + execute: ({ id }) => Effect.gen(function* () { trace.starts.push(id) trace.active += 1 @@ -70,7 +70,7 @@ const openTool = (gate: (id: number) => Deferred.Deferred) => description: "Open the gate for an id", input: Schema.Struct({ id: Schema.Number }), output: Schema.Boolean, - run: ({ id }) => Deferred.succeed(gate(id), undefined), + execute: ({ id }) => Deferred.succeed(gate(id), undefined), }) const pendingTool = (trace: Trace) => @@ -78,7 +78,7 @@ const pendingTool = (trace: Trace) => description: "Never settle", input: Schema.Struct({ id: Schema.Number }), output: Schema.Number, - run: ({ id }) => + execute: ({ id }) => Effect.gen(function* () { trace.starts.push(id) trace.active += 1 @@ -98,14 +98,14 @@ const failingTool = Tool.make({ description: "Always refuse", input: Schema.Struct({}), output: Schema.String, - run: () => Effect.fail(toolError("Lookup refused")), + execute: () => Effect.fail(toolError("Lookup refused")), }) const interruptedTool = Tool.make({ description: "Interrupt this call", input: Schema.Struct({}), output: Schema.String, - run: () => Effect.interrupt, + execute: () => Effect.interrupt, }) const completedTool = (trace: Trace) => @@ -113,7 +113,7 @@ const completedTool = (trace: Trace) => description: "Return the number of completed calls", input: Schema.Struct({}), output: Schema.Number, - run: () => Effect.succeed(trace.completed), + execute: () => Effect.succeed(trace.completed), }) /** Never settles, and holds interruption cleanup for `cleanupMs` so completion cleanup can outlast a timeout. */ @@ -122,7 +122,7 @@ const stubbornTool = (trace: Trace) => description: "Never settle; clean up slowly when interrupted", input: Schema.Struct({ cleanupMs: Schema.Number }), output: Schema.Number, - run: ({ cleanupMs }) => + execute: ({ cleanupMs }) => Effect.never.pipe( Effect.onInterrupt(() => Effect.andThen( diff --git a/packages/codemode/test/signature.test.ts b/packages/codemode/test/signature.test.ts index dea0e890ac..38121e5c6e 100644 --- a/packages/codemode/test/signature.test.ts +++ b/packages/codemode/test/signature.test.ts @@ -18,7 +18,8 @@ const listIssues = Tool.make({ }, required: ["owner"], }, - run: () => Effect.succeed("[]"), + output: {}, + execute: () => Effect.succeed("[]"), }) // An Effect Schema tool whose field annotations must flow through the emitted JSON Schema. @@ -31,7 +32,7 @@ const lookupOrder = Tool.make({ output: Schema.Struct({ status: Schema.String.annotate({ description: "Current order status" }), }), - run: () => Effect.succeed({ status: "open" }), + execute: () => Effect.succeed({ status: "open" }), }) describe("pretty signature rendering", () => { @@ -261,7 +262,7 @@ describe("non-identifier property names render as quoted keys", () => { properties: { "content-type": { type: "string" } }, required: ["content-type"], } as const, - run: () => Effect.succeed({ "content-type": "text/plain" }), + execute: () => Effect.succeed({ "content-type": "text/plain" }), }) expect(inputTypeScript(tool)).toContain('"foo-bar"?: string') expect(outputTypeScript(tool)).toBe('{ "content-type": string }') @@ -272,7 +273,7 @@ describe("non-identifier property names render as quoted keys", () => { const tool = Tool.make({ description: "Schema tool with awkward field names", input: Schema.Struct({ "foo-bar": Schema.String, plain: Schema.optionalKey(Schema.Number) }), - run: () => Effect.succeed(null), + execute: () => Effect.succeed(null), }) expect(inputTypeScript(tool)).toBe('{ "foo-bar": string; plain?: number }') expect(inputTypeScript(tool, true)).toBe(["{", ' "foo-bar": string,', " plain?: number,", "}"].join("\n")) @@ -306,7 +307,7 @@ describe("union schemas render every alternative", () => { }, } as const, output: { anyOf: [{ type: "number" }, { type: "boolean" }] } as const, - run: () => Effect.succeed(1), + execute: () => Effect.succeed(1), }) expect(inputTypeScript(tool)).toBe("{ value?: string | number }") expect(outputTypeScript(tool)).toBe("number | boolean") @@ -417,7 +418,8 @@ describe("non-identifier tool paths", () => { }, required: ["query", "libraryName"], } as const, - run: () => Effect.succeed("/reactjs/react.dev"), + output: {}, + execute: () => Effect.succeed("/reactjs/react.dev"), }) const runtime = CodeMode.make({ tools: { context7: { "resolve-library-id": resolveLibrary } } }) diff --git a/packages/codemode/test/stdlib.test.ts b/packages/codemode/test/stdlib.test.ts index 19989b75a5..e3a840fd0f 100644 --- a/packages/codemode/test/stdlib.test.ts +++ b/packages/codemode/test/stdlib.test.ts @@ -329,7 +329,7 @@ describe("RegExp", () => { description: "Decorate a string", input: Schema.String, output: Schema.String, - run: (input) => Effect.succeed(`[${input}]`), + execute: (input) => Effect.succeed(`[${input}]`), }) const result = await Effect.runPromise( CodeMode.execute({ @@ -1028,7 +1028,7 @@ describe("CodeMode values at intra-CodeMode checkpoints", () => { const capture = Tool.make({ description: "Capture the exact input the host receives", input: { type: "object" }, - run: (input) => + execute: (input) => Effect.sync(() => { observed.push(input) return "ok" diff --git a/packages/codemode/test/tool-paths.test.ts b/packages/codemode/test/tool-paths.test.ts index c92739c5df..91d23cdee7 100644 --- a/packages/codemode/test/tool-paths.test.ts +++ b/packages/codemode/test/tool-paths.test.ts @@ -7,7 +7,7 @@ const echo = (description: string, result: string) => description, input: Schema.Struct({}), output: Schema.String, - run: () => Effect.succeed(result), + execute: () => Effect.succeed(result), }) const value = async (runtime: CodeMode.Runtime, code: string) => { @@ -88,7 +88,7 @@ describe("callable namespaces", () => { expect(diagnostic.message).toContain("Unknown tool 'issues.missing'") }) - test("a namespace without its own definition stays non-callable", async () => { + test("a namespace without its own tool stays non-callable", async () => { const nested = CodeMode.make({ tools: { "issues.list": echo("List issues", "list") } }) const diagnostic = await failure(nested, `return await tools.issues({})`) expect(diagnostic.kind).toBe("UnknownTool") @@ -114,9 +114,9 @@ describe("blocked member names on tool paths", () => { expect(await value(runtime, `return Object.keys(tools.issues)`)).toEqual(["constructor"]) }) - test("a literal __proto__ key cannot poison a namespace into a fake definition", async () => { + test("a literal __proto__ key cannot poison a namespace into a fake tool", async () => { const poisoned = CodeMode.make({ - tools: { ns: { "__proto__": echo("Hidden", "hidden"), real: echo("Real tool", "real") } }, + tools: { ns: { __proto__: echo("Hidden", "hidden"), real: echo("Real tool", "real") } }, }) expect(poisoned.catalog().map((tool) => tool.path)).toEqual(["ns.real"]) expect(await value(poisoned, `return await tools.ns.real({})`)).toBe("real") @@ -138,7 +138,7 @@ describe("empty segments", () => { }) describe("canonical path collisions", () => { - test("the last definition supplied for a canonical path wins", async () => { + test("the last tool supplied for a canonical path wins", async () => { const runtime = CodeMode.make({ tools: { "issues.list": echo("First", "first"), issues: { list: echo("Second", "second") } }, }) diff --git a/packages/core/src/codemode.ts b/packages/core/src/codemode.ts index cd7e9ee557..7d2dd2c570 100644 --- a/packages/core/src/codemode.ts +++ b/packages/core/src/codemode.ts @@ -4,19 +4,17 @@ import { Context, Effect, Layer, Scope } from "effect" import { makeLocationNode } from "@opencode-ai/util/effect/app-node" import { PermissionV2 } from "./permission" import { ExecuteTool } from "./tool/execute" -import { permission, registrationEntries, type AnyTool } from "./tool/tool" -import { Tools } from "./tool/tools" +import type { Any, Registration } from "./tool/tool" import { Wildcard } from "./util/wildcard" export interface Materialization { - readonly tool?: AnyTool + readonly tool?: Any readonly instructions?: string } export interface Interface { readonly register: ( - tools: Readonly>, - options?: Tools.RegisterOptions, + registrations: ReadonlyArray, ) => Effect.Effect readonly materialize: (permissions?: PermissionV2.Ruleset) => Effect.Effect } @@ -26,29 +24,28 @@ export class Service extends Context.Service()("@opencode/v2 const layer = Layer.effect( Service, Effect.gen(function* () { - const local = new Map< - string, - Array<{ readonly token: object; readonly registration: ExecuteTool.Registration }> - >() + const local = new Map>() return Service.of({ - register: Effect.fn("CodeMode.register")(function* (tools, options) { - const entries = registrationEntries(tools, options?.namespace) - if (entries.length === 0) return + register: Effect.fn("CodeMode.register")(function* (registrations) { + if (registrations.length === 0) return yield* Effect.uninterruptible( Effect.gen(function* () { const token = {} - for (const entry of entries) - local.set(entry.key, [ - ...(local.get(entry.key) ?? []), - { token, registration: { tool: entry.tool, name: entry.name, namespace: entry.namespace } }, + for (const registration of registrations) + local.set(registration.key, [ + ...(local.get(registration.key) ?? []), + { + token, + registration, + }, ]) yield* Effect.addFinalizer(() => Effect.sync(() => { - for (const entry of entries) { - const registrations = local.get(entry.key)?.filter((item) => item.token !== token) ?? [] - if (registrations.length > 0) local.set(entry.key, registrations) - else local.delete(entry.key) + for (const registration of registrations) { + const remaining = local.get(registration.key)?.filter((item) => item.token !== token) ?? [] + if (remaining.length > 0) local.set(registration.key, remaining) + else local.delete(registration.key) } }), ) @@ -61,7 +58,7 @@ const layer = Layer.effect( for (const [name, entries] of local) { const registration = entries.at(-1)?.registration if (!registration) continue - const rule = rules.findLast((rule) => Wildcard.match(permission(registration.tool, name), rule.action)) + const rule = rules.findLast((rule) => Wildcard.match(registration.permission, rule.action)) if (rule?.resource === "*" && rule.effect === "deny") continue registrations.set(name, registration) } diff --git a/packages/core/src/database/migration.gen.ts b/packages/core/src/database/migration.gen.ts index dd6214b095..a7eaed9b73 100644 --- a/packages/core/src/database/migration.gen.ts +++ b/packages/core/src/database/migration.gen.ts @@ -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[] diff --git a/packages/core/src/database/migration/20260722170000_canonical_tool_results.ts b/packages/core/src/database/migration/20260722170000_canonical_tool_results.ts new file mode 100644 index 0000000000..2cb656cd02 --- /dev/null +++ b/packages/core/src/database/migration/20260722170000_canonical_tool_results.ts @@ -0,0 +1,123 @@ +import { sql } from "drizzle-orm" +import { Effect, Schema } from "effect" +import type { DatabaseMigration } from "../migration" + +const decodeJson = Schema.decodeUnknownOption(Schema.UnknownFromJsonString) +const isObject = Schema.is(Schema.Record(Schema.String, Schema.Unknown)) +const isJsonObject = Schema.is(Schema.Record(Schema.String, Schema.Json)) + +const object = (value: unknown): Record => (isObject(value) ? value : {}) + +const stringify = (value: unknown) => { + try { + return JSON.stringify(value, null, 2) ?? String(value) + } catch { + return String(value) + } +} + +const contentOf = (state: Record) => (Array.isArray(state.content) ? state.content : []) +const resultOf = (state: Record) => + isObject(state.result) && "value" in state.result ? state.result.value : state.result +const metadataOf = (state: Record) => { + 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) => { + const preserved = contentOf(state) + if (preserved.length > 0) return preserved + return [{ type: "text", text: stringify(Object.keys(object(state.structured)).length ? state.structured : resultOf(state)) }] +} + +/** + * One-time rewrite of projected tool rows into the canonical result shape: + * terminal states store model content plus optional metadata; the generic + * `structured` and `result` fields disappear. Provider-hosted result payloads + * move into provider-owned result state so hosted continuation survives. + * Pre-release durable event versions are intentionally left untouched. + */ +export default { + id: "20260722170000_canonical_tool_results", + up(tx) { + return Effect.gen(function* () { + // Keyset-paginated batches keep memory bounded: production databases hold + // gigabytes of assistant rows, and materializing them all at once was + // measured at a ~5GB RSS spike. + let cursor = "" + while (true) { + const messages = yield* tx.all<{ id: string; data: string }>( + sql`SELECT id, data FROM session_message WHERE type = 'assistant' AND id > ${cursor} ORDER BY id LIMIT 1000`, + ) + if (messages.length === 0) break + cursor = messages[messages.length - 1].id + yield* rewrite(tx, messages) + } + }) + }, +} satisfies DatabaseMigration.Migration + +function rewrite(tx: Parameters[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}`) + } + }) +} diff --git a/packages/core/src/plugin/host.ts b/packages/core/src/plugin/host.ts index a97b6bf0bb..7761aa1823 100644 --- a/packages/core/src/plugin/host.ts +++ b/packages/core/src/plugin/host.ts @@ -127,8 +127,7 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int ), }, model: { - get: (providerID, modelID) => - catalog.model.get(ProviderV2.ID.make(providerID), ModelV2.ID.make(modelID)), + get: (providerID, modelID) => catalog.model.get(ProviderV2.ID.make(providerID), ModelV2.ID.make(modelID)), list: () => response(catalog.model.available()), default: () => response(catalog.model.default()), }, @@ -358,7 +357,7 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int Effect.gen(function* () { const registrations: Array<{ readonly name: string - readonly tool: Tool.AnyTool + readonly tool: Tool.Any readonly options?: Tool.RegisterOptions }> = [] yield* Effect.sync(() => @@ -395,25 +394,42 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int }) } return toolHooks.hook.after((event) => { - const output = { + // JS plugin boundary: marshal the canonical outcome out, copy mutations back. + const output: Record = { tool: event.tool, sessionID: event.sessionID, agent: event.agent, messageID: event.messageID, callID: event.callID, input: event.input, - result: event.result, - output: event.output, + status: event.status, + content: event.content, + metadata: event.metadata, outputPaths: event.outputPaths, + ...(event.status === "error" ? { error: event.error } : {}), } return Reflect.apply(callback, undefined, [output]).pipe( - Effect.tap(() => - Effect.sync(() => { - event.result = output.result - event.output = output.output - event.outputPaths = output.outputPaths - }), - ), + Effect.tap(() => { + const decoded = Schema.decodeUnknownOption(Tool.ExecuteAfterOutcome)(output) + if (decoded._tag === "None") + return Effect.logWarning("ignoring invalid execute.after tool outcome", { tool: event.tool }) + if (decoded.value.status !== event.status) + return Effect.logWarning("ignoring execute.after tool status change", { tool: event.tool }) + return Effect.sync(() => { + if (event.status === "completed" && decoded.value.status === "completed") { + if (output.content !== event.content) event.content = decoded.value.content + if (output.metadata !== event.metadata) event.metadata = decoded.value.metadata + if (output.outputPaths !== event.outputPaths) event.outputPaths = decoded.value.outputPaths + return + } + if (event.status === "error" && decoded.value.status === "error") { + if (output.error !== event.error) event.error = decoded.value.error + if (output.content !== event.content) event.content = decoded.value.content + if (output.metadata !== event.metadata) event.metadata = decoded.value.metadata + if (output.outputPaths !== event.outputPaths) event.outputPaths = decoded.value.outputPaths + } + }) + }), ) }) }, diff --git a/packages/core/src/plugin/promise.ts b/packages/core/src/plugin/promise.ts index e54365e79f..950f6a2afc 100644 --- a/packages/core/src/plugin/promise.ts +++ b/packages/core/src/plugin/promise.ts @@ -2,7 +2,7 @@ export * as PluginPromise from "./promise" import { define } from "@opencode-ai/plugin/v2/effect/plugin" import type { Context, Plugin } from "@opencode-ai/plugin/v2/plugin" -import type { AnyTool } from "@opencode-ai/plugin/v2/tool" +import type { Any, RegisterOptions } from "@opencode-ai/plugin/v2/tool" import { Agent } from "@opencode-ai/schema/agent" import { Integration } from "@opencode-ai/schema/integration" import { Location } from "@opencode-ai/schema/location" @@ -189,7 +189,8 @@ export function fromPromise(plugin: Plugin) { register( host.tool.transform((draft) => callback({ - add: (tool: AnyTool) => draft.add(tool.name, fromPromiseTool(tool), tool.options), + add: (name: string, tool: Any, options?: RegisterOptions) => + draft.add(name, fromPromiseTool(tool), options), }), ), ), @@ -302,19 +303,8 @@ function wireEvent(value: unknown): unknown { return wire(value) } -function fromPromiseTool(tool: AnyTool) { - if ("jsonSchema" in tool) - return Tool.make({ - ...tool, - execute: (input, context) => - Effect.promise(() => - tool.execute(input, { - ...context, - progress: (update) => Effect.runPromise(context.progress(update)), - }), - ), - }) - return Tool.make({ +function fromPromiseTool(tool: Any): Tool.Any { + return { ...tool, execute: (input, context) => Effect.promise(() => @@ -323,5 +313,5 @@ function fromPromiseTool(tool: AnyTool) { progress: (update) => Effect.runPromise(context.progress(update)), }), ), - }) + } } diff --git a/packages/core/src/session/generate-node.ts b/packages/core/src/session/generate-node.ts index 96804fc57f..0158760c3b 100644 --- a/packages/core/src/session/generate-node.ts +++ b/packages/core/src/session/generate-node.ts @@ -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]) => { diff --git a/packages/core/src/session/message-updater.ts b/packages/core/src/session/message-updater.ts index ecc6bf6257..3a256e043a 100644 --- a/packages/core/src/session/message-updater.ts +++ b/packages/core/src/session/message-updater.ts @@ -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 }), }), ) } diff --git a/packages/core/src/session/model-request.ts b/packages/core/src/session/model-request.ts index 6afe3318ac..f7fcb95adb 100644 --- a/packages/core/src/session/model-request.ts +++ b/packages/core/src/session/model-request.ts @@ -14,13 +14,15 @@ import { MAX_STEPS_PROMPT } from "./runner/max-steps" import PROMPT_DEFAULT from "./runner/prompt/base.txt" import { toLLMMessages } from "./runner/to-llm-message" -type ToolCallResolution = - | { readonly type: "reject"; readonly error: SessionError.Error } - | { readonly type: "settle"; readonly settle: ToolRegistry.Materialization["settle"] } - interface Prepared { readonly request: LLMRequest - readonly resolveToolCall: (name: string) => ToolCallResolution + /** + * One request-scoped execution operation. Unknown, hook-removed, and + * step-limit-violating calls fail individually through the same seam. + */ + readonly executeTool: ToolRegistry.ToolSet["execute"] + /** True when this request is the final Step; violating calls are rejected and no continuation follows. */ + readonly stepLimitReached: boolean } interface PrepareInput { @@ -94,14 +96,16 @@ export const layer = Layer.effect( const model = resolved.model const providerMetadataKey = model.route.providerMetadataKey ?? model.provider const stepLimitReached = agent.info.steps !== undefined && input.step >= agent.info.steps - const executableTools = stepLimitReached ? undefined : yield* registry.materialize(agent.info.permissions) + // The final Step keeps definitions available to protocols with native "none", + // preserving their prompt cache prefix. Calls are still rejected at execution. + const toolSet = yield* registry.snapshot(agent.info.permissions) const promptCacheKey = /^ses_[0-9a-f]{64}$/.test(session.id) ? session.id.slice(4) : session.id const system = [agent.info.system ? agent.info.system : PROMPT_DEFAULT, input.context.initial] .filter((part) => part.length > 0) .map(SystemPart.make) const history = toLLMMessages(input.context.messages, resolved.ref, providerMetadataKey) const messages = stepLimitReached ? [...history, Message.assistant(MAX_STEPS_PROMPT)] : history - const toolDefinitions = executableTools?.definitions ?? [] + const toolDefinitions = toolSet.definitions const toolsByName = new Map(toolDefinitions.map((tool) => [tool.name, tool])) // Hooks may reshape available definitions but cannot advertise tools omitted by permissions or the Step limit. const contextEvent = yield* hooks.trigger("session", "context", { @@ -131,22 +135,23 @@ export const layer = Layer.effect( tools: hookedTools, toolChoice: stepLimitReached ? "none" : undefined, }) - const resolveToolCall = (name: string): ToolCallResolution => { - if (!executableTools) - return { - type: "reject", + const executeTool: ToolRegistry.ToolSet["execute"] = (executeInput) => { + if (stepLimitReached) + return Effect.succeed({ + status: "error", error: { type: "tool.execution", message: "Tools are disabled after the maximum agent steps" }, - } - if (toolsByName.has(name) && !Object.hasOwn(contextEvent.tools, name)) - return { - type: "reject", - error: { type: "tool.execution", message: `Tool is not available for this request: ${name}` }, - } - return { type: "settle", settle: executableTools.settle } + }) + if (toolsByName.has(executeInput.call.name) && !Object.hasOwn(contextEvent.tools, executeInput.call.name)) + return Effect.succeed({ + status: "error", + error: { type: "tool.unknown", message: `Tool is not available for this request: ${executeInput.call.name}` }, + }) + return toolSet.execute(executeInput) } return { request, - resolveToolCall, + executeTool, + stepLimitReached, } }) diff --git a/packages/core/src/session/runner/llm.ts b/packages/core/src/session/runner/llm.ts index ad792ce1c7..3d92a5c813 100644 --- a/packages/core/src/session/runner/llm.ts +++ b/packages/core/src/session/runner/llm.ts @@ -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)), ) diff --git a/packages/core/src/session/runner/publish-llm-event.ts b/packages/core/src/session/runner/publish-llm-event.ts index df3e950895..f84253c39d 100644 --- a/packages/core/src/session/runner/publish-llm-event.ts +++ b/packages/core/src/session/runner/publish-llm-event.ts @@ -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 => typeof value === "object" && value !== null && !Array.isArray(value) ? (value as Record) : { 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; 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() 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 ${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 ${name}`)) + if (tool.settled) { + if (execution.status === "error") return + return yield* Effect.die(new Error(`Duplicate tool execution: ${callID}`)) + } + tool.settled = true + if (execution.status === "completed") { + yield* events.publish(SessionEvent.Tool.Success, { + sessionID: input.sessionID, + assistantMessageID: tool.assistantMessageID, + callID, + content: execution.content, + ...(execution.metadata === undefined ? {} : { metadata: execution.metadata }), + executed: tool.providerExecuted, + }) + return + } + // An execution-provided snapshot wins; otherwise fall back to retained progress. + const snapshot = + execution.content !== undefined || execution.metadata !== undefined + ? { + ...(execution.content === undefined ? {} : { content: execution.content }), + ...(execution.metadata === undefined ? {} : { metadata: execution.metadata }), + } + : failureSnapshot(tool) + yield* events.publish(SessionEvent.Tool.Failed, { + sessionID: input.sessionID, + assistantMessageID: tool.assistantMessageID, + callID, + error: execution.error, + ...snapshot, + executed: tool.providerExecuted, }) }) return { publish, progress, + toolExecution, flush, failAssistant, publishStepFailure, diff --git a/packages/core/src/session/runner/to-llm-message.ts b/packages/core/src/session/runner/to-llm-message.ts index 1c875387d0..bf115c4ff1 100644 --- a/packages/core/src/session/runner/to-llm-message.ts +++ b/packages/core/src/session/runner/to-llm-message.ts @@ -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] }) diff --git a/packages/core/src/session/to-session-error.ts b/packages/core/src/session/to-session-error.ts index e3ad94fd10..fe21051d8a 100644 --- a/packages/core/src/session/to-session-error.ts +++ b/packages/core/src/session/to-session-error.ts @@ -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 } diff --git a/packages/core/src/tool-output-store.ts b/packages/core/src/tool-output-store.ts index f7eccefea0..d59a342159 100644 --- a/packages/core/src/tool-output-store.ts +++ b/packages/core/src/tool-output-store.ts @@ -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 } export interface BoundResult { - readonly output: ToolOutput + readonly content: ReadonlyArray readonly outputPaths: ReadonlyArray } @@ -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], } }) diff --git a/packages/core/src/tool/AGENTS.md b/packages/core/src/tool/AGENTS.md index beb838a567..7c1e3f1506 100644 --- a/packages/core/src/tool/AGENTS.md +++ b/packages/core/src/tool/AGENTS.md @@ -1,26 +1,26 @@ # Core Tool Architecture -This folder owns Core's one local tool representation, process and Location registration, effective lookup, and settlement. +This folder owns Core's local tools, Location-scoped registrations, effective lookup, execution, and terminal outcomes. ## Representations -- `tool.ts` defines the structural canonical `Tool.make({ description, input, output, execute, toModelOutput })` declaration. Shipped built-ins and plugin tools use the same type. +- `tool.ts` defines the structural canonical `Tool.make({ description, input, output?, execute })` tool. Executors return model content and metadata alongside declared machine output. Shipped built-ins and plugin tools use the same type. - `tools.ts` exposes the registration-only `Tools.Service` view used by Location producers. -- `registry.ts` stores only canonical Location registrations, derives definitions, invokes tools, and applies generic output bounding. +- `registry.ts` stores only canonical Location registrations, derives LLM definitions, executes tools, and applies generic output bounding. Do not add a second executable entry type, registry-owned executor, authorization callback, output-path callback, or legacy normalization path. ## Construction -Tool schemas and projection use `input` and `output` terminology. A tool value carries its schemas, executor, projection, and optional catalog permission directly so separately loaded plugin package instances can exchange it structurally. +Tool schemas use `input` and `output` terminology. A tool carries schemas and executable behavior without public identity. A registration binds its name, namespace, CodeMode placement, and optional catalog permission action. Location-scoped built-in layers acquire `PermissionV2.Service` and every other required Location service while the layer is constructed. The executor captures those services. Permission sources are always constructed from the canonical invocation context: ```ts const source = { type: "tool" as const, - messageID: context.assistantMessageID, - callID: context.toolCallID, + messageID: context.messageID, + callID: context.callID, } ``` @@ -42,13 +42,13 @@ Registrations are scoped: ## Permissions -The registry has no `PermissionV2.Service` dependency and performs no execution authorization. An internal built-in-only operation attaches a permission action solely to preserve whole-tool definition filtering; it is not part of public `Tool.make`. Most tools default to their registered name; `edit`, `write`, and `patch` declare the shared `edit` action. +The registry has no `PermissionV2.Service` dependency and performs no execution authorization. Registration options may attach a permission action solely to preserve whole-tool definition filtering. Most registrations default to their effective name; `edit`, `write`, and `patch` use the shared `edit` action. -Definition filtering is catalog visibility, not execution authorization. A call still executes the captured leaf policy if it reaches settlement. +Tool filtering is catalog visibility, not execution authorization. A call still executes the captured tool's leaf policy if it reaches execution. ## Output -Built-ins return complete validated domain output. `ToolRegistry.Materialization.settle` is the only execution and generic model-output bounding boundary and owns managed retention paths. +Built-ins return complete tool responses. `ToolRegistry.ToolSet.execute` is the only local execution and generic model-output bounding boundary and owns managed retention paths. Producer capture limits are separate. For example, Bash keeps `AppProcess.maxOutputBytes` and accurately reports stdout/stderr capture loss, but it does not run model-output truncation or return a managed `outputPath`. diff --git a/packages/core/src/tool/edit.ts b/packages/core/src/tool/edit.ts index 0456195dd9..00b49ca0d0 100644 --- a/packages/core/src/tool/edit.ts +++ b/packages/core/src/tool/edit.ts @@ -97,15 +97,11 @@ export const Plugin = { .transform((draft) => draft.add( name, - Tool.withPermission( - Tool.make({ + Tool.make({ description: "Replace exact text in one file. Relative paths resolve within the active Location. Absolute paths inside the Location are accepted. Explicit external absolute paths require external_directory approval before edit approval.", input: Input, output: Output, - toModelOutput: ({ input, output }) => [ - { type: "text", text: toModelOutput(output, input.oldString, input.newString) }, - ], execute: (input, context) => { const unableToEdit = (effect: Effect.Effect) => effect.pipe( @@ -207,12 +203,16 @@ export const Plugin = { ], replacements, } satisfies Output - }) + }).pipe( + Effect.map((output) => ({ + output, + content: toModelOutput(output, input.oldString, input.newString), + metadata: { files: output.files }, + })), + ) }, }), - "edit", - ), - { codemode: false }, + { codemode: false, permission: "edit" }, ), ) .pipe(Effect.orDie) diff --git a/packages/core/src/tool/execute.ts b/packages/core/src/tool/execute.ts index b9ffa751e3..30ca66712c 100644 --- a/packages/core/src/tool/execute.ts +++ b/packages/core/src/tool/execute.ts @@ -1,9 +1,10 @@ export * as ExecuteTool from "./execute" +export type { Registration } from "./tool" import { CodeMode, Tool, toolError } from "@opencode-ai/codemode" -import { ToolOutput } from "@opencode-ai/ai" +import type { ToolContent } from "@opencode-ai/ai" import { Effect, Ref, Schema } from "effect" -import { definition, make, settle, type AnyTool } from "./tool" +import { execute, make, toLLMDefinition, type Content, type Metadata, type Registration } from "./tool" const ExecuteFile = Schema.Struct({ data: Schema.String, @@ -14,16 +15,11 @@ const ExecuteFile = Schema.Struct({ const ExecuteCall = Schema.Struct({ tool: Schema.String, status: Schema.Literals(["running", "completed", "error"]), - input: Schema.optionalKey(Schema.Record(Schema.String, Schema.Unknown)), + input: Schema.optionalKey(Schema.Record(Schema.String, Schema.Json)), }) type ExecuteCall = typeof ExecuteCall.Type -const ExecuteMetadata = Schema.Struct({ - toolCalls: Schema.Array(ExecuteCall), - error: Schema.optionalKey(Schema.Literal(true)), -}) - const ExecuteOutput = Schema.Struct({ output: Schema.String, toolCalls: Schema.Array(ExecuteCall), @@ -36,12 +32,6 @@ type CollectedFiles = { readonly files: Array } -export interface Registration { - readonly tool: AnyTool - readonly name: string - readonly namespace?: string -} - // Invariant model-facing guidance; the changing tool catalog is delivered through Instructions. const description = [ "Run JavaScript in a confined Code Mode runtime through { code }.", @@ -55,20 +45,6 @@ export const create = (registrations: ReadonlyMap) => { description, input: CodeMode.Input, output: ExecuteOutput, - structured: ExecuteMetadata, - toStructuredOutput: ({ output }) => ({ - toolCalls: output.toolCalls, - ...(output.error ? { error: true as const } : {}), - }), - toModelOutput: ({ output }) => [ - { type: "text" as const, text: output.output }, - ...output.files.map((file) => ({ - type: "file" as const, - data: file.data, - mime: file.mime, - ...(file.name === undefined ? {} : { name: file.name }), - })), - ], execute: ({ code }, context) => Effect.gen(function* () { const callIndex = yield* Ref.make(0) @@ -85,21 +61,17 @@ export const create = (registrations: ReadonlyMap) => { (name, registration, input) => Effect.gen(function* () { const index = yield* Ref.getAndUpdate(callIndex, (index) => index + 1) - const output = yield* settle( - registration.tool, - { type: "tool-call", id: context.callID, name, input }, - { - sessionID: context.sessionID, - agent: context.agent, - messageID: context.messageID, - callID: context.callID, - progress: context.progress, - }, - ).pipe(Effect.mapError((failure) => toolError(failure.message, failure))) - const outputFileParts = outputFiles(output) + const executed = yield* execute(registration.tool, input, { + sessionID: context.sessionID, + agent: context.agent, + messageID: context.messageID, + callID: context.callID, + progress: context.progress, + }).pipe(Effect.mapError((failure) => toolError(failure.message, failure))) + const outputFileParts = outputFiles(executed.content) if (outputFileParts.length > 0) yield* Ref.update(files, (items) => [...items, { index, files: outputFileParts }]) - return output.structured + return executed.output }), { onToolCallStart: ({ index, name, input }) => @@ -126,7 +98,30 @@ export const create = (registrations: ReadonlyMap) => { .toSorted((left, right) => left.index - right.index) .flatMap((item) => item.files) const output = formatResult(result) - return { output, toolCalls, files: collected, ...(result.ok ? {} : { error: true as const }) } + const value: typeof ExecuteOutput.Type = { + output, + toolCalls, + files: collected, + ...(result.ok ? {} : { error: true }), + } + const content: [Content, ...Content[]] = [{ type: "text", text: value.output }] + content.push( + ...value.files.map((file) => ({ + type: "file" as const, + data: file.data, + mime: file.mime, + ...(file.name === undefined ? {} : { name: file.name }), + })), + ) + const metadata: Metadata = { + toolCalls: value.toolCalls, + ...(value.error ? { error: true } : {}), + } + return { + output: value, + content, + metadata, + } }), }) } @@ -137,28 +132,30 @@ export const instructions = (registrations: ReadonlyMap) = function runtime( registrations: ReadonlyMap, - invoke: (name: string, registration: Registration, input: unknown) => Effect.Effect, + executeTool: (name: string, registration: Registration, input: unknown) => Effect.Effect, hooks?: CodeMode.ToolCallHooks, ) { - const tools: Record> = {} + const tools: Record> = {} for (const [name, registration] of registrations) { - const child = definition(name, registration.tool) - const path = registration.namespace === undefined ? registration.name : `${registration.namespace}.${registration.name}` + const child = toLLMDefinition(name, registration.tool) + const path = + registration.namespace === undefined ? registration.name : `${registration.namespace}.${registration.name}` tools[path] = Tool.make({ description: child.description, input: child.inputSchema, output: child.outputSchema, - run: (input) => invoke(name, registration, input), + execute: (input) => executeTool(name, registration, input), }) } return CodeMode.make({ tools, ...hooks }) } -function displayInput(input: unknown): Record | undefined { +// Tool inputs arrive as parsed JSON, so the JSON value cast is a boundary fact. +function displayInput(input: unknown): Record | 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 + return input as Record } function formatResult(result: CodeMode.Result) { @@ -180,8 +177,8 @@ function formatValue(value: CodeMode.DataValue) { return JSON.stringify(value, null, 2) ?? String(value) } -function outputFiles(output: ToolOutput): Array { - return output.content.flatMap((part) => { +function outputFiles(content: ReadonlyArray): Array { + return content.flatMap((part) => { if (part.type !== "file") return [] const prefix = `data:${part.mime};base64,` if (!part.uri.startsWith(prefix)) return [] diff --git a/packages/core/src/tool/glob.ts b/packages/core/src/tool/glob.ts index 27a7d26a01..50ef685394 100644 --- a/packages/core/src/tool/glob.ts +++ b/packages/core/src/tool/glob.ts @@ -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 diff --git a/packages/core/src/tool/grep.ts b/packages/core/src/tool/grep.ts index 56cb004ddf..fa7326302d 100644 --- a/packages/core/src/tool/grep.ts +++ b/packages/core/src/tool/grep.ts @@ -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 diff --git a/packages/core/src/tool/hooks.ts b/packages/core/src/tool/hooks.ts index 5a0c93c314..2e6cf32942 100644 --- a/packages/core/src/tool/hooks.ts +++ b/packages/core/src/tool/hooks.ts @@ -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 -} +/** The canonical execution outcome. Hooks never observe the raw domain output. */ +export type AfterEvent = Tool.ToolExecuteAfterEvent export interface Interface { readonly hook: { diff --git a/packages/core/src/tool/mcp.ts b/packages/core/src/tool/mcp.ts index de9910a819..24b10f79da 100644 --- a/packages/core/src/tool/mcp.ts +++ b/packages/core/src/tool/mcp.ts @@ -32,86 +32,90 @@ export const layer = Layer.effectDiscard( // registry never has a gap where MCP tools disappear mid-swap. const reconcile = lock.withPermit( Effect.gen(function* () { - const groups = new Map; codemode: boolean }>() + const groups = new Map< + string, + { + tools: Record + codemode: boolean + } + >() for (const tool of yield* mcp.tools()) { const group = groups.get(tool.server) ?? { tools: {}, codemode: tool.codemode !== false } const schema = (tool.inputSchema ?? {}) as JsonSchema.JsonSchema - group.tools[tool.name] = Tool.withPermission( - Tool.make({ - description: tool.description ?? "", - jsonSchema: { - ...schema, - type: "object", - properties: schema.properties ?? {}, - additionalProperties: false, - }, - outputSchema: tool.outputSchema as JsonSchema.JsonSchema | undefined, - execute: (input, context) => - Effect.gen(function* () { - yield* permission.assert({ - action: name(tool.server, tool.name), - resources: ["*"], - save: ["*"], - metadata: {}, - sessionID: context.sessionID, - agent: context.agent, - source: { - type: "tool", - messageID: context.messageID, - callID: context.callID, - }, + group.tools[tool.name] = Tool.make({ + description: tool.description ?? "", + input: { + ...schema, + type: "object", + properties: schema.properties ?? {}, + additionalProperties: false, + }, + output: (tool.outputSchema ?? {}) as JsonSchema.JsonSchema, + execute: (input, context) => + Effect.gen(function* () { + yield* permission.assert({ + action: name(tool.server, tool.name), + resources: ["*"], + save: ["*"], + metadata: {}, + sessionID: context.sessionID, + agent: context.agent, + source: { + type: "tool", + messageID: context.messageID, + callID: context.callID, + }, + }) + const result = yield* mcp + .callTool({ + server: tool.server, + name: tool.name, + args: (input ?? {}) as Record, }) - const result = yield* mcp - .callTool({ - server: tool.server, - name: tool.name, - args: (input ?? {}) as Record, - }) - .pipe( - Effect.catchTags({ - "MCP.NotFoundError": (error) => - new ToolFailure({ message: `MCP server "${error.server}" is not available` }), - "MCP.ToolCallError": (error) => new ToolFailure({ message: error.message }), - }), - ) - if (result.isError) - return yield* new ToolFailure({ - message: - result.content - .flatMap((part) => (part.type === "text" ? [part.text] : [])) - .join("\n") - .trim() || "MCP tool returned an error", - }) - const content = result.content.map((part) => - part.type === "text" - ? { type: "text" as const, text: part.text } - : { type: "file" as const, data: part.data, mime: part.mimeType }, + .pipe( + Effect.catchTags({ + "MCP.NotFoundError": (error) => + new ToolFailure({ message: `MCP server "${error.server}" is not available` }), + "MCP.ToolCallError": (error) => new ToolFailure({ message: error.message }), + }), ) - const text = content.flatMap((part) => (part.type === "text" ? [part.text] : [])).join("\n") - return { - structured: result.structured ?? (text === "" ? null : text), - content, - } - }).pipe( - Effect.mapError((error) => - error instanceof ToolFailure - ? error - : new ToolFailure({ message: `Unable to execute ${name(tool.server, tool.name)}` }), - ), + if (result.isError) + return yield* new ToolFailure({ + message: + result.content + .flatMap((part) => (part.type === "text" ? [part.text] : [])) + .join("\n") + .trim() || "MCP tool returned an error", + }) + const content = result.content.map((part) => + part.type === "text" + ? { type: "text" as const, text: part.text } + : { type: "file" as const, data: part.data, mime: part.mimeType }, + ) + const text = content.flatMap((part) => (part.type === "text" ? [part.text] : [])).join("\n") + return { + output: result.structured ?? (text === "" ? null : text), + ...(content.length === 0 ? {} : { content: content as [Tool.Content, ...Tool.Content[]] }), + } + }).pipe( + Effect.mapError((error) => + error instanceof ToolFailure + ? error + : new ToolFailure({ message: `Unable to execute ${name(tool.server, tool.name)}` }), ), - }), - name(tool.server, tool.name), - ) + ), + }) groups.set(tool.server, group) } const next = yield* Scope.fork(scope) - yield* Effect.forEach( - groups, - ([server, group]) => tools.register(group.tools, { namespace: namespace(server), codemode: group.codemode }), - { - discard: true, - }, - ).pipe(Scope.provide(next), Effect.orDie) + yield* tools + .registerBatch( + Array.from(groups, ([server, group]) => ({ + tools: group.tools, + options: { namespace: namespace(server), codemode: group.codemode }, + })), + ) + .pipe(Scope.provide(next), Effect.orDie) if (current) yield* Scope.close(current, Exit.void) current = next }), diff --git a/packages/core/src/tool/patch.ts b/packages/core/src/tool/patch.ts index 7a70b3c5c3..5e90c35596 100644 --- a/packages/core/src/tool/patch.ts +++ b/packages/core/src/tool/patch.ts @@ -75,12 +75,10 @@ export const Plugin = { .transform((draft) => draft.add( name, - Tool.withPermission( - Tool.make({ + Tool.make({ description: DESCRIPTION, input: Input, output: Output, - toModelOutput: ({ output }) => [{ type: "text", text: toModelOutput(output) }], execute: (input, context) => { const applied: Array = [] const fail = (path: string, error?: unknown) => { @@ -278,12 +276,17 @@ export const Plugin = { { discard: true }, ) return { applied, files: patchFiles } - }).pipe(Effect.mapError((error) => (error instanceof ToolFailure ? error : fail("patch", error)))) + }).pipe( + Effect.map((output) => ({ + output, + content: toModelOutput(output), + metadata: { files: output.files }, + })), + Effect.mapError((error) => (error instanceof ToolFailure ? error : fail("patch", error))), + ) }, }), - "edit", - ), - { codemode: false }, + { codemode: false, permission: "edit" }, ), ) .pipe(Effect.orDie) diff --git a/packages/core/src/tool/question.ts b/packages/core/src/tool/question.ts index aa343c9652..cdb80a0000 100644 --- a/packages/core/src/tool/question.ts +++ b/packages/core/src/tool/question.ts @@ -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 }, }) }), ), diff --git a/packages/core/src/tool/read.ts b/packages/core/src/tool/read.ts index 4021516cc4..5c0b9254be 100644 --- a/packages/core/src/tool/read.ts +++ b/packages/core/src/tool/read.ts @@ -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 || diff --git a/packages/core/src/tool/registry.ts b/packages/core/src/tool/registry.ts index 1dd10038df..9316f6f8f6 100644 --- a/packages/core/src/tool/registry.ts +++ b/packages/core/src/tool/registry.ts @@ -1,7 +1,7 @@ export * as ToolRegistry from "./registry" -import { ToolOutput, type ToolCall, type ToolDefinition, type ToolResultValue } from "@opencode-ai/ai" -import { Context, Effect, Layer, Scope, Semaphore } from "effect" +import { type ToolCall, type ToolContent, type ToolDefinition } from "@opencode-ai/ai" +import { Context, Effect, Layer, Schema, Scope, Semaphore } from "effect" import type { AgentV2 } from "../agent" import { Image } from "../image" import { PermissionV2 } from "../permission" @@ -10,19 +10,10 @@ import { SessionSchema } from "../session/schema" import { ToolOutputStore } from "../tool-output-store" import { Wildcard } from "../util/wildcard" import { CodeMode } from "../codemode" -import { - definition, - permission, - registrationEntries, - RegistrationError, - settle, - validateNamespace, - type AnyTool, -} from "./tool" +import { Tool, nonEmpty, registrationEntries, toLLMDefinition, validateName, validateNamespace } from "./tool" import { Tools } from "./tools" import { ToolHooks } from "./hooks" import { makeLocationNode } from "@opencode-ai/util/effect/app-node" -import { SessionError } from "@opencode-ai/schema/session-error" import { toSessionError } from "../session/to-session-error" export type ExecuteInput = { @@ -33,38 +24,42 @@ export type ExecuteInput = { readonly progress?: (update: Progress) => Effect.Effect } -export interface Progress { - readonly structured: Readonly> - 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 + readonly snapshot: (permissions?: PermissionV2.Ruleset) => Effect.Effect /** Internal registration capability exposed publicly only through Tools.Service. */ readonly register: ( - tools: Readonly>, + tools: Readonly>, options?: Tools.RegisterOptions, - ) => Effect.Effect + ) => Effect.Effect /** Internal atomic registration capability used by plugin transforms. */ readonly registerBatch: ( registrations: ReadonlyArray<{ - readonly tools: Readonly> + readonly tools: Readonly> readonly options?: Tools.RegisterOptions }>, - ) => Effect.Effect + ) => Effect.Effect } -export interface Materialization { +/** + * One request-scoped snapshot pairing advertised definitions with captured + * tools. A model request executes exactly the tool values it advertised + * even if registration changes while the request is in flight. + */ +export interface ToolSet { readonly definitions: ReadonlyArray - readonly settle: (input: ExecuteInput) => Effect.Effect + readonly execute: (input: ExecuteInput) => Effect.Effect } -export interface Settlement { - readonly result: ToolResultValue - readonly output?: ToolOutput - readonly outputPaths?: ReadonlyArray - readonly error?: SessionError.Error -} +/** + * The canonical outcome of one local tool execution. `output` is the validated + * machine value for Code Mode and remains ephemeral; durable publication drops it. + */ +export type ToolOutcome = + | (Extract & { readonly output?: unknown }) + | Extract export class Service extends Context.Service()("@opencode/v2/ToolRegistry") {} @@ -76,26 +71,24 @@ const registryLayer = Layer.effect( const image = yield* Image.Service const codeMode = yield* CodeMode.Service - type NormalizedItem = ToolOutput["content"][number] | "decode" | "size" - const normalizeImages = Effect.fn("ToolRegistry.normalizeImages")(function* (content: ToolOutput["content"]) { + type NormalizedItem = ToolContent | "decode" | "size" + const normalizeImages = Effect.fn("ToolRegistry.normalizeImages")(function* (content: ReadonlyArray) { const normalized = yield* Effect.forEach(content, (item): Effect.Effect => { if (item.type !== "file" || !item.mime.startsWith("image/")) return Effect.succeed(item) // RFC 2397 permits parameters between the mime and ";base64". const base64 = /^data:[^,]*;base64,(.*)$/s.exec(item.uri)?.[1] if (base64 === undefined) return Effect.succeed(item) const resource = item.name ?? `${item.mime} tool output` - return image - .normalize(resource, { uri: resource, content: base64, encoding: "base64", mime: item.mime }) - .pipe( - Effect.map((result) => ({ - ...item, - uri: `data:${result.mime};base64,${result.content}`, - mime: result.mime, - })), - Effect.catchTag("Image.ResizerUnavailableError", () => Effect.succeed(item)), - Effect.catchTag("Image.DecodeError", () => Effect.succeed("decode" as const)), - Effect.catchTag("Image.SizeError", () => Effect.succeed("size" as const)), - ) + return image.normalize(resource, { uri: resource, content: base64, encoding: "base64", mime: item.mime }).pipe( + Effect.map((result) => ({ + ...item, + uri: `data:${result.mime};base64,${result.content}`, + mime: result.mime, + })), + Effect.catchTag("Image.ResizerUnavailableError", () => Effect.succeed(item)), + Effect.catchTag("Image.DecodeError", () => Effect.succeed("decode" as const)), + Effect.catchTag("Image.SizeError", () => Effect.succeed("size" as const)), + ) }) const note = (reason: "decode" | "size", text: string) => { const count = normalized.filter((item) => item === reason).length @@ -108,16 +101,24 @@ const registryLayer = Layer.effect( ...note("size", "could not be resized below the image size limit."), ] }) - type Registration = { - readonly tool: AnyTool - readonly name: string - readonly namespace?: string - } + + // Invalid or oversized metadata is dropped with a warning; it never fails a + // successful side-effecting tool. + const validMetadata = Effect.fnUntraced(function* (tool: string, metadata: Tool.Metadata | undefined) { + if (metadata === undefined) return undefined + const limits = yield* resources.limits() + const valid = Tool.jsonMetadata(metadata, limits.maxBytes) + if (valid === undefined) + yield* Effect.logWarning("dropping invalid or oversized tool metadata").pipe(Effect.annotateLogs({ tool })) + return valid + }) + + type Registration = Tool.Registration const local = new Map>() const registrationLock = Semaphore.makeUnsafe(1) - const settleTool = Effect.fn("ToolRegistry.settleTool")(function* (input: ExecuteInput, tool: AnyTool) { - // Hooks fire only for hosted/local tools; provider-executed calls never reach settleTool. + const executeTool = Effect.fn("ToolRegistry.executeTool")(function* (input: ExecuteInput, tool: Tool.Any) { + // Hooks fire only for hosted/local tools; provider-executed calls never reach executeTool. const beforeEvent: ToolHooks.BeforeEvent = { tool: input.call.name, sessionID: input.sessionID, @@ -127,76 +128,100 @@ const registryLayer = Layer.effect( input: input.call.input, } yield* toolHooks.runBefore(beforeEvent) - const pending = yield* settle( - tool, - { ...input.call, input: beforeEvent.input }, - { - sessionID: input.sessionID, - agent: input.agent, - messageID: input.messageID, - callID: input.call.id, - progress: (update) => { - const progress = input.progress - if (!progress) return Effect.void - return normalizeImages( - (update.content ?? []).map((part) => - part.type === "text" - ? { type: "text" as const, text: part.text } - : { - type: "file" as const, - uri: `data:${part.mime};base64,${part.data}`, - mime: part.mime, - name: part.name, - }, - ), - ).pipe(Effect.flatMap((content) => progress({ structured: update.structured, content }))) - }, + const execution = yield* Tool.execute(tool, beforeEvent.input, { + sessionID: input.sessionID, + agent: input.agent, + messageID: input.messageID, + callID: input.call.id, + progress: (metadata) => { + const progress = input.progress + if (!progress) return Effect.void + return validMetadata(input.call.name, metadata).pipe( + Effect.flatMap((valid) => (valid === undefined ? Effect.void : progress(valid))), + ) }, - ).pipe( - Effect.map((output) => ({ output })), - Effect.catchTag("LLM.ToolFailure", (failure) => - Effect.succeed({ - result: { type: "error" as const, value: failure.message }, - error: toSessionError(failure), - }), - ), + }).pipe( + Effect.map((value) => ({ value })), + Effect.catchTag("LLM.ToolFailure", (failure) => Effect.succeed({ failure: toSessionError(failure) })), ) - let settlement: Settlement - if ("result" in pending) { - settlement = pending - } else { + + const outcome: ToolOutcome = yield* Effect.gen(function* () { + if ("failure" in execution) return { status: "error" as const, error: execution.failure } const bounded = yield* resources.bound({ sessionID: input.sessionID, callID: input.call.id, - output: { structured: pending.output.structured, content: yield* normalizeImages(pending.output.content) }, + content: yield* normalizeImages(execution.value.content), }) - const result = ToolOutput.toResultValue(bounded.output) - settlement = - result.type === "error" - ? bounded.outputPaths.length > 0 - ? { result, outputPaths: bounded.outputPaths } - : { result } - : bounded.outputPaths.length > 0 - ? { result, output: bounded.output, outputPaths: bounded.outputPaths } - : { result, output: bounded.output } - } - const afterEvent: ToolHooks.AfterEvent = { + const metadata = yield* validMetadata(input.call.name, execution.value.metadata) + return { + status: "completed" as const, + ...(execution.value.output === undefined ? {} : { output: execution.value.output }), + content: nonEmpty(bounded.content) ?? execution.value.content, + ...(metadata === undefined ? {} : { metadata }), + ...(bounded.outputPaths.length > 0 ? { outputPaths: bounded.outputPaths } : {}), + } + }) + + const base = { tool: input.call.name, sessionID: input.sessionID, agent: input.agent, messageID: input.messageID, callID: input.call.id, input: beforeEvent.input, - result: settlement.result, - output: settlement.output, - outputPaths: settlement.outputPaths, } + const afterEvent: ToolHooks.AfterEvent = + outcome.status === "completed" + ? { + ...base, + status: "completed", + content: outcome.content, + ...(outcome.metadata === undefined ? {} : { metadata: outcome.metadata }), + ...(outcome.outputPaths === undefined ? {} : { outputPaths: outcome.outputPaths }), + } + : { + ...base, + status: "error", + error: outcome.error, + ...(outcome.content === undefined ? {} : { content: outcome.content }), + ...(outcome.metadata === undefined ? {} : { metadata: outcome.metadata }), + ...(outcome.outputPaths === undefined ? {} : { outputPaths: outcome.outputPaths }), + } yield* toolHooks.runAfter(afterEvent) + const afterMetadata = yield* validMetadata(input.call.name, afterEvent.metadata) + const afterContent = yield* Effect.gen(function* () { + if ( + afterEvent.content === undefined || + (outcome.status === "completed" && afterEvent.content === outcome.content) + ) + return { content: afterEvent.content, outputPaths: afterEvent.outputPaths } + const bounded = yield* resources.bound({ + sessionID: input.sessionID, + callID: input.call.id, + content: yield* normalizeImages(afterEvent.content), + }) + return { + content: nonEmpty(bounded.content), + outputPaths: + bounded.outputPaths.length === 0 + ? afterEvent.outputPaths + : Array.from(new Set([...(afterEvent.outputPaths ?? []), ...bounded.outputPaths])), + } + }) + if (afterEvent.status === "completed") + return { + status: "completed" as const, + ...(outcome.status === "completed" && outcome.output !== undefined ? { output: outcome.output } : {}), + content: afterContent.content ?? afterEvent.content, + ...(afterMetadata === undefined ? {} : { metadata: afterMetadata }), + ...(afterContent.outputPaths === undefined ? {} : { outputPaths: afterContent.outputPaths }), + } return { - result: afterEvent.result, - ...(afterEvent.output !== undefined ? { output: afterEvent.output } : {}), - ...(afterEvent.outputPaths !== undefined ? { outputPaths: afterEvent.outputPaths } : {}), - ...(settlement.error !== undefined ? { error: settlement.error } : {}), + status: "error" as const, + error: afterEvent.error, + ...(afterContent.content === undefined ? {} : { content: afterContent.content }), + ...(afterMetadata === undefined ? {} : { metadata: afterMetadata }), + ...(afterContent.outputPaths === undefined ? {} : { outputPaths: afterContent.outputPaths }), } }) @@ -205,12 +230,26 @@ const registryLayer = Layer.effect( const planned = yield* Effect.forEach(registrations, ({ tools, options }) => Effect.gen(function* () { if (options?.namespace !== undefined) yield* validateNamespace(options.namespace) - const entries = registrationEntries(tools, options?.namespace) + const entries = registrationEntries(tools, options) + yield* Effect.forEach(entries, (entry) => validateName(entry.name), { discard: true }) + const collision = entries.find( + (entry, index) => entries.findIndex((candidate) => candidate.key === entry.key) !== index, + ) + if (collision) + return yield* Effect.fail( + new Tool.RegistrationError({ + name: collision.key, + message: `Duplicate normalized tool name: ${collision.key}`, + }), + ) const codemode = options?.codemode ?? true const reserved = codemode ? undefined : entries.find((entry) => entry.key === "execute") if (reserved) return yield* Effect.fail( - new RegistrationError({ name: reserved.key, message: 'Tool name "execute" is reserved for CodeMode' }), + new Tool.RegistrationError({ + name: reserved.key, + message: 'Tool name "execute" is reserved for CodeMode', + }), ) return { tools, options, entries, codemode } }), @@ -218,7 +257,7 @@ const registryLayer = Layer.effect( // CodeMode registrations live in the CodeMode service; the registry keeps only direct tools. yield* Effect.forEach( planned.filter((plan) => plan.codemode && plan.entries.length > 0), - (plan) => codeMode.register(plan.tools, plan.options), + (plan) => codeMode.register(plan.entries), { discard: true }, ) const direct = planned.filter((plan) => !plan.codemode) @@ -237,6 +276,7 @@ const registryLayer = Layer.effect( tool: entry.tool, name: entry.name, namespace: entry.namespace, + permission: entry.permission, }, }, ]) @@ -269,7 +309,7 @@ const registryLayer = Layer.effect( ]), ), registerBatch, - materialize: Effect.fn("ToolRegistry.materialize")((permissions) => + snapshot: Effect.fn("ToolRegistry.snapshot")((permissions) => registrationLock.withPermit( Effect.gen(function* () { const direct = new Map() @@ -277,21 +317,21 @@ const registryLayer = Layer.effect( for (const [name, entries] of local) { const registration = entries.at(-1)?.registration if (!registration) continue - if (whollyDisabled(permission(registration.tool, name), rules)) continue + if (whollyDisabled(registration.permission, rules)) continue direct.set(name, registration) } - const execute = (yield* codeMode.materialize(permissions)).tool + const codemodeTool = (yield* codeMode.materialize(permissions)).tool return { definitions: [ - ...Array.from(direct, ([name, registration]) => definition(name, registration.tool)), - ...(execute ? [definition("execute", execute)] : []), + ...Array.from(direct, ([name, registration]) => toLLMDefinition(name, registration.tool)), + ...(codemodeTool ? [toLLMDefinition("execute", codemodeTool)] : []), ], - settle: (input: ExecuteInput) => { - if (input.call.name === "execute" && execute) return settleTool(input, execute) + execute: (input: ExecuteInput) => { + if (input.call.name === "execute" && codemodeTool) return executeTool(input, codemodeTool) const registration = direct.get(input.call.name) - if (registration) return settleTool(input, registration.tool) - return Effect.succeed({ - result: { type: "error", value: `Unknown tool: ${input.call.name}` }, + if (registration) return executeTool(input, registration.tool) + return Effect.succeed({ + status: "error", error: { type: "tool.unknown", message: `Unknown tool: ${input.call.name}` }, }) }, diff --git a/packages/core/src/tool/shell.ts b/packages/core/src/tool/shell.ts index 63980f911d..534855a78d 100644 --- a/packages/core/src/tool/shell.ts +++ b/packages/core/src/tool/shell.ts @@ -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() 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 }), ), diff --git a/packages/core/src/tool/skill.ts b/packages/core/src/tool/skill.ts index dcf0e8bf70..c397489204 100644 --- a/packages/core/src/tool/skill.ts +++ b/packages/core/src/tool/skill.ts @@ -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 }, ), diff --git a/packages/core/src/tool/subagent.ts b/packages/core/src/tool/subagent.ts index 1f2f2f6c8b..daef4e1cf3 100644 --- a/packages/core/src/tool/subagent.ts +++ b/packages/core/src/tool/subagent.ts @@ -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 }, ), diff --git a/packages/core/src/tool/tool.ts b/packages/core/src/tool/tool.ts index 7525c9c0dd..e9917f94dd 100644 --- a/packages/core/src/tool/tool.ts +++ b/packages/core/src/tool/tool.ts @@ -1,2 +1,90 @@ -export * as Tool from "@opencode-ai/plugin/v2/effect/tool" +export * as Tool from "./tool" export * from "@opencode-ai/plugin/v2/effect/tool" + +import type { ToolContent } from "@opencode-ai/ai" +import { + decodeInput, + encodeOutput, + type Any, + type Content, + type Context, + Failure, + type Metadata, +} from "@opencode-ai/plugin/v2/effect/tool" +import { Effect, Schema } from "effect" + +/** Non-empty canonical model content. */ +export type NonEmptyContent = readonly [ToolContent, ...ToolContent[]] + +/** + * The execution-local result of one tool call: the machine output for + * Code Mode, canonical model content, and optional UI metadata. The typed + * domain output never leaves this function. + */ +export type Execution = { + readonly output?: unknown + readonly content: NonEmptyContent + readonly metadata?: Metadata +} + +export const execute = (tool: Any, input: unknown, context: Context): Effect.Effect => + Effect.gen(function* () { + const decoded = yield* decodeInput(tool.input, input) + const result = yield* tool.execute(decoded, context) + if (tool.output === undefined) { + if ("output" in result) return yield* Effect.die("Tool result declared output without an output schema") + return { + content: contentFrom(result.content), + ...(result.metadata === undefined ? {} : { metadata: result.metadata }), + } + } + if (!("output" in result)) + return yield* Effect.fail(new Failure({ message: "Tool did not return its declared output" })) + const encoded = yield* encodeOutput(tool.output, result.output) + return { + output: encoded, + content: contentFrom(result.content, encoded), + ...(result.metadata === undefined ? {} : { metadata: result.metadata }), + } + }) + +/** Model content from the tool's projection, falling back to the stringified encoded output. */ +const contentFrom = (projected: string | ReadonlyArray | 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): 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 +} diff --git a/packages/core/src/tool/tools.ts b/packages/core/src/tool/tools.ts index cfc4dd95dc..31ecc10cdf 100644 --- a/packages/core/src/tool/tools.ts +++ b/packages/core/src/tool/tools.ts @@ -7,13 +7,13 @@ export type RegisterOptions = Tool.RegisterOptions export interface Interface { readonly register: ( - tools: Readonly>, + tools: Readonly>, options?: Tool.RegisterOptions, ) => Effect.Effect /** Internal atomic registration capability used by plugin transforms. */ readonly registerBatch: ( registrations: ReadonlyArray<{ - readonly tools: Readonly> + readonly tools: Readonly> readonly options?: Tool.RegisterOptions }>, ) => Effect.Effect diff --git a/packages/core/src/tool/webfetch.ts b/packages/core/src/tool/webfetch.ts index 0ce3250995..d3f894bdbc 100644 --- a/packages/core/src/tool/webfetch.ts +++ b/packages/core/src/tool/webfetch.ts @@ -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 }, diff --git a/packages/core/src/tool/websearch.ts b/packages/core/src/tool/websearch.ts index 49b059c9df..bc6f1bcfa7 100644 --- a/packages/core/src/tool/websearch.ts +++ b/packages/core/src/tool/websearch.ts @@ -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 }), diff --git a/packages/core/src/tool/write.ts b/packages/core/src/tool/write.ts index 6c8041ff42..4d86df9729 100644 --- a/packages/core/src/tool/write.ts +++ b/packages/core/src/tool/write.ts @@ -53,13 +53,11 @@ export const Plugin = { .transform((draft) => draft.add( name, - Tool.withPermission( - Tool.make({ + Tool.make({ description: "Write content to one file. Relative paths resolve within the active Location. Absolute paths inside the Location are accepted. Explicit external absolute paths require external_directory approval before edit approval.", input: Input, output: Output, - toModelOutput: ({ output }) => [{ type: "text", text: toModelOutput(output) }], execute: (input, context) => Effect.gen(function* () { const source = { @@ -86,12 +84,11 @@ export const Plugin = { }) return yield* files.writeTextPreservingBom({ target, content: input.content }) }).pipe( + Effect.map((output) => ({ output, content: toModelOutput(output) })), Effect.mapError((error) => new ToolFailure({ message: `Unable to write ${input.path}`, error })), ), }), - "edit", - ), - { codemode: false }, + { codemode: false, permission: "edit" }, ), ) .pipe(Effect.orDie) diff --git a/packages/core/test/codemode.test.ts b/packages/core/test/codemode.test.ts index 695be92145..b80eb1a985 100644 --- a/packages/core/test/codemode.test.ts +++ b/packages/core/test/codemode.test.ts @@ -9,14 +9,16 @@ describe("CodeMode", () => { it.effect("owns registrations, execute, and catalog materialization", () => Effect.gen(function* () { const codeMode = yield* CodeMode.Service - yield* codeMode.register({ - echo: Tool.make({ - description: "Echo text", - input: Schema.Struct({ text: Schema.String }), - output: Schema.String, - execute: ({ text }) => Effect.succeed(text), + yield* codeMode.register( + Tool.registrationEntries({ + echo: Tool.make({ + description: "Echo text", + input: Schema.Struct({ text: Schema.String }), + output: Schema.String, + execute: ({ text }) => Effect.succeed({ output: text }), + }), }), - }) + ) const materialized = yield* codeMode.materialize() expect(materialized.tool).toBeDefined() diff --git a/packages/core/test/database-migration.test.ts b/packages/core/test/database-migration.test.ts index 58673f2039..f98ded68f3 100644 --- a/packages/core/test/database-migration.test.ts +++ b/packages/core/test/database-migration.test.ts @@ -25,6 +25,7 @@ import addSessionForkMigration from "@opencode-ai/core/database/migration/202607 import timeSuspendedMigration from "@opencode-ai/core/database/migration/20260709163752_time_suspended" import instructionSyncMigration from "@opencode-ai/core/database/migration/20260710025429_instruction_sync" import deleteToolProgressEventsMigration from "@opencode-ai/core/database/migration/20260722011141_delete_tool_progress_events" +import canonicalToolResultsMigration from "@opencode-ai/core/database/migration/20260722170000_canonical_tool_results" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { LayerNode } from "@opencode-ai/util/effect/layer-node" import { EventV2 } from "@opencode-ai/core/event" @@ -583,6 +584,208 @@ describe("DatabaseMigration", () => { ) }) + test("rewrites projected tool rows into the canonical result shape", async () => { + await run( + Effect.gen(function* () { + const db = yield* makeDb + yield* db.run( + sql`CREATE TABLE session_message (id text PRIMARY KEY, session_id text NOT NULL, type text NOT NULL, seq integer NOT NULL, time_created integer NOT NULL, time_updated integer NOT NULL, data text NOT NULL)`, + ) + yield* db.run(sql`CREATE TABLE event (id text PRIMARY KEY, type text NOT NULL, data text NOT NULL)`) + const assistant = { + agent: "build", + model: { id: "model", providerID: "provider" }, + content: [ + { type: "text", text: "before" }, + { + type: "tool", + id: "call_content", + name: "grep", + state: { + status: "completed", + input: { pattern: "TODO" }, + content: [{ type: "text", text: "src/a.ts:1: TODO" }], + structured: { value: [{ file: "src/a.ts", line: 1 }] }, + }, + time: { created: 1, completed: 2 }, + }, + { + type: "tool", + id: "call_structured_only", + name: "read", + state: { + status: "completed", + input: { path: "README.md" }, + content: [], + structured: { text: "hello" }, + }, + time: { created: 1, completed: 2 }, + }, + { + type: "tool", + id: "call_hosted", + name: "web_search", + executed: true, + providerResultState: { blockType: "web_search_tool_result" }, + state: { + status: "completed", + input: { query: "effect" }, + content: [], + structured: {}, + result: { type: "json", value: [{ url: "https://example.com" }] }, + }, + time: { created: 1, completed: 2 }, + }, + { + type: "tool", + id: "call_failed", + name: "shell", + state: { + status: "error", + input: { command: "sleep 99" }, + error: { type: "tool.execution", message: "timed out" }, + content: [{ type: "text", text: "partial output" }], + structured: { truncated: false }, + result: { type: "error", value: "timed out" }, + }, + time: { created: 1, completed: 2 }, + }, + { + type: "tool", + id: "call_running", + name: "shell", + state: { + status: "running", + input: { command: "sleep 1" }, + structured: { truncated: false }, + content: [{ type: "text", text: "tick" }], + }, + time: { created: 1, ran: 2 }, + }, + ], + time: { created: 1 }, + } + yield* db.run( + sql`INSERT INTO session_message VALUES ('msg_tools', 'ses_test', 'assistant', 1, 10, 11, ${JSON.stringify(assistant)})`, + ) + yield* db.run( + sql`INSERT INTO session_message VALUES ('msg_user', 'ses_test', 'user', 2, 12, 13, '{"text":"hi","time":{"created":1}}')`, + ) + // A row that never decoded must be skipped, not fail the migration. + yield* db.run( + sql`INSERT INTO session_message VALUES ('msg_corrupt', 'ses_test', 'assistant', 3, 14, 15, 'not json')`, + ) + yield* db.run( + sql`INSERT INTO event VALUES ('evt_success', 'session.tool.success.1', ${JSON.stringify({ + sessionID: "ses_test", + assistantMessageID: "msg_tools", + callID: "call_hosted", + structured: {}, + content: [], + result: { type: "json", value: [{ url: "https://example.com" }] }, + executed: true, + })})`, + ) + yield* db.run( + sql`INSERT INTO event VALUES ('evt_failed', 'session.tool.failed.1', ${JSON.stringify({ + sessionID: "ses_test", + assistantMessageID: "msg_tools", + callID: "call_failed", + error: { type: "tool.execution", message: "timed out" }, + metadata: { truncated: false }, + executed: false, + })})`, + ) + + yield* DatabaseMigration.applyOnly(db, [canonicalToolResultsMigration]) + + const row = yield* db.get<{ data: string }>(sql`SELECT data FROM session_message WHERE id = 'msg_tools'`) + const migrated = JSON.parse(row!.data) + // Every migrated row must decode with the current schema; reload hard-fails otherwise. + Schema.decodeUnknownSync(SessionMessage.Info)({ ...migrated, id: "msg_tools", type: "assistant" }) + const states = new Map( + migrated.content.flatMap((part: { type: string; id?: string }) => + part.type === "tool" ? [[part.id, part]] : [], + ), + ) + expect(states.get("call_content")).toMatchObject({ + state: { + status: "completed", + input: { pattern: "TODO" }, + content: [{ type: "text", text: "src/a.ts:1: TODO" }], + // Old generic structured payloads survive as canonical metadata. + metadata: { value: [{ file: "src/a.ts", line: 1 }] }, + }, + }) + expect((states.get("call_content") as { state: Record }).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 }).state + expect(failedState).not.toHaveProperty("result") + expect(failedState).not.toHaveProperty("structured") + expect(states.get("call_running")).toMatchObject({ + state: { + status: "running", + metadata: { truncated: false }, + }, + }) + const event = yield* db.get<{ type: string; data: string }>(sql`SELECT type, data FROM event WHERE id = 'evt_success'`) + expect(event!.type).toBe("session.tool.success.1") + expect(JSON.parse(event!.data)).toEqual({ + sessionID: "ses_test", + assistantMessageID: "msg_tools", + callID: "call_hosted", + structured: {}, + content: [], + result: { type: "json", value: [{ url: "https://example.com" }] }, + executed: true, + }) + const failedEvent = yield* db.get<{ type: string; data: string }>(sql`SELECT type, data FROM event WHERE id = 'evt_failed'`) + expect(failedEvent!.type).toBe("session.tool.failed.1") + expect(JSON.parse(failedEvent!.data)).toEqual({ + sessionID: "ses_test", + assistantMessageID: "msg_tools", + callID: "call_failed", + error: { type: "tool.execution", message: "timed out" }, + metadata: { truncated: false }, + executed: false, + }) + expect(yield* db.get(sql`SELECT data FROM session_message WHERE id = 'msg_user'`)).toEqual({ + data: '{"text":"hi","time":{"created":1}}', + }) + expect(yield* db.get(sql`SELECT data FROM session_message WHERE id = 'msg_corrupt'`)).toEqual({ + data: "not json", + }) + }), + ) + }) + test("records the authoritative parent sequence on existing forks", async () => { await run( Effect.gen(function* () { diff --git a/packages/core/test/lib/tool.ts b/packages/core/test/lib/tool.ts index e3fdccb13a..2f41c3b9b4 100644 --- a/packages/core/test/lib/tool.ts +++ b/packages/core/test/lib/tool.ts @@ -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 = (plugin: { readonly id: string @@ -52,7 +52,7 @@ export const registerToolPlugin = (plugin: { Effect.gen(function* () { const registrations: Array<{ readonly name: string - readonly tool: Tool.AnyTool + readonly tool: Tool.Any readonly options?: Tool.RegisterOptions }> = [] callback({ @@ -73,8 +73,5 @@ export const registerToolPlugin = (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))) diff --git a/packages/core/test/mcp.test.ts b/packages/core/test/mcp.test.ts index 158ad32c67..55933feb1f 100644 --- a/packages/core/test/mcp.test.ts +++ b/packages/core/test/mcp.test.ts @@ -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 | undefined let decision: Effect.Effect = 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() + 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() + 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, }) diff --git a/packages/core/test/plugin.test.ts b/packages/core/test/plugin.test.ts index 0d19e4e74b..b784058149 100644 --- a/packages/core/test/plugin.test.ts +++ b/packages/core/test/plugin.test.ts @@ -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: [] }) }), ) }) diff --git a/packages/core/test/plugin/promise.test.ts b/packages/core/test/plugin/promise.test.ts index 7922d965bb..76e2a1e31b 100644 --- a/packages/core/test/plugin/promise.test.ts +++ b/packages/core/test/plugin/promise.test.ts @@ -14,6 +14,7 @@ import { SessionPending } from "@opencode-ai/core/session/pending" import { ToolRegistry } from "@opencode-ai/core/tool/registry" import { ProviderV2 } from "@opencode-ai/core/provider" import { Plugin } from "@opencode-ai/plugin/v2" +import { Tool } from "@opencode-ai/plugin/v2/tool" import type { SessionHooks } from "@opencode-ai/plugin/v2/effect/session" import { Model } from "@opencode-ai/schema/model" import { Provider } from "@opencode-ai/schema/provider" @@ -270,7 +271,7 @@ describe("fromPromise", () => { }), ) - it.effect("constructs plain Promise tool declarations in the host", () => + it.effect("constructs plain Promise tool definitions in the host", () => Effect.gen(function* () { const plugins = yield* PluginV2.Service const registry = yield* ToolRegistry.Service @@ -280,35 +281,41 @@ describe("fromPromise", () => { id: "promise-tool", setup: async (ctx) => { await ctx.tool.transform((tools) => { - tools.add({ - name: "hello", - options: { codemode: false }, - description: "Hello", - input: Schema.Struct({ name: Schema.String }), - output: Schema.String, - execute: async ({ name }, context) => { - await context.progress({ structured: { phase: "greeting" } }) - return `Hello, ${name}!` - }, - }) + tools.add( + "hello", + Tool.make({ + description: "Hello", + input: Schema.Struct({ name: Schema.String }), + output: Schema.String, + execute: async ({ name }, context) => { + await context.progress({ phase: "greeting" }) + return { output: `Hello, ${name}!` } + }, + }), + { codemode: false }, + ) }) }, }) yield* PluginPromise.fromPromise(promisePlugin).effect(host) - const materialized = yield* registry.materialize() - expect(materialized.definitions).toContainEqual(expect.objectContaining({ name: "hello", description: "Hello" })) + const toolSet = yield* registry.snapshot() + expect(toolSet.definitions).toContainEqual(expect.objectContaining({ name: "hello", description: "Hello" })) expect( - yield* materialized.settle({ + yield* toolSet.execute({ sessionID: SessionV2.ID.make("ses_promise_tool"), agent: AgentV2.ID.make("build"), messageID: SessionMessage.ID.make("msg_promise_tool"), progress: (update) => Effect.sync(() => progress.push(update)), call: { type: "tool-call", id: "call_promise_tool", name: "hello", input: { name: "world" } }, }), - ).toMatchObject({ result: { type: "text", value: "Hello, world!" } }) - expect(progress).toEqual([{ structured: { phase: "greeting" }, content: [] }]) + ).toMatchObject({ + status: "completed", + output: "Hello, world!", + content: [{ type: "text", text: "Hello, world!" }], + }) + expect(progress).toEqual([{ phase: "greeting" }]) }), ) }) diff --git a/packages/core/test/session-generate.test.ts b/packages/core/test/session-generate.test.ts index e78f41bdac..7471564610 100644 --- a/packages/core/test/session-generate.test.ts +++ b/packages/core/test/session-generate.test.ts @@ -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")), diff --git a/packages/core/test/session-instructions.test.ts b/packages/core/test/session-instructions.test.ts index f0720cfc12..9d40f95f4b 100644 --- a/packages/core/test/session-instructions.test.ts +++ b/packages/core/test/session-instructions.test.ts @@ -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) }), diff --git a/packages/core/test/session-runner-message.test.ts b/packages/core/test/session-runner-message.test.ts index a79b351cfa..3cebdcb985 100644 --- a/packages/core/test/session-runner-message.test.ts +++ b/packages/core/test/session-runner-message.test.ts @@ -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, diff --git a/packages/core/test/session-runner-tool-events.test.ts b/packages/core/test/session-runner-tool-events.test.ts index 66a0a43f54..2ba096b306 100644 --- a/packages/core/test/session-runner-tool-events.test.ts +++ b/packages/core/test/session-runner-tool-events.test.ts @@ -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 () => { diff --git a/packages/core/test/session-runner-tool-registry.test.ts b/packages/core/test/session-runner-tool-registry.test.ts index 053b215269..29e1e17c14 100644 --- a/packages/core/test/session-runner-tool-registry.test.ts +++ b/packages/core/test/session-runner-tool-registry.test.ts @@ -8,23 +8,24 @@ import { SessionV2 } from "@opencode-ai/core/session" import { SessionMessage } from "@opencode-ai/core/session/message" import { ToolOutputStore } from "@opencode-ai/core/tool-output-store" import { ToolRegistry } from "@opencode-ai/core/tool/registry" -import { executeTool, settleTool, toolDefinitions } from "./lib/tool" +import { executeTool, toolDefinitions } from "./lib/tool" import { Cause, Deferred, Effect, Exit, Fiber, Layer, Option, Schema, SchemaGetter, SchemaIssue, Scope } from "effect" import { testEffect } from "./lib/effect" const bounds: ToolOutputStore.BoundInput[] = [] const retentionFailure = new ToolOutputStore.StorageError({ operation: "write", cause: new Error("disk full") }) const outputStore = Layer.mock(ToolOutputStore.Service, { + limits: () => Effect.succeed({ maxLines: ToolOutputStore.MAX_LINES, maxBytes: ToolOutputStore.MAX_BYTES }), bound: (input) => { if (input.callID === "call-retention-failure") return Effect.fail(retentionFailure) return Effect.sync(() => bounds.push(input)).pipe( Effect.as( input.callID === "call-bounded" ? { - output: { structured: {}, content: [{ type: "text" as const, text: "bounded reference" }] }, + content: [{ type: "text" as const, text: "bounded reference" }], outputPaths: ["/managed/generic"], } - : { output: input.output, outputPaths: [] }, + : { content: input.content, outputPaths: [] }, ), ) }, @@ -63,24 +64,20 @@ const call = (name: string, id = `call-${name}`): ToolRegistry.ExecuteInput => ( call: { type: "tool-call", id, name, input: { text: name } }, }) -const make = (permission?: string) => { - const tool = Tool.make({ +const make = () => + Tool.make({ description: "Echo text", input: Schema.Struct({ text: Schema.String }), output: Schema.Struct({ text: Schema.String }), - execute: ({ text }) => Effect.succeed({ text }), - toModelOutput: ({ output }) => [{ type: "text", text: output.text }], + execute: ({ text }) => Effect.succeed({ output: { text }, content: text }), }) - return permission ? Tool.withPermission(tool, permission) : tool -} const constant = (text: string) => Tool.make({ description: "Return text", input: Schema.Struct({ text: Schema.String }), output: Schema.Struct({ text: Schema.String }), - execute: () => Effect.succeed({ text }), - toModelOutput: ({ output }) => [{ type: "text" as const, text: output.text }], + execute: () => Effect.succeed({ output: { text }, content: text }), }) describe("ToolRegistry", () => { @@ -91,7 +88,21 @@ describe("ToolRegistry", () => { expect(error).toBeInstanceOf(Tool.RegistrationError) expect(error.message).toBe('Invalid tool namespace: "slack..admin"') - expect((yield* service.materialize()).definitions).toEqual([]) + expect((yield* service.snapshot()).definitions).toEqual([]) + }), + ) + + it.effect("rejects invalid and colliding normalized names", () => + Effect.gen(function* () { + const service = yield* ToolRegistry.Service + const invalid = yield* service.register({ "123": make() }, { codemode: false }).pipe(Effect.flip) + expect(invalid.message).toBe("Invalid tool name: 123") + + const collision = yield* service + .register({ "echo.tool": make(), echo_tool: make() }, { codemode: false }) + .pipe(Effect.flip) + expect(collision.message).toBe("Duplicate normalized tool name: echo_tool") + expect((yield* service.snapshot()).definitions).toEqual([]) }), ) @@ -106,19 +117,15 @@ describe("ToolRegistry", () => { .pipe(Effect.flip) expect(error).toBeInstanceOf(Tool.RegistrationError) - expect((yield* service.materialize()).definitions).toEqual([]) + expect((yield* service.snapshot()).definitions).toEqual([]) }), ) it.effect("filters disabled tools with edit aliases and ordered wildcard precedence", () => Effect.gen(function* () { const service = yield* ToolRegistry.Service - yield* service.register({ - question: make(), - bash: make(), - edit: make("edit"), - write: make("edit"), - }, { codemode: false }) + yield* service.register({ question: make(), bash: make() }, { codemode: false }) + yield* service.register({ edit: make(), write: make() }, { codemode: false, permission: "edit" }) const names = (permissions: PermissionV2.Ruleset) => toolDefinitions(service, permissions).pipe(Effect.map((definitions) => definitions.map((tool) => tool.name))) @@ -139,18 +146,15 @@ describe("ToolRegistry", () => { }), ) - it.effect("keeps permission decoration isolated between registrations", () => + it.effect("keeps permission options isolated between registrations", () => Effect.gen(function* () { const service = yield* ToolRegistry.Service const shared = make() yield* service.register({ first: shared }, { codemode: false }) - yield* service.register({ second: Tool.withPermission(shared, "edit") }, { codemode: false }) - Tool.withPermission(shared, "question") + yield* service.register({ second: shared }, { codemode: false, permission: "edit" }) expect( - (yield* toolDefinitions(service, [{ action: "edit", resource: "*", effect: "deny" }])).map( - (definition) => definition.name, - ), + (yield* toolDefinitions(service, [{ action: "edit", resource: "*", effect: "deny" }])).map((tool) => tool.name), ).toEqual(["first"]) }), ) @@ -191,41 +195,47 @@ describe("ToolRegistry", () => { it.effect("returns model errors without swallowing interruption or defects", () => Effect.gen(function* () { const service = yield* ToolRegistry.Service - yield* service.register({ - failed: Tool.make({ - description: "Failed", - input: Schema.Struct({}), - output: Schema.Struct({ ok: Schema.Boolean }), - execute: () => Effect.fail(new Tool.Failure({ message: "Denied" })), - }), - }, { codemode: false }) + yield* service.register( + { + failed: Tool.make({ + description: "Failed", + input: Schema.Struct({}), + output: Schema.Struct({ ok: Schema.Boolean }), + execute: () => Effect.fail(new Tool.Failure({ message: "Denied" })), + }), + }, + { codemode: false }, + ) expect( yield* executeTool(service, { sessionID, ...identity, call: { type: "tool-call", id: "failed", name: "failed", input: {} }, }), - ).toEqual({ type: "error", value: "Denied" }) + ).toEqual({ status: "error", error: { type: "tool.execution", message: "Denied" } }) expect( yield* executeTool(service, { sessionID, ...identity, call: { type: "tool-call", id: "missing", name: "missing", input: {} }, }), - ).toEqual({ type: "error", value: "Unknown tool: missing" }) + ).toEqual({ status: "error", error: { type: "tool.unknown", message: "Unknown tool: missing" } }) - yield* service.register({ - defect: Tool.make({ - description: "Defect", - input: Schema.Struct({}), - output: Schema.Struct({}), - execute: () => Effect.die("unexpected executor defect"), - }), - }, { codemode: false }) + yield* service.register( + { + defect: Tool.make({ + description: "Defect", + input: Schema.Struct({}), + output: Schema.Struct({}), + execute: () => Effect.die("unexpected executor defect"), + }), + }, + { codemode: false }, + ) expect( - yield* service.materialize().pipe( - Effect.flatMap((materialized) => - materialized.settle({ + yield* service.snapshot().pipe( + Effect.flatMap((toolSet) => + toolSet.execute({ sessionID, ...identity, call: { type: "tool-call", id: "defect", name: "defect", input: {} }, @@ -237,12 +247,12 @@ describe("ToolRegistry", () => { }), ) - it.effect("propagates retention failures through settlement", () => + it.effect("propagates retention failures through execution", () => Effect.gen(function* () { const service = yield* ToolRegistry.Service yield* service.register({ echo: make() }, { codemode: false }) - const materialized = yield* service.materialize() - const exit = yield* materialized.settle(call("echo", "call-retention-failure")).pipe(Effect.exit) + const toolSet = yield* service.snapshot() + const exit = yield* toolSet.execute(call("echo", "call-retention-failure")).pipe(Effect.exit) expect(Exit.isFailure(exit)).toBe(true) if (Exit.isFailure(exit)) expect(Option.getOrUndefined(Cause.findErrorOption(exit.cause))).toBe(retentionFailure) @@ -250,79 +260,88 @@ describe("ToolRegistry", () => { }), ) - it.effect("exposes settlement only through materialization", () => + it.effect("exposes execution only through a snapshot", () => Effect.gen(function* () { const service = yield* ToolRegistry.Service expect("definitions" in service).toBe(false) expect("execute" in service).toBe(false) expect("settle" in service).toBe(false) - expect(typeof service.materialize).toBe("function") + expect(typeof service.snapshot).toBe("function") }), ) - it.effect("passes complete invocation identity to the canonical handler", () => + it.effect("passes complete call identity to tool execution", () => Effect.gen(function* () { const service = yield* ToolRegistry.Service const contexts: Tool.Context[] = [] - yield* service.register({ - context: Tool.make({ - description: "Context", - input: Schema.Struct({}), - output: Schema.Struct({ ok: Schema.Boolean }), - execute: (_, context) => Effect.sync(() => contexts.push(context)).pipe(Effect.as({ ok: true })), - }), - }, { codemode: false }) + yield* service.register( + { + context: Tool.make({ + description: "Context", + input: Schema.Struct({}), + output: Schema.Struct({ ok: Schema.Boolean }), + execute: (_, context) => + Effect.sync(() => contexts.push(context)).pipe(Effect.as({ output: { ok: true } })), + }), + }, + { codemode: false }, + ) yield* executeTool(service, { sessionID, ...identity, call: { type: "tool-call", id: "call-context", name: "context", input: {} }, }) - expect(contexts).toEqual([ - { sessionID, ...identity, callID: "call-context", progress: expect.any(Function) }, - ]) + expect(contexts).toEqual([{ sessionID, ...identity, callID: "call-context", progress: expect.any(Function) }]) }), ) - it.effect("encodes output and applies generic settlement bounding", () => + it.effect("encodes output and applies generic execution bounding", () => Effect.gen(function* () { bounds.length = 0 const service = yield* ToolRegistry.Service yield* service.register({ bounded: make() }, { codemode: false }) expect( - yield* settleTool(service, { + yield* executeTool(service, { sessionID, ...identity, call: { type: "tool-call", id: "call-bounded", name: "bounded", input: { text: "complete" } }, }), ).toEqual({ - result: { type: "text", value: "bounded reference" }, - output: { structured: {}, content: [{ type: "text", text: "bounded reference" }] }, + status: "completed", + output: { text: "complete" }, + content: [{ type: "text", text: "bounded reference" }], outputPaths: ["/managed/generic"], }) expect(bounds).toHaveLength(1) }), ) - it.effect("normalizes image tool output at settlement and drops unresizable images", () => + it.effect("normalizes image tool output at execution and drops unresizable images", () => Effect.gen(function* () { const service = yield* ToolRegistry.Service - yield* service.register({ - snapshot: Tool.make({ - description: "Return images", - input: Schema.Struct({ text: Schema.String }), - output: Schema.Struct({ text: Schema.String }), - execute: ({ text }) => Effect.succeed({ text }), - toModelOutput: ({ output }) => [ - { type: "file", data: "aW1hZ2U=", mime: "image/png", name: "frame.png" }, - { type: "file", data: "aW1hZ2U=", mime: "image/png", name: "too-large.png" }, - { type: "file", data: "aW1hZ2U=", mime: "image/png", name: "corrupt.png" }, - { type: "text", text: output.text }, - ], - }), - }, { codemode: false }) + yield* service.register( + { + snapshot: Tool.make({ + description: "Return images", + input: Schema.Struct({ text: Schema.String }), + output: Schema.Struct({ text: Schema.String }), + execute: ({ text }) => + Effect.succeed({ + output: { text }, + content: [ + { type: "file", data: "aW1hZ2U=", mime: "image/png", name: "frame.png" }, + { type: "file", data: "aW1hZ2U=", mime: "image/png", name: "too-large.png" }, + { type: "file", data: "aW1hZ2U=", mime: "image/png", name: "corrupt.png" }, + { type: "text", text }, + ], + }), + }), + }, + { codemode: false }, + ) - const settlement = yield* settleTool(service, call("snapshot")) - expect(settlement.output?.content).toEqual([ + const execution = yield* executeTool(service, call("snapshot")) + expect(execution.content).toEqual([ { type: "file", uri: "data:image/jpeg;base64,bm9ybWFsaXplZA==", mime: "image/jpeg", name: "frame.png" }, { type: "text", text: "snapshot" }, { type: "text", text: "[1 image omitted: could not be decoded.]" }, @@ -331,44 +350,31 @@ describe("ToolRegistry", () => { }), ) - it.effect("normalizes image progress content before it is published", () => + it.effect("publishes progress metadata unchanged", () => Effect.gen(function* () { const service = yield* ToolRegistry.Service - yield* service.register({ - progressive: Tool.make({ - description: "Emit image progress", - input: Schema.Struct({ text: Schema.String }), - output: Schema.Struct({ text: Schema.String }), - execute: ({ text }, context) => - context - .progress({ - structured: { stage: "capture" }, - content: [ - { type: "file", data: "aW1hZ2U=", mime: "image/png", name: "frame.png" }, - { type: "file", data: "aW1hZ2U=", mime: "image/png", name: "too-large.png" }, - ], - }) - .pipe(Effect.as({ text })), - }), - }, { codemode: false }) + yield* service.register( + { + progressive: Tool.make({ + description: "Emit image progress", + input: Schema.Struct({ text: Schema.String }), + output: Schema.Struct({ text: Schema.String }), + execute: ({ text }, context) => + context.progress({ stage: "capture" }).pipe(Effect.as({ output: { text } })), + }), + }, + { codemode: false }, + ) const updates: ToolRegistry.Progress[] = [] - yield* settleTool(service, { + yield* executeTool(service, { ...call("progressive"), progress: (update) => Effect.sync(() => { updates.push(update) }), }) - expect(updates).toEqual([ - { - structured: { stage: "capture" }, - content: [ - { type: "file", uri: "data:image/jpeg;base64,bm9ybWFsaXplZA==", mime: "image/jpeg", name: "frame.png" }, - { type: "text", text: "[1 image omitted: could not be resized below the image size limit.]" }, - ], - }, - ]) + expect(updates).toEqual([{ stage: "capture" }]) }), ) @@ -382,23 +388,31 @@ describe("ToolRegistry", () => { encode: SchemaGetter.transform((value) => value === "yes"), }), ) - yield* service.register({ - transformed: Tool.make({ - description: "Transform values", - input: Schema.Struct({ value: Transformed }), - output: Schema.Struct({ value: Transformed }), - execute: ({ value }) => Effect.sync(() => executed.push(value)).pipe(Effect.as({ value })), - toModelOutput: ({ output }) => [{ type: "text", text: String(output.value) }], - }), - }, { codemode: false }) + yield* service.register( + { + transformed: Tool.make({ + description: "Transform values", + input: Schema.Struct({ value: Transformed }), + output: Schema.Struct({ value: Transformed }), + execute: ({ value }) => + Effect.sync(() => executed.push(value)).pipe(Effect.as({ output: { value }, content: String(value) })), + }), + }, + { codemode: false }, + ) + // Canonical content observes the decoded domain value; Code Mode observes the encoded value. expect( yield* executeTool(service, { sessionID, ...identity, call: { type: "tool-call", id: "transformed", name: "transformed", input: { value: true } }, }), - ).toEqual({ type: "text", value: "true" }) + ).toEqual({ + status: "completed", + output: { value: true }, + content: [{ type: "text", text: "yes" }], + }) expect(executed).toEqual(["yes"]) expect( yield* executeTool(service, { @@ -406,35 +420,44 @@ describe("ToolRegistry", () => { ...identity, call: { type: "tool-call", id: "invalid-input", name: "transformed", input: { value: "yes" } }, }), - ).toMatchObject({ type: "error", value: expect.stringContaining("Invalid tool input") }) + ).toMatchObject({ + status: "error", + error: { type: "tool.execution", message: expect.stringContaining("Invalid tool input") }, + }) expect(executed).toEqual(["yes"]) - yield* service.register({ - invalid_output: Tool.make({ - description: "Return invalid output", - input: Schema.Struct({}), - output: Schema.Struct({ - value: Schema.Boolean.pipe( - Schema.decodeTo(Schema.String, { - decode: SchemaGetter.transform((value) => String(value)), - encode: SchemaGetter.transformOrFail((value) => - value === "valid" - ? Effect.succeed(true) - : Effect.fail(new SchemaIssue.InvalidValue(Option.some(value), { message: "invalid output" })), - ), - }), - ), + yield* service.register( + { + invalid_output: Tool.make({ + description: "Return invalid output", + input: Schema.Struct({}), + output: Schema.Struct({ + value: Schema.Boolean.pipe( + Schema.decodeTo(Schema.String, { + decode: SchemaGetter.transform((value) => String(value)), + encode: SchemaGetter.transformOrFail((value) => + value === "valid" + ? Effect.succeed(true) + : Effect.fail(new SchemaIssue.InvalidValue(Option.some(value), { message: "invalid output" })), + ), + }), + ), + }), + execute: () => Effect.succeed({ output: { value: "invalid" } }), }), - execute: () => Effect.succeed({ value: "invalid" }), - }), - }, { codemode: false }) + }, + { codemode: false }, + ) expect( yield* executeTool(service, { sessionID, ...identity, call: { type: "tool-call", id: "invalid-output", name: "invalid_output", input: {} }, }), - ).toMatchObject({ type: "error", value: expect.stringContaining("invalid value for its output schema") }) + ).toMatchObject({ + status: "error", + error: { type: "tool.execution", message: expect.stringContaining("invalid value for its output schema") }, + }) }), ) @@ -443,12 +466,12 @@ describe("ToolRegistry", () => { const service = yield* ToolRegistry.Service const scope = yield* Scope.make() yield* service.register({ echo: constant("advertised") }, { codemode: false }).pipe(Scope.provide(scope)) - const request = yield* service.materialize() + const request = yield* service.snapshot() yield* Scope.close(scope, Exit.void) yield* service.register({ echo: constant("replacement") }, { codemode: false }) - expect((yield* request.settle(call("echo"))).result).toEqual({ type: "text", value: "advertised" }) - expect(yield* executeTool(service, call("echo"))).toEqual({ type: "text", value: "replacement" }) + expect((yield* request.execute(call("echo"))).content).toEqual([{ type: "text", text: "advertised" }]) + expect((yield* executeTool(service, call("echo"))).content).toEqual([{ type: "text", text: "replacement" }]) }), ) @@ -459,9 +482,9 @@ describe("ToolRegistry", () => { const overlay = yield* Scope.make() yield* service.register({ echo: constant("overlay") }, { codemode: false }).pipe(Scope.provide(overlay)) - expect(yield* executeTool(service, call("echo"))).toEqual({ type: "text", value: "overlay" }) + expect((yield* executeTool(service, call("echo"))).content).toEqual([{ type: "text", text: "overlay" }]) yield* Scope.close(overlay, Exit.void) - expect(yield* executeTool(service, call("echo"))).toEqual({ type: "text", value: "base" }) + expect((yield* executeTool(service, call("echo"))).content).toEqual([{ type: "text", text: "base" }]) }), ) @@ -476,12 +499,13 @@ describe("ToolRegistry", () => { description: "Echo text", input: Schema.Struct({ text: Schema.String }), output: Schema.Struct({ text: Schema.String }), - execute: ({ text }) => Effect.sync(() => executed.push(`old:${text}`)).pipe(Effect.as({ text })), + execute: ({ text }) => + Effect.sync(() => executed.push(`old:${text}`)).pipe(Effect.as({ output: { text } })), }), }) .pipe(Scope.provide(scope)) - const materialized = yield* service.materialize() - const execute = materialized.definitions.find((tool) => tool.name === "execute") + const toolSet = yield* service.snapshot() + const execute = toolSet.definitions.find((tool) => tool.name === "execute") expect(execute?.description).toContain("confined Code Mode runtime") expect(execute?.description).not.toContain("Echo text") yield* Scope.close(scope, Exit.void) @@ -490,11 +514,11 @@ describe("ToolRegistry", () => { description: "Echo text", input: Schema.Struct({ text: Schema.String }), output: Schema.Struct({ text: Schema.String }), - execute: ({ text }) => Effect.sync(() => executed.push(`new:${text}`)).pipe(Effect.as({ text })), + execute: ({ text }) => Effect.sync(() => executed.push(`new:${text}`)).pipe(Effect.as({ output: { text } })), }), }) - const settlement = yield* materialized.settle({ + const execution = yield* toolSet.execute({ ...call("execute"), call: { type: "tool-call", @@ -504,7 +528,7 @@ describe("ToolRegistry", () => { }, }) - expect(settlement.result).toMatchObject({ type: "text" }) + expect(execution).toMatchObject({ status: "completed", content: [{ type: "text" }] }) expect(executed).toEqual(["old:request"]) }), ) diff --git a/packages/core/test/session-runner.test.ts b/packages/core/test/session-runner.test.ts index 86e9d7c576..77046d6d9f 100644 --- a/packages/core/test/session-runner.test.ts +++ b/packages/core/test/session-runner.test.ts @@ -52,6 +52,7 @@ import { AgentV2 } from "@opencode-ai/core/agent" import { Config } from "@opencode-ai/core/config" import { ConfigCompaction } from "@opencode-ai/core/config/compaction" import { Tool } from "@opencode-ai/core/tool/tool" +import { ToolHooks } from "@opencode-ai/core/tool/hooks" import { InstructionStateTable, SessionPendingTable, @@ -238,43 +239,45 @@ const permission = Layer.succeed( ) const echo = Layer.effectDiscard( ToolRegistry.Service.use((registry) => - registry.register({ - echo: Tool.make({ - description: "Echo text", - input: Schema.Struct({ text: Schema.String }), - output: Schema.Struct({ text: Schema.String }), - toModelOutput: ({ output }) => [{ type: "text", text: output.text }], - execute: ({ text }, context) => - Effect.gen(function* () { - authorizations.push(context) - executions.push(text) - activeToolExecutions++ - maxActiveToolExecutions = Math.max(maxActiveToolExecutions, activeToolExecutions) - if (activeToolExecutions === toolExecutionsReady && toolExecutionsStarted) { - yield* Deferred.succeed(toolExecutionsStarted, undefined) - } - if (toolExecutionGate) yield* Deferred.await(toolExecutionGate) - return { text } - }).pipe(Effect.ensuring(Effect.sync(() => activeToolExecutions--))), - }), - defect: Tool.make({ - description: "Fail unexpectedly", - input: Schema.Struct({}), - output: Schema.Struct({}), - execute: () => - (toolExecutionGate ? Deferred.await(toolExecutionGate) : Effect.void).pipe( - Effect.andThen(Effect.die("unexpected tool defect")), - ), - }), - // BigInt output with no model content forces ToolOutputStore.bound onto its - // JSON.stringify encode path, which fails with a typed StorageError. - storefail: Tool.make({ - description: "Produce output that cannot be persisted", - input: Schema.Struct({}), - output: Schema.Any, - execute: () => Effect.succeed({ big: 1n }), - }), - }, { codemode: false }), + registry.register( + { + echo: Tool.make({ + description: "Echo text", + input: Schema.Struct({ text: Schema.String }), + output: Schema.Struct({ text: Schema.String }), + execute: ({ text }, context) => + Effect.gen(function* () { + authorizations.push(context) + executions.push(text) + activeToolExecutions++ + maxActiveToolExecutions = Math.max(maxActiveToolExecutions, activeToolExecutions) + if (activeToolExecutions === toolExecutionsReady && toolExecutionsStarted) { + yield* Deferred.succeed(toolExecutionsStarted, undefined) + } + if (toolExecutionGate) yield* Deferred.await(toolExecutionGate) + return { output: { text }, content: text } + }).pipe(Effect.ensuring(Effect.sync(() => activeToolExecutions--))), + }), + defect: Tool.make({ + description: "Fail unexpectedly", + input: Schema.Struct({}), + output: Schema.Struct({}), + execute: () => + (toolExecutionGate ? Deferred.await(toolExecutionGate) : Effect.void).pipe( + Effect.andThen(Effect.die("unexpected tool defect")), + ), + }), + // The wrapped ToolOutputStore below fails bound for this call ID with a + // typed StorageError, exercising the infrastructure failure channel. + storefail: Tool.make({ + description: "Produce output that cannot be persisted", + input: Schema.Struct({}), + output: Schema.Struct({}), + execute: () => Effect.succeed({ output: {} }), + }), + }, + { codemode: false }, + ), ), ) const echoNode = makeLocationNode({ name: "test/session-runner-tools", layer: echo, deps: [ToolRegistry.node] }) @@ -379,6 +382,15 @@ const promptCatalog = Layer.mock(Catalog.Service, { small: () => Effect.succeed(undefined), }, }) +// Pass-through bounding that fails "call-storefail" with a typed StorageError so +// runner tests can exercise the infrastructure failure channel deterministically. +const toolOutputStore = Layer.mock(ToolOutputStore.Service, { + limits: () => Effect.succeed({ maxLines: ToolOutputStore.MAX_LINES, maxBytes: ToolOutputStore.MAX_BYTES }), + bound: (input) => + input.callID === "call-storefail" + ? Effect.fail(new ToolOutputStore.StorageError({ operation: "write", cause: new Error("disk full") })) + : Effect.succeed({ content: input.content, outputPaths: [] }), +}) const runnerLayer = AppNodeBuilder.build(SessionRunnerLLM.node, [ [Snapshot.node, Snapshot.noopLayer], [LayerNodePlatform.llmClient, client], @@ -391,7 +403,7 @@ const runnerLayer = AppNodeBuilder.build(SessionRunnerLLM.node, [ [PermissionV2.node, permission], [Config.node, config], [McpInstructions.node, mcpInstructions], - [ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig], + [ToolOutputStore.node, toolOutputStore], [PluginSupervisor.node, pluginSupervisor], ]) const execution = Layer.effect( @@ -422,6 +434,7 @@ const it = testEffect( Catalog.node, ToolRegistry.node, ToolRegistry.toolsNode, + ToolHooks.node, PluginHooks.node, echoNode, SessionRunnerModel.node, @@ -449,7 +462,7 @@ const it = testEffect( [Snapshot.node, Snapshot.noopLayer], [SessionExecution.node, execution], [Config.node, config], - [ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig], + [ToolOutputStore.node, toolOutputStore], [PluginSupervisor.node, pluginSupervisor], ], ), @@ -586,8 +599,8 @@ const recordedStepSettlementEvents = (id: SessionV2.ID, assistantMessageID: Sess const settlementTypes = new Set([ "session.step.started.1", "session.tool.called.1", - "session.tool.success.1", - "session.tool.failed.1", + "session.tool.success.2", + "session.tool.failed.2", "session.step.ended.1", "session.step.failed.1", ]) @@ -827,12 +840,26 @@ describe("SessionRunnerLLM", () => { yield* session.resume(sessionID) - expect(requests).toHaveLength(1) + // A hook-removed call fails independently and continues while step allowance remains. + expect(requests).toHaveLength(2) expect(requests[0]?.system.map((part) => part.text)).toEqual(["Hooked system"]) expect(requests[0]?.messages).toEqual([Message.user("Hooked message")]) expect(requests[0]?.tools.map((tool) => tool.name)).not.toContain("echo") expect(requests[0]?.tools.map((tool) => tool.name)).not.toContain("unregistered") expect(executions).toEqual([]) + expect(yield* session.context(sessionID)).toMatchObject([ + { type: "user", text: "Original message" }, + { + type: "assistant", + content: [ + { + type: "tool", + id: "call-removed", + state: { status: "error", error: { type: "tool.unknown" } }, + }, + ], + }, + ]) }), ) @@ -841,19 +868,22 @@ describe("SessionRunnerLLM", () => { const session = yield* setup const registry = yield* ToolRegistry.Service const contexts: Tool.Context[] = [] - yield* registry.register({ - location_context: Tool.make({ - description: "Read application context", - input: Schema.Struct({ query: Schema.String }), - output: Schema.Struct({ answer: Schema.String }), - execute: ({ query }, context) => - Effect.gen(function* () { - contexts.push(context) - yield* context.progress({ structured: { phase: "reading" } }) - return { answer: query.toUpperCase() } - }), - }), - }, { codemode: false }) + yield* registry.register( + { + location_context: Tool.make({ + description: "Read application context", + input: Schema.Struct({ query: Schema.String }), + output: Schema.Struct({ answer: Schema.String }), + execute: ({ query }, context) => + Effect.gen(function* () { + contexts.push(context) + yield* context.progress({ phase: "reading" }) + return { output: { answer: query.toUpperCase() } } + }), + }), + }, + { codemode: false }, + ) yield* admit(session, "Use application context") responses = [reply.tool("call-location", "location_context", { query: "hello" }), []] const events = yield* EventV2.Service @@ -876,7 +906,7 @@ describe("SessionRunnerLLM", () => { progress: expect.any(Function), }, ]) - expect(Array.from(yield* Fiber.join(progressFiber))[0]?.data.structured).toEqual({ phase: "reading" }) + expect(Array.from(yield* Fiber.join(progressFiber))[0]?.data.metadata).toEqual({ phase: "reading" }) expect(yield* session.context(sessionID)).toMatchObject([ { type: "user", text: "Use application context" }, { @@ -885,7 +915,7 @@ describe("SessionRunnerLLM", () => { { type: "tool", id: "call-location", - state: { status: "completed", structured: { answer: "HELLO" } }, + state: { status: "completed", content: [{ type: "text", text: '{"answer":"HELLO"}' }] }, }, ], }, @@ -893,25 +923,29 @@ describe("SessionRunnerLLM", () => { }), ) - it.effect("persists the latest partial snapshot when a tool fails", () => + it.effect("prefers failure outcome metadata over retained progress", () => Effect.gen(function* () { const session = yield* setup const registry = yield* ToolRegistry.Service - yield* registry.register({ - failing_progress: Tool.make({ - description: "Report progress and fail", - input: Schema.Struct({}), - output: Schema.Struct({}), - execute: (_, context) => - Effect.gen(function* () { - yield* context.progress({ - structured: { phase: "running" }, - content: [{ type: "text", text: "before failure" }], - }) - return yield* new ToolFailure({ message: "failed after progress" }) - }), - }), - }, { codemode: false }) + const hooks = yield* ToolHooks.Service + yield* hooks.hook.after((event) => { + if (event.status === "error") event.metadata = { phase: "failed" } + }) + yield* registry.register( + { + failing_progress: Tool.make({ + description: "Report progress and fail", + input: Schema.Struct({}), + output: Schema.Struct({}), + execute: (_, context) => + Effect.gen(function* () { + yield* context.progress({ phase: "running" }) + return yield* new ToolFailure({ message: "failed after progress" }) + }), + }), + }, + { codemode: false }, + ) yield* admit(session, "Run failing progress") responses = [reply.tool("call-failing-progress", "failing_progress", {}), reply.stop()] @@ -927,8 +961,7 @@ describe("SessionRunnerLLM", () => { id: "call-failing-progress", state: { status: "error", - structured: { phase: "running" }, - content: [{ type: "text", text: "before failure" }], + metadata: { phase: "failed" }, error: { message: "failed after progress" }, }, }, @@ -946,14 +979,20 @@ describe("SessionRunnerLLM", () => { const scope = yield* Scope.make() const executions: string[] = [] yield* registry - .register({ - reloaded: Tool.make({ - description: "Record the advertised tool", - input: Schema.Struct({}), - output: Schema.Struct({ value: Schema.String }), - execute: () => Effect.sync(() => executions.push("advertised")).pipe(Effect.as({ value: "advertised" })), - }), - }, { codemode: false }) + .register( + { + reloaded: Tool.make({ + description: "Record the advertised tool", + input: Schema.Struct({}), + output: Schema.Struct({ value: Schema.String }), + execute: () => + Effect.sync(() => executions.push("advertised")).pipe( + Effect.as({ output: { value: "advertised" } }), + ), + }), + }, + { codemode: false }, + ) .pipe(Scope.provide(scope)) yield* admit(session, "Use the reloaded tool") responses = [ @@ -971,14 +1010,20 @@ describe("SessionRunnerLLM", () => { const run = yield* session.resume(sessionID).pipe(Effect.forkChild) yield* Deferred.await(streamStarted) yield* Scope.close(scope, Exit.void) - yield* registry.register({ - reloaded: Tool.make({ - description: "Record the replacement tool", - input: Schema.Struct({}), - output: Schema.Struct({ value: Schema.String }), - execute: () => Effect.sync(() => executions.push("replacement")).pipe(Effect.as({ value: "replacement" })), - }), - }, { codemode: false }) + yield* registry.register( + { + reloaded: Tool.make({ + description: "Record the replacement tool", + input: Schema.Struct({}), + output: Schema.Struct({ value: Schema.String }), + execute: () => + Effect.sync(() => executions.push("replacement")).pipe( + Effect.as({ output: { value: "replacement" } }), + ), + }), + }, + { codemode: false }, + ) yield* Deferred.succeed(streamGate, undefined) yield* Fiber.join(run) @@ -991,7 +1036,7 @@ describe("SessionRunnerLLM", () => { { type: "tool", id: "call-reloaded", - state: { status: "completed", structured: { value: "advertised" } }, + state: { status: "completed", content: [{ type: "text", text: '{"value":"advertised"}' }] }, }, ], }, @@ -2377,7 +2422,6 @@ describe("SessionRunnerLLM", () => { state: { status: "completed", input: { query: "hello" }, - structured: {}, content: [ { type: "text", text: "Hello" }, { type: "file", mime: "image/png", uri: "data:image/png;base64,aGVsbG8=", name: "hello.png" }, @@ -2417,7 +2461,6 @@ describe("SessionRunnerLLM", () => { state: { status: "completed", input: { text: "hello" }, - structured: { text: "hello" }, content: [{ type: "text", text: "hello" }], }, }, @@ -2429,7 +2472,7 @@ describe("SessionRunnerLLM", () => { expect((yield* recordedStepSettlementEvents(sessionID, assistant.id)).map((event) => event.type)).toEqual([ "session.step.started.1", "session.tool.called.1", - "session.tool.success.1", + "session.tool.success.2", "session.step.ended.1", ]) }), @@ -2581,7 +2624,8 @@ describe("SessionRunnerLLM", () => { type: "tool-result", id: "hosted-search", name: "web_search", - result: { type: "json", value: [{ title: "Effect" }] }, + // The generic replay result derives from canonical stored content. + result: { type: "text", value: '[{"title":"Effect"}]' }, providerExecuted: true, providerMetadata: { openai: { blockType: "web_search_tool_result" } }, }, @@ -2667,7 +2711,7 @@ describe("SessionRunnerLLM", () => { { type: "tool", id: "tool_0", - state: { status: "completed", structured: { text: "first" }, content: [{ type: "text", text: "first" }] }, + state: { status: "completed", content: [{ type: "text", text: "first" }] }, }, ], }, @@ -2677,11 +2721,7 @@ describe("SessionRunnerLLM", () => { { type: "tool", id: "tool_0", - state: { - status: "completed", - structured: { text: "second" }, - content: [{ type: "text", text: "second" }], - }, + state: { status: "completed", content: [{ type: "text", text: "second" }] }, }, ], }, @@ -2697,7 +2737,7 @@ describe("SessionRunnerLLM", () => { { type: "tool", id: "tool_0", - state: { status: "completed", structured: { text: "first" }, content: [{ type: "text", text: "first" }] }, + state: { status: "completed", content: [{ type: "text", text: "first" }] }, }, ], }, @@ -2707,11 +2747,7 @@ describe("SessionRunnerLLM", () => { { type: "tool", id: "tool_0", - state: { - status: "completed", - structured: { text: "second" }, - content: [{ type: "text", text: "second" }], - }, + state: { status: "completed", content: [{ type: "text", text: "second" }] }, }, ], }, @@ -3404,7 +3440,7 @@ describe("SessionRunnerLLM", () => { expect((yield* recordedStepSettlementEvents(sessionID, assistant.id)).map((event) => event.type)).toEqual([ "session.step.started.1", "session.tool.called.1", - "session.tool.failed.1", + "session.tool.failed.2", "session.step.ended.1", ]) }), @@ -3414,17 +3450,20 @@ describe("SessionRunnerLLM", () => { Effect.gen(function* () { const session = yield* setup const registry = yield* ToolRegistry.Service - yield* registry.register({ - blocked: Tool.make({ - description: "Fail because policy blocked execution", - input: Schema.Struct({}), - output: Schema.Struct({}), - execute: () => - Effect.fail(new PermissionV2.BlockedError({ rules: [], permission: "blocked", resources: ["*"] })).pipe( - Effect.mapError(() => new Tool.Failure({ message: "Permission blocked" })), - ), - }), - }, { codemode: false }) + yield* registry.register( + { + blocked: Tool.make({ + description: "Fail because policy blocked execution", + input: Schema.Struct({}), + output: Schema.Struct({}), + execute: () => + Effect.fail(new PermissionV2.BlockedError({ rules: [], permission: "blocked", resources: ["*"] })).pipe( + Effect.mapError(() => new Tool.Failure({ message: "Permission blocked" })), + ), + }), + }, + { codemode: false }, + ) yield* admit(session, "Call blocked") responses = [reply.tool("call-blocked", "blocked", {}), reply.stop()] @@ -3449,14 +3488,17 @@ describe("SessionRunnerLLM", () => { Effect.gen(function* () { const session = yield* setup const registry = yield* ToolRegistry.Service - yield* registry.register({ - declined: Tool.make({ - description: "Fail because the user declined approval", - input: Schema.Struct({}), - output: Schema.Struct({}), - execute: () => Effect.die(new PermissionV2.DeclinedError()), - }), - }, { codemode: false }) + yield* registry.register( + { + declined: Tool.make({ + description: "Fail because the user declined approval", + input: Schema.Struct({}), + output: Schema.Struct({}), + execute: () => Effect.die(new PermissionV2.DeclinedError()), + }), + }, + { codemode: false }, + ) yield* admit(session, "Call declined") response = reply.tool("call-declined", "declined", {}) @@ -3486,17 +3528,20 @@ describe("SessionRunnerLLM", () => { Effect.gen(function* () { const session = yield* setup const registry = yield* ToolRegistry.Service - yield* registry.register({ - corrected: Tool.make({ - description: "Fail with user correction feedback", - input: Schema.Struct({}), - output: Schema.Struct({}), - execute: () => - Effect.fail(new PermissionV2.CorrectedError({ feedback: "Use another tool" })).pipe( - Effect.mapError(() => new Tool.Failure({ message: "Use another tool" })), - ), - }), - }, { codemode: false }) + yield* registry.register( + { + corrected: Tool.make({ + description: "Fail with user correction feedback", + input: Schema.Struct({}), + output: Schema.Struct({}), + execute: () => + Effect.fail(new PermissionV2.CorrectedError({ feedback: "Use another tool" })).pipe( + Effect.mapError(() => new Tool.Failure({ message: "Use another tool" })), + ), + }), + }, + { codemode: false }, + ) yield* admit(session, "Call corrected") responses = [reply.tool("call-corrected", "corrected", {}), reply.stop()] @@ -3540,13 +3585,13 @@ describe("SessionRunnerLLM", () => { status: "error", error: { type: "unknown", - message: expect.stringContaining("Failed to encode tool output"), + message: expect.stringContaining("Failed to write tool output"), }, }, }, ], finish: "error", - error: { type: "unknown", message: expect.stringContaining("Failed to encode tool output") }, + error: { type: "unknown", message: expect.stringContaining("Failed to write tool output") }, }, ]) }), @@ -3594,14 +3639,17 @@ describe("SessionRunnerLLM", () => { Effect.gen(function* () { const session = yield* setup const registry = yield* ToolRegistry.Service - yield* registry.register({ - question: Tool.make({ - description: "Ask the user", - input: Schema.Struct({}), - output: Schema.Struct({}), - execute: () => Effect.die(new QuestionTool.CancelledError()), - }), - }, { codemode: false }) + yield* registry.register( + { + question: Tool.make({ + description: "Ask the user", + input: Schema.Struct({}), + output: Schema.Struct({}), + execute: () => Effect.die(new QuestionTool.CancelledError()), + }), + }, + { codemode: false }, + ) yield* admit(session, "Ask then stop") responses = [reply.tool("call-question", "question", {}), []] @@ -3655,7 +3703,11 @@ describe("SessionRunnerLLM", () => { { type: "assistant", content: [ - { type: "tool", id: "call-before-failure", state: { status: "completed", structured: { text: "settle" } } }, + { + type: "tool", + id: "call-before-failure", + state: { status: "completed", content: [{ type: "text", text: "settle" }] }, + }, ], }, ]) @@ -3663,7 +3715,7 @@ describe("SessionRunnerLLM", () => { expect((yield* recordedStepSettlementEvents(sessionID, assistant.id)).map((event) => event.type)).toEqual([ "session.step.started.1", "session.tool.called.1", - "session.tool.success.1", + "session.tool.success.2", "session.step.failed.1", ]) }), @@ -3707,7 +3759,7 @@ describe("SessionRunnerLLM", () => { expect((yield* recordedStepSettlementEvents(sessionID, assistant.id)).map((event) => event.type)).toEqual([ "session.step.started.1", "session.tool.called.1", - "session.tool.failed.1", + "session.tool.failed.2", "session.step.failed.1", ]) @@ -3808,7 +3860,8 @@ describe("SessionRunnerLLM", () => { expect(requests).toHaveLength(2) expect(requests[0]?.toolChoice).toBeUndefined() expect(requests[1]?.toolChoice).toMatchObject({ type: "none" }) - expect(requests[1]?.tools).toEqual([]) + // Protocols with native "none" keep these definitions for prompt caching. + expect(requests[1]?.tools.map((tool) => tool.name)).toContain("echo") expect(requests[1]?.messages.at(-1)).toMatchObject({ role: "assistant", content: [{ type: "text", text: expect.stringContaining("MAXIMUM STEPS REACHED") }], @@ -3953,7 +4006,7 @@ describe("SessionRunnerLLM", () => { expect(events.map((event) => event.type)).toEqual([ "session.step.started.1", "session.tool.called.1", - "session.tool.success.1", + "session.tool.success.2", "session.step.failed.1", ]) expect( @@ -4146,7 +4199,8 @@ describe("SessionRunnerLLM", () => { content: [{ type: "text", text: expect.stringContaining("MAXIMUM STEPS REACHED") }], }) expect(requests[2]?.toolChoice).toMatchObject({ type: "none" }) - expect(requests[2]?.tools).toEqual([]) + // The final step keeps tool definitions to preserve provider prompt caching. + expect(requests[2]?.tools.map((tool) => tool.name)).toContain("echo") expect(requests[2]?.messages.at(-1)).toMatchObject({ role: "assistant", content: [{ type: "text", text: expect.stringContaining("MAXIMUM STEPS REACHED") }], @@ -4197,7 +4251,7 @@ describe("SessionRunnerLLM", () => { expect(yield* recordedStepSettlementEvents(sessionID, assistant.id)).toMatchObject([ { type: "session.step.started.1" }, { - type: "session.tool.failed.1", + type: "session.tool.failed.2", data: { callID: "call-malformed", error: { type: "provider.invalid-output", message: "Invalid JSON input for tool call echo" }, @@ -4292,7 +4346,7 @@ describe("SessionRunnerLLM", () => { expect(failed.error).toBeUndefined() expect((yield* recordedStepSettlementEvents(sessionID, failed.id)).map((event) => event.type)).toEqual([ "session.step.started.1", - "session.tool.failed.1", + "session.tool.failed.2", "session.step.ended.1", ]) const database = (yield* Database.Service).db @@ -4521,7 +4575,7 @@ describe("SessionRunnerLLM", () => { expect(requests).toHaveLength(2) expect(requests[0]?.toolChoice).toBeUndefined() expect(requests[1]?.toolChoice).toMatchObject({ type: "none" }) - expect((yield* recordedEventTypes(sessionID)).filter((type) => type === "session.tool.failed.1")).toHaveLength(2) + expect((yield* recordedEventTypes(sessionID)).filter((type) => type === "session.tool.failed.2")).toHaveLength(2) }), ) @@ -4553,7 +4607,7 @@ describe("SessionRunnerLLM", () => { expect((yield* recordedStepSettlementEvents(sessionID, assistant.id)).map((event) => event.type)).toEqual([ "session.step.started.1", "session.tool.called.1", - "session.tool.success.1", + "session.tool.success.2", "session.step.failed.1", ]) }), @@ -4585,7 +4639,7 @@ describe("SessionRunnerLLM", () => { expect((yield* recordedStepSettlementEvents(sessionID, assistant.id)).map((event) => event.type)).toEqual([ "session.step.started.1", "session.tool.called.1", - "session.tool.failed.1", + "session.tool.failed.2", "session.step.failed.1", ]) }), @@ -4609,7 +4663,7 @@ describe("SessionRunnerLLM", () => { expect(events.map((event) => event.type)).toEqual([ "session.step.started.1", "session.tool.called.1", - "session.tool.failed.1", + "session.tool.failed.2", "session.step.failed.1", ]) expect(events[2]?.data.error).toMatchObject({ type: "unknown", message: "unexpected tool defect" }) @@ -4646,7 +4700,7 @@ describe("SessionRunnerLLM", () => { expect(events.map((event) => event.type)).toEqual([ "session.step.started.1", "session.tool.called.1", - "session.tool.failed.1", + "session.tool.failed.2", "session.step.failed.1", ]) expect( @@ -4684,7 +4738,7 @@ describe("SessionRunnerLLM", () => { expect(events.map((event) => event.type)).toEqual([ "session.step.started.1", "session.tool.called.1", - "session.tool.failed.1", + "session.tool.failed.2", "session.step.ended.1", ]) expect( @@ -4721,8 +4775,8 @@ describe("SessionRunnerLLM", () => { { type: "session.step.started.1", callID: undefined }, { type: "session.tool.called.1", callID: "call-local-raw-failure" }, { type: "session.tool.called.1", callID: "call-hosted-raw-failure-pair" }, - { type: "session.tool.failed.1", callID: "call-local-raw-failure" }, - { type: "session.tool.failed.1", callID: "call-hosted-raw-failure-pair" }, + { type: "session.tool.failed.2", callID: "call-local-raw-failure" }, + { type: "session.tool.failed.2", callID: "call-hosted-raw-failure-pair" }, { type: "session.step.failed.1", callID: undefined }, ]) expect( @@ -4748,7 +4802,7 @@ describe("SessionRunnerLLM", () => { expect(events.map((event) => event.type)).toEqual([ "session.step.started.1", "session.tool.called.1", - "session.tool.failed.1", + "session.tool.failed.2", "session.step.failed.1", ]) expect( diff --git a/packages/core/test/session-tool-progress.test.ts b/packages/core/test/session-tool-progress.test.ts index cc81820e39..414e260e98 100644 --- a/packages/core/test/session-tool-progress.test.ts +++ b/packages/core/test/session-tool-progress.test.ts @@ -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)) }), ) }) diff --git a/packages/core/test/tool-edit.test.ts b/packages/core/test/tool-edit.test.ts index aaecf9766f..446e6aecf5 100644 --- a/packages/core/test/tool-edit.test.ts +++ b/packages/core/test/tool-edit.test.ts @@ -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([]) diff --git a/packages/core/test/tool-execute.test.ts b/packages/core/test/tool-execute.test.ts index 66254ec408..84df02243e 100644 --- a/packages/core/test/tool-execute.test.ts +++ b/packages/core/test/tool-execute.test.ts @@ -6,6 +6,69 @@ import { Session } from "@opencode-ai/schema/session" import { SessionMessage } from "@opencode-ai/schema/session-message" import { Effect, Schema } from "effect" +const context = { + sessionID: Session.ID.make("ses_execute"), + agent: Agent.ID.make("build"), + messageID: SessionMessage.ID.make("msg_execute"), + callID: "call_execute", + progress: () => Effect.void, +} + +test("canonical execution distinguishes declared, model-only, and raw schema outputs", async () => { + const declared = Tool.make({ + description: "Declared", + input: Schema.Struct({ value: Schema.String }), + output: Schema.Struct({ value: Schema.String }), + execute: ({ value }) => Effect.succeed({ output: { value } }), + }) + const modelOnly = Tool.make({ + description: "Model only", + input: Schema.Struct({}), + execute: () => Effect.succeed({ content: "visible only", metadata: { kind: "model" } }), + }) + const raw = Tool.make({ + description: "Raw", + input: {}, + output: {}, + execute: (input) => Effect.succeed({ output: input, content: "raw" }), + }) + + expect(await Effect.runPromise(Tool.execute(declared, { value: "encoded" }, context))).toEqual({ + output: { value: "encoded" }, + content: [{ type: "text", text: '{"value":"encoded"}' }], + }) + expect(await Effect.runPromise(Tool.execute(modelOnly, {}, context))).toEqual({ + content: [{ type: "text", text: "visible only" }], + metadata: { kind: "model" }, + }) + expect(await Effect.runPromise(Tool.execute(raw, { unchecked: true }, context))).toEqual({ + output: { unchecked: true }, + content: [{ type: "text", text: "raw" }], + }) +}) + +test("declared outputs cannot bypass validation and raw outputs stay JSON-compatible", async () => { + const missing: Tool.Any = { + description: "Missing output", + input: Schema.Struct({}), + output: Schema.String, + execute: () => Effect.succeed({ content: "not an output" }), + } + const invalid: Tool.Any = { + description: "Invalid raw output", + input: {}, + output: {}, + execute: () => Effect.succeed({ output: 1n, content: "not JSON" }), + } + + expect((await Effect.runPromiseExit(Tool.execute(missing, {}, context))).toString()).toContain( + "Tool did not return its declared output", + ) + expect((await Effect.runPromiseExit(Tool.execute(invalid, {}, context))).toString()).toContain( + "Tool returned a non-JSON value", + ) +}) + test("execute preserves successful results with visible unhandled rejections", async () => { const child = Tool.make({ description: "Always fail", @@ -13,27 +76,10 @@ test("execute preserves successful results with visible unhandled rejections", a output: Schema.String, execute: () => Effect.fail(new Tool.Failure({ message: "Lookup refused" })), }) - const execute = ExecuteTool.create(new Map([["fail", { tool: child, name: "fail" }]])) - const result = await Effect.runPromise( - Tool.settle( - execute, - { - type: "tool-call", - id: "call_execute", - name: "execute", - input: { code: `tools.fail({}); return "done"` }, - }, - { - sessionID: Session.ID.make("ses_execute"), - agent: Agent.ID.make("build"), - messageID: SessionMessage.ID.make("msg_execute"), - callID: "call_execute", - progress: () => Effect.void, - }, - ), - ) + const execute = ExecuteTool.create(new Map([["fail", { tool: child, name: "fail", permission: "fail" }]])) + const result = await Effect.runPromise(Tool.execute(execute, { code: `tools.fail({}); return "done"` }, context)) - expect(result.structured).toEqual({ toolCalls: [{ tool: "fail", status: "error" }] }) + expect(result.metadata).toEqual({ toolCalls: [{ tool: "fail", status: "error" }] }) expect(result.content).toEqual([ { type: "text", @@ -52,40 +98,32 @@ test("execute supports callable namespace tools", async () => { description: "Administer Slack", input: Schema.Struct({}), output: Schema.String, - execute: () => Effect.succeed("admin"), + execute: () => Effect.succeed({ output: "admin" }), }) const child = Tool.make({ description: "Create a Slack resource", input: Schema.Struct({}), output: Schema.String, - execute: () => Effect.succeed("created"), + execute: () => Effect.succeed({ output: "created" }), }) const execute = ExecuteTool.create( new Map([ - ["slack_admin", { tool: callable, name: "admin", namespace: "slack" }], - ["slack_admin_create", { tool: child, name: "create", namespace: "slack.admin" }], + ["slack_admin", { tool: callable, name: "admin", namespace: "slack", permission: "slack_admin" }], + [ + "slack_admin_create", + { tool: child, name: "create", namespace: "slack.admin", permission: "slack_admin_create" }, + ], ]), ) const result = await Effect.runPromise( - Tool.settle( + Tool.execute( execute, - { - type: "tool-call", - id: "call_execute", - name: "execute", - input: { code: "return [await tools.slack.admin({}), await tools.slack.admin.create({})]" }, - }, - { - sessionID: Session.ID.make("ses_execute"), - agent: Agent.ID.make("build"), - messageID: SessionMessage.ID.make("msg_execute"), - callID: "call_execute", - progress: () => Effect.void, - }, + { code: "return [await tools.slack.admin({}), await tools.slack.admin.create({})]" }, + context, ), ) - expect(result.structured).toEqual({ + expect(result.metadata).toEqual({ toolCalls: [ { tool: "slack.admin", status: "completed" }, { tool: "slack.admin.create", status: "completed" }, diff --git a/packages/core/test/tool-output-store.test.ts b/packages/core/test/tool-output-store.test.ts index d8a872ce60..2e22402e10 100644 --- a/packages/core/test/tool-output-store.test.ts +++ b/packages/core/test/tool-output-store.test.ts @@ -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) }), diff --git a/packages/core/test/tool-patch.test.ts b/packages/core/test/tool-patch.test.ts index 86ae95e183..cfae81eef8 100644 --- a/packages/core/test/tool-patch.test.ts +++ b/packages/core/test/tool-patch.test.ts @@ -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 = ( const activeLocation = Layer.succeed( Location.Service, Location.Service.of( - location( - { directory: AbsolutePath.make(directory) }, - { projectDirectory: AbsolutePath.make(projectDirectory) }, - ), + location({ directory: AbsolutePath.make(directory) }, { projectDirectory: AbsolutePath.make(projectDirectory) }), ), ) return Effect.gen(function* () { return yield* body(yield* ToolRegistry.Service) }).pipe( Effect.provide( - AppNodeBuilder.build( - LayerNode.group([ - ToolRegistry.node, - ToolRegistry.toolsNode, - patchToolNode, - ]), - [ - [FSUtil.node, filesystem], - [Location.node, activeLocation], - [PermissionV2.node, permission], - [ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig], - ], - ), + AppNodeBuilder.build(LayerNode.group([ToolRegistry.node, ToolRegistry.toolsNode, patchToolNode]), [ + [FSUtil.node, filesystem], + [Location.node, activeLocation], + [PermissionV2.node, permission], + [ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig], + ]), ), ) } @@ -162,18 +152,23 @@ describe("PatchTool", () => { withTool(tmp.path, (registry) => Effect.gen(function* () { expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["patch"]) - const settled = yield* settleTool( + const settled = yield* executeTool( registry, call( "*** Begin Patch\n*** Add File: nested/new.txt\n+created\n*** Update File: update.txt\n@@\n-before\n+after\n*** Delete File: remove.txt\n*** End Patch", ), ) - expect(settled.result).toEqual({ - type: "text", - value: "Success. Updated the following files:\nA nested/new.txt\nM update.txt\nD remove.txt", - }) - if (process.platform === "win32") expect(settled.result.value).not.toContain("\\") - expect(settled.output?.structured).toMatchObject({ + expect(settled.status).toBe("completed") + if (settled.status !== "completed") return + expect(settled.content).toEqual([ + { + type: "text", + text: "Success. Updated the following files:\nA nested/new.txt\nM update.txt\nD remove.txt", + }, + ]) + const modelText = settled.content[0]?.type === "text" ? settled.content[0].text : "" + if (process.platform === "win32") expect(modelText).not.toContain("\\") + expect(settled.output).toMatchObject({ applied: [ { type: "add", resource: "nested/new.txt" }, { type: "update", resource: "update.txt" }, @@ -248,9 +243,11 @@ describe("PatchTool", () => { "*** Begin Patch\n*** Add File: created.txt\n+created\n*** Update File: old.txt\n*** Move to: moved.txt\n@@\n-before\n+after\n*** End Patch", ), ), - ).toEqual({ - type: "text", - value: "Success. Updated the following files:\nA created.txt\nM moved.txt", + ).toMatchObject({ + status: "completed", + content: [ + { type: "text", text: "Success. Updated the following files:\nA created.txt\nM moved.txt" }, + ], }) expect(yield* exists(source)).toBe(false) expect(yield* Effect.promise(() => fs.readFile(path.join(tmp.path, "moved.txt"), "utf8"))).toBe( @@ -278,7 +275,9 @@ describe("PatchTool", () => { return Effect.promise(() => Promise.all([ fs.writeFile(source, "before\n"), - fs.mkdir(path.dirname(destination), { recursive: true }).then(() => fs.writeFile(destination, "existing\n")), + fs + .mkdir(path.dirname(destination), { recursive: true }) + .then(() => fs.writeFile(destination, "existing\n")), ]), ).pipe( Effect.andThen( @@ -291,7 +290,7 @@ describe("PatchTool", () => { "*** Begin Patch\n*** Update File: old.txt\n*** Move to: nested/moved.txt\n@@\n-before\n+after\n*** End Patch", ), ), - ).toMatchObject({ type: "text" }) + ).toMatchObject({ status: "completed" }) expect(yield* exists(source)).toBe(false) expect(yield* Effect.promise(() => fs.readFile(destination, "utf8"))).toBe("after\n") }), @@ -325,19 +324,21 @@ describe("PatchTool", () => { ), ) - it.live("includes move file info in structured output", () => + it.live("includes move file info in output and metadata", () => withTempTool((directory, registry) => Effect.gen(function* () { const source = path.join(directory, "old", "name.txt") yield* Effect.promise(() => fs.mkdir(path.dirname(source), { recursive: true })) yield* Effect.promise(() => fs.writeFile(source, "old content\n")) - const settled = yield* settleTool( + const settled = yield* executeTool( registry, call( "*** Begin Patch\n*** Update File: old/name.txt\n*** Move to: renamed/dir/name.txt\n@@\n-old content\n+new content\n*** End Patch", ), ) - expect(settled.output?.structured).toMatchObject({ + expect(settled.status).toBe("completed") + if (settled.status !== "completed") return + expect(settled.output).toMatchObject({ applied: [{ type: "update", resource: "renamed/dir/name.txt" }], files: [ { @@ -393,7 +394,7 @@ describe("PatchTool", () => { yield* Effect.promise(() => fs.mkdir(path.join(directory, "dir"))) expect( yield* executeTool(registry, call("*** Begin Patch\n*** Delete File: dir\n*** End Patch")), - ).toMatchObject({ type: "error" }) + ).toMatchObject({ status: "error" }) expect(yield* exists(path.join(directory, "dir"))).toBe(true) }), ), @@ -407,11 +408,9 @@ describe("PatchTool", () => { expect( yield* executeTool( registry, - call( - "*** Begin Patch\n*** Update File: two-chunks.txt\n@@\n-b\n+B\n\n-d\n+D\n*** End Patch", - ), + call("*** Begin Patch\n*** Update File: two-chunks.txt\n@@\n-b\n+B\n\n-d\n+D\n*** End Patch"), ), - ).toMatchObject({ type: "error" }) + ).toMatchObject({ status: "error" }) expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("a\nb\nc\nd\n") }), ), @@ -420,7 +419,10 @@ describe("PatchTool", () => { it.live("requires patchText", () => withTempTool((_directory, registry) => Effect.gen(function* () { - expect(yield* executeTool(registry, call(""))).toEqual({ type: "error", value: "patchText is required" }) + expect(yield* executeTool(registry, call(""))).toEqual({ + status: "error", + error: { type: "tool.execution", message: "patchText is required" }, + }) }), ), ) @@ -429,12 +431,18 @@ describe("PatchTool", () => { withTempTool((_directory, registry) => Effect.gen(function* () { expect(yield* executeTool(registry, call("invalid patch"))).toEqual({ - type: "error", - value: "patch verification failed: The first line of the patch must be '*** Begin Patch'", + status: "error", + error: { + type: "tool.execution", + message: "patch verification failed: The first line of the patch must be '*** Begin Patch'", + }, }) expect(yield* executeTool(registry, call("*** Begin Patch\n*** Add File: foo\n+hello"))).toEqual({ - type: "error", - value: "patch verification failed: The last line of the patch must be '*** End Patch'", + status: "error", + error: { + type: "tool.execution", + message: "patch verification failed: The last line of the patch must be '*** End Patch'", + }, }) }), ), @@ -444,8 +452,8 @@ describe("PatchTool", () => { withTempTool((_directory, registry) => Effect.gen(function* () { expect(yield* executeTool(registry, call("*** Begin Patch\n*** End Patch"))).toEqual({ - type: "error", - value: "patch rejected: empty patch", + status: "error", + error: { type: "tool.execution", message: "patch rejected: empty patch" }, }) }), ), @@ -454,15 +462,13 @@ describe("PatchTool", () => { it.live("rejects an invalid hunk header", () => withTempTool((_directory, registry) => Effect.gen(function* () { - expect( - yield* executeTool( - registry, - call("*** Begin Patch\n*** Frobnicate File: foo\n*** End Patch"), - ), - ).toEqual({ - type: "error", - value: - "patch verification failed: Invalid hunk at line 2: '*** Frobnicate File: foo' is not a valid hunk header. Valid hunk headers: '*** Add File: {path}', '*** Delete File: {path}', '*** Update File: {path}'", + expect(yield* executeTool(registry, call("*** Begin Patch\n*** Frobnicate File: foo\n*** End Patch"))).toEqual({ + status: "error", + error: { + type: "tool.execution", + message: + "patch verification failed: Invalid hunk at line 2: '*** Frobnicate File: foo' is not a valid hunk header. Valid hunk headers: '*** Add File: {path}', '*** Delete File: {path}', '*** Update File: {path}'", + }, }) }), ), @@ -490,13 +496,13 @@ describe("PatchTool", () => { const bom = "\uFEFF" const target = path.join(directory, "example.cs") yield* Effect.promise(() => fs.writeFile(target, `${bom}using System;\n\nclass Test {}\n`)) - const settled = yield* settleTool( + const settled = yield* executeTool( registry, - call( - "*** Begin Patch\n*** Update File: example.cs\n@@\n class Test {}\n+class Next {}\n*** End Patch", - ), + call("*** Begin Patch\n*** Update File: example.cs\n@@\n class Test {}\n+class Next {}\n*** End Patch"), ) - const output = Schema.decodeUnknownSync(PatchTool.Output)(settled.output?.structured) + expect(settled.status).toBe("completed") + if (settled.status !== "completed") return + const output = Schema.decodeUnknownSync(PatchTool.Output)(settled.output) expect(output.files[0]?.patch).not.toContain(bom) expect(output.files[0]?.patch).not.toContain("-using System;") expect(output.files[0]?.patch).not.toContain("+using System;") @@ -517,7 +523,10 @@ describe("PatchTool", () => { registry, call("*** Begin Patch\n*** Update File: unchanged.txt\n@@\n-missing\n+changed\n*** End Patch"), ), - ).toMatchObject({ type: "error", value: expect.stringContaining("Failed to find expected lines") }) + ).toMatchObject({ + status: "error", + error: { message: expect.stringContaining("Failed to find expected lines") }, + }) expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("line1\nline2\n") }), ), @@ -532,10 +541,12 @@ describe("PatchTool", () => { call("*** Begin Patch\n*** Update File: missing.txt\n@@\n-old\n+new\n*** End Patch"), ), ).toMatchObject({ - type: "error", - value: expect.stringContaining( - `patch verification failed: Failed to read file to update ${path.join(directory, "missing.txt")}: `, - ), + status: "error", + error: { + message: expect.stringContaining( + `patch verification failed: Failed to read file to update ${path.join(directory, "missing.txt")}: `, + ), + }, }) }), ), @@ -548,8 +559,11 @@ describe("PatchTool", () => { expect( yield* executeTool(registry, call("*** Begin Patch\n*** Update File: nested\n@@\n-old\n+new\n*** End Patch")), ).toEqual({ - type: "error", - value: `patch verification failed: Failed to read file to update ${path.join(directory, "nested")}: path is a directory`, + status: "error", + error: { + type: "tool.execution", + message: `patch verification failed: Failed to read file to update ${path.join(directory, "nested")}: path is a directory`, + }, }) }), ), @@ -560,7 +574,7 @@ describe("PatchTool", () => { Effect.gen(function* () { expect( yield* executeTool(registry, call("*** Begin Patch\n*** Delete File: missing.txt\n*** End Patch")), - ).toMatchObject({ type: "error", value: expect.stringContaining("patch verification failed") }) + ).toMatchObject({ status: "error", error: { message: expect.stringContaining("patch verification failed") } }) }), ), ) @@ -580,7 +594,7 @@ describe("PatchTool", () => { registry, call(`*** Begin Patch\n*** Update File: ${target}\n@@\n-before\n+after\n*** End Patch`), ), - ).toMatchObject({ type: "text" }) + ).toMatchObject({ status: "completed" }) expect(assertions.map((input) => input.action)).toEqual(["external_directory", "edit"]) expect(readsBeforeEditApproval).toBe(1) expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("after\n") @@ -614,7 +628,7 @@ describe("PatchTool", () => { registry, call(`*** Begin Patch\n*** Update File: ${target}\n@@\n-before\n+after\n*** End Patch`), ), - ).toMatchObject({ type: "error" }) + ).toMatchObject({ status: "error" }) expect(assertions.map((input) => input.action)).toEqual(["external_directory"]) expect(readsBeforeEditApproval).toBe(0) expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("before\n") @@ -649,7 +663,7 @@ describe("PatchTool", () => { registry, call("*** Begin Patch\n*** Update File: ../sibling.txt\n@@\n-before\n+after\n*** End Patch"), ), - ).toMatchObject({ type: "text" }) + ).toMatchObject({ status: "completed" }) expect(assertions.map((input) => input.action)).toEqual(["edit"]) expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("after\n") }), @@ -680,7 +694,7 @@ describe("PatchTool", () => { registry, call("*** Begin Patch\n*** Update File: link.txt\n@@\n-before\n+after\n*** End Patch"), ), - ).toMatchObject({ type: "text" }) + ).toMatchObject({ status: "completed" }) expect(assertions.map((input) => input.action)).toEqual(["edit"]) expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("after\n") }), @@ -711,7 +725,7 @@ describe("PatchTool", () => { registry, call(`*** Begin Patch\n*** Update File: ${relative}\n@@\n-before\n+after\n*** End Patch`), ), - ).toMatchObject({ type: "text" }) + ).toMatchObject({ status: "completed" }) expect(assertions.map((input) => input.action)).toEqual(["external_directory", "edit"]) expect(readsBeforeEditApproval).toBe(1) expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("after\n") @@ -747,7 +761,7 @@ describe("PatchTool", () => { `*** Begin Patch\n*** Update File: ${first}\n@@\n-before\n+after\n*** Update File: ${second}\n@@\n-before\n+after\n*** End Patch`, ), ), - ).toMatchObject({ type: "text" }) + ).toMatchObject({ status: "completed" }) expect(assertions.map((input) => input.action)).toEqual([ "external_directory", "external_directory", @@ -786,8 +800,10 @@ describe("PatchTool", () => { ), ), ).toMatchObject({ - type: "error", - value: expect.stringContaining("patch verification failed: Failed to read file to update"), + status: "error", + error: { + message: expect.stringContaining("patch verification failed: Failed to read file to update"), + }, }) expect(yield* exists(path.join(tmp.path, "created.txt"))).toBe(false) }), @@ -812,7 +828,7 @@ describe("PatchTool", () => { registry, call("*** Begin Patch\n*** Add File: existing.txt\n+replacement\n*** End Patch"), ), - ).toMatchObject({ type: "text" }) + ).toMatchObject({ status: "completed" }) expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("replacement\n") }), ), @@ -837,7 +853,7 @@ describe("PatchTool", () => { registry, call("*** Begin Patch\n*** Add File: appeared.txt\n+replacement\n*** End Patch"), ), - ).toMatchObject({ type: "text" }) + ).toMatchObject({ status: "completed" }) expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("replacement\n") }), ) @@ -876,5 +892,4 @@ describe("PatchTool", () => { (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), ), ) - }) diff --git a/packages/core/test/tool-question.test.ts b/packages/core/test/tool-question.test.ts index 002806ec49..9a72bca1b5 100644 --- a/packages/core/test/tool-question.test.ts +++ b/packages/core/test/tool-question.test.ts @@ -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({ diff --git a/packages/core/test/tool-read.test.ts b/packages/core/test/tool-read.test.ts index 6d309e3834..6b3d084a10 100644 --- a/packages/core/test/tool-read.test.ts +++ b/packages/core/test/tool-read.test.ts @@ -22,7 +22,7 @@ import { ReadToolFileSystem } from "@opencode-ai/core/tool/read-filesystem" import { makeLocationNode } from "@opencode-ai/util/effect/app-node" import { SessionInstructions } from "@opencode-ai/core/session/instructions" import { testEffect } from "./lib/effect" -import { toolIdentity, executeTool, registerToolPlugin, settleTool, toolDefinitions } from "./lib/tool" +import { toolIdentity, executeTool, registerToolPlugin, toolDefinitions } from "./lib/tool" const readToolNode = makeLocationNode({ name: "test/read-tool-plugin", @@ -199,21 +199,19 @@ describe("ReadTool", () => { expect(yield* toolDefinitions(registry)).toMatchObject([{ name: "read" }]) expect(yield* toolDefinitions(registry, [{ action: "read", resource: "*", effect: "deny" }])).toEqual([]) - expect( - yield* executeTool(registry, { - sessionID, - ...toolIdentity, - call: { type: "tool-call", id: "call-read", name: "read", input: { path: "README.md" } }, - }), - ).toEqual({ - type: "json", - value: { - uri: "file:///README.md", - name: "README.md", - content: "hello", - encoding: "utf8", - mime: "text/plain", - }, + const execution = yield* executeTool(registry, { + sessionID, + ...toolIdentity, + call: { type: "tool-call", id: "call-read", name: "read", input: { path: "README.md" } }, + }) + expect(execution.status).toBe("completed") + if (execution.status !== "completed") return + expect(execution.output).toEqual({ + uri: "file:///README.md", + name: "README.md", + content: "hello", + encoding: "utf8", + mime: "text/plain", }) expect(assertions).toMatchObject([{ sessionID, action: "read", resources: ["README.md"], save: ["*"] }]) expect(readCalls).toEqual([ @@ -236,7 +234,7 @@ describe("ReadTool", () => { ...toolIdentity, call: { type: "tool-call", id: "call-external-read", name: "read", input: { path: external } }, }), - ).toMatchObject({ type: "json" }) + ).toMatchObject({ status: "completed" }) expect(assertions).toMatchObject([ { sessionID, @@ -261,19 +259,17 @@ describe("ReadTool", () => { } const registry = yield* ToolRegistry.Service - expect( - yield* executeTool(registry, { - sessionID, - ...toolIdentity, - call: { type: "tool-call", id: "call-image", name: "read", input: { path: "pixel.png" } }, - }), - ).toEqual({ - type: "content", - value: [ - { type: "text", text: "Image read successfully" }, - { type: "file", uri: `data:image/png;base64,${png}`, mime: "image/png", name: "pixel.png" }, - ], + const execution = yield* executeTool(registry, { + sessionID, + ...toolIdentity, + call: { type: "tool-call", id: "call-image", name: "read", input: { path: "pixel.png" } }, }) + expect(execution.status).toBe("completed") + if (execution.status !== "completed") return + expect(execution.content).toEqual([ + { type: "text", text: "Image read successfully" }, + { type: "file", uri: `data:image/png;base64,${png}`, mime: "image/png", name: "pixel.png" }, + ]) expect(readCalls).toEqual([ { input: AbsolutePath.make(path.join(process.cwd(), "pixel.png")), @@ -281,21 +277,17 @@ describe("ReadTool", () => { }, ]) - const settled = yield* settleTool(registry, { + const settled = yield* executeTool(registry, { sessionID, ...toolIdentity, call: { type: "tool-call", id: "call-image-settle", name: "read", input: { path: "pixel.png" } }, }) - expect(settled.output?.structured).toMatchObject({ - uri: "file:///pixel.png", - name: "pixel.png", - mime: "image/png", - encoding: "base64", - // Image base64 is carried by the content file item only; structured is slimmed - // so the original bytes are never persisted twice. - content: "", - }) - expect(settled.output?.content).toMatchObject([ + expect(settled.status).toBe("completed") + if (settled.status !== "completed") return + // Image base64 is carried by the content file item only; read produces no + // metadata, so the original bytes are never persisted twice. + expect(settled.metadata).toBeUndefined() + expect(settled.content).toMatchObject([ { type: "text", text: "Image read successfully" }, { type: "file", mime: "image/png", uri: `data:image/png;base64,${png}` }, ]) @@ -319,26 +311,25 @@ describe("ReadTool", () => { } const registry = yield* ToolRegistry.Service - const settled = yield* settleTool(registry, { + const settled = yield* executeTool(registry, { sessionID, ...toolIdentity, call: { type: "tool-call", id: "call-large-image", name: "read", input: { path: "large.png" } }, }) expect(settled.outputPaths).toBeUndefined() - expect(settled.output?.structured).toMatchObject({ + expect(settled.status).toBe("completed") + if (settled.status !== "completed") return + expect(settled.output).toMatchObject({ uri: "file:///large.png", name: "large.png", mime: "image/png", encoding: "base64", }) - expect(settled.result).toEqual({ - type: "content", - value: [ - { type: "text", text: "Image read successfully" }, - { type: "file", uri: `data:image/png;base64,${png}`, mime: "image/png", name: "large.png" }, - ], - }) + expect(settled.content).toEqual([ + { type: "text", text: "Image read successfully" }, + { type: "file", uri: `data:image/png;base64,${png}`, mime: "image/png", name: "large.png" }, + ]) }), ) @@ -361,13 +352,13 @@ describe("ReadTool", () => { call: { type: "tool-call", id: "call-image-fallback", name: "read", input: { path: "pixel.png" } }, }), ).toMatchObject({ - type: "content", - value: [{ type: "text" }, { type: "file", uri: `data:image/png;base64,${png}`, mime: "image/png" }], + status: "completed", + content: [{ type: "text" }, { type: "file", uri: `data:image/png;base64,${png}`, mime: "image/png" }], }) }), ) - it.effect("drops undecodable image data at settlement", () => + it.effect("drops undecodable image data from the outcome", () => Effect.gen(function* () { readResult = { uri: "file:///truncated.png", @@ -384,9 +375,9 @@ describe("ReadTool", () => { ...toolIdentity, call: { type: "tool-call", id: "call-truncated-image", name: "read", input: { path: "truncated.png" } }, }), - ).toEqual({ - type: "content", - value: [ + ).toMatchObject({ + status: "completed", + content: [ { type: "text", text: "Image read successfully" }, { type: "text", text: "[1 image omitted: could not be decoded.]" }, ], @@ -394,7 +385,7 @@ describe("ReadTool", () => { }), ) - it.effect("drops oversized images at settlement when resizing is disabled", () => + it.effect("drops oversized images from the outcome when resizing is disabled", () => Effect.gen(function* () { const photon = yield* Effect.promise(() => import("@silvia-odwyer/photon-node")) const source = new photon.PhotonImage(new Uint8Array(Array.from({ length: 16 * 4 }, () => 255)), 16, 1) @@ -425,9 +416,9 @@ describe("ReadTool", () => { ...toolIdentity, call: { type: "tool-call", id: "call-wide-image", name: "read", input: { path: "wide.png" } }, }), - ).toEqual({ - type: "content", - value: [ + ).toMatchObject({ + status: "completed", + content: [ { type: "text", text: "Image read successfully" }, { type: "text", text: "[1 image omitted: could not be resized below the image size limit.]" }, ], @@ -463,9 +454,9 @@ describe("ReadTool", () => { call: { type: "tool-call", id: "call-resize-image", name: "read", input: { path: "wide.png" } }, }) - expect(result.type).toBe("content") - if (result.type !== "content") return - const media = result.value[1] + expect(result.status).toBe("completed") + if (result.status !== "completed") return + const media = result.content[1] expect(media?.type).toBe("file") if (media?.type !== "file") return const resized = photon.PhotonImage.new_from_byteslice(Buffer.from(media.uri.split(",")[1] ?? "", "base64")) @@ -503,9 +494,9 @@ describe("ReadTool", () => { ...toolIdentity, call: { type: "tool-call", id: "call-max-bytes", name: "read", input: { path: "pixel.png" } }, }), - ).toEqual({ - type: "content", - value: [ + ).toMatchObject({ + status: "completed", + content: [ { type: "text", text: "Image read successfully" }, { type: "text", text: "[1 image omitted: could not be resized below the image size limit.]" }, ], @@ -532,8 +523,8 @@ describe("ReadTool", () => { call: { type: "tool-call", id: "call-disguised-image", name: "read", input: { path: "pixel.bin" } }, }), ).toMatchObject({ - type: "content", - value: [{ type: "text" }, { type: "file", mime: "image/png", name: "pixel.bin" }], + status: "completed", + content: [{ type: "text" }, { type: "file", mime: "image/png", name: "pixel.bin" }], }) }), ) @@ -554,7 +545,7 @@ describe("ReadTool", () => { input: { path: "archive.dat", offset: 2, limit: 1 }, }, }), - ).toEqual({ type: "error", value: "Cannot read binary file: archive.dat" }) + ).toEqual({ status: "error", error: { type: "unknown", message: "Cannot read binary file: archive.dat" } }) expect(readCalls).toEqual([ { input: AbsolutePath.make(path.join(process.cwd(), "archive.dat")), page: { offset: 2, limit: 1 } }, ]) @@ -589,7 +580,7 @@ describe("ReadTool", () => { ...toolIdentity, call: { type: "tool-call", id: "call-read", name: "read", input: { path: "README.md" } }, }), - ).toEqual({ type: "error", value: "Unable to read README.md" }) + ).toEqual({ status: "error", error: { type: "permission.rejected", message: "Permission denied: read" } }) expect(readCalls).toEqual([]) }), ) @@ -604,7 +595,9 @@ describe("ReadTool", () => { ...toolIdentity, call: { type: "tool-call", id: "call-missing-path", name: "read", input: { path: missingPath } }, }), - ).toEqual({ type: "error", value: `Unable to read ${missingPath}` }) + // The message-less PathError cause must not erase the tool's curated + // failure message; the canonical error is the sole authority. + ).toEqual({ status: "error", error: { type: "tool.execution", message: `Unable to read ${missingPath}` } }) expect(assertions).toEqual([]) expect(readCalls).toEqual([]) }), @@ -626,7 +619,7 @@ describe("ReadTool", () => { input: { path: "src", offset: 2, limit: 10 }, }, }), - ).toEqual({ type: "json", value: { entries: [], truncated: false } }) + ).toMatchObject({ status: "completed", output: { entries: [], truncated: false } }) expect(assertions).toMatchObject([{ sessionID, action: "read", resources: ["src"], save: ["*"] }]) expect(listCalls).toEqual([{ offset: 2, limit: 10 }]) }), @@ -644,7 +637,7 @@ describe("ReadTool", () => { ...toolIdentity, call: { type: "tool-call", id: "call-read-directory-denied", name: "read", input: { path: "src" } }, }), - ).toEqual({ type: "error", value: "Unable to read src" }) + ).toEqual({ status: "error", error: { type: "permission.rejected", message: "Permission denied: read" } }) expect(listCalls).toEqual([]) }), ) @@ -691,9 +684,9 @@ describe("ReadTool", () => { input: { path: "large.txt", offset: 2, limit: 1 }, }, }), - ).toEqual({ - type: "json", - value: { type: "text-page", content: "hello", mime: "text/plain", offset: 2, truncated: true, next: 3 }, + ).toMatchObject({ + status: "completed", + output: { type: "text-page", content: "hello", mime: "text/plain", offset: 2, truncated: true, next: 3 }, }) expect(readCalls).toEqual([ { input: AbsolutePath.make(path.join(process.cwd(), "large.txt")), page: { offset: 2, limit: 1 } }, @@ -718,7 +711,7 @@ describe("ReadTool", () => { ...toolIdentity, call: { type: "tool-call", id: "call-direct-binary", name: "read", input: { path: "late-binary" } }, }), - ).toEqual({ type: "error", value: "Cannot read binary file: late-binary" }) + ).toEqual({ status: "error", error: { type: "unknown", message: "Cannot read binary file: late-binary" } }) }), ) }) diff --git a/packages/core/test/tool-search.test.ts b/packages/core/test/tool-search.test.ts index 4367d1b82e..9958b0dfb4 100644 --- a/packages/core/test/tool-search.test.ts +++ b/packages/core/test/tool-search.test.ts @@ -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]()), diff --git a/packages/core/test/tool-shell.test.ts b/packages/core/test/tool-shell.test.ts index 039680a8d1..69af78d569 100644 --- a/packages/core/test/tool-shell.test.ts +++ b/packages/core/test/tool-shell.test.ts @@ -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() - yield* settleTool(registry, { + const observed = yield* Deferred.make() + yield* executeTool(registry, { ...call( { command: progressOverflowCommand(ShellTool.MAX_CAPTURE_BYTES + 1024, release) }, "call-progress", ), progress: (update) => Effect.gen(function* () { - if (update.structured.truncated !== true) return - const content = update.content[0] - if (content?.type !== "text") return - if (content.text.indexOf("\n\n[output truncated; full output saved to:") !== ShellTool.MAX_CAPTURE_BYTES) - return - yield* Deferred.succeed(observed, update) + if (typeof update.shellID !== "string") return + yield* Deferred.succeed(observed, update.shellID) yield* Effect.promise(() => fs.writeFile(releasePath, "")) }), }) - const progress = yield* Deferred.await(observed) - expect(progress.structured).toEqual({ truncated: true }) - const content = progress.content[0] - expect(content?.type).toBe("text") - if (content?.type !== "text") return - expect(content.text.indexOf("\n\n[output truncated; full output saved to:")).toBe( - ShellTool.MAX_CAPTURE_BYTES, - ) + expect(yield* Deferred.await(observed)).toMatch(/^sh_/) }).pipe(Effect.ensuring(Effect.promise(() => fs.writeFile(releasePath, "")).pipe(Effect.ignore))), ) }, @@ -466,7 +458,7 @@ describe("ShellTool", () => { ) it.live( - "does not repeat unchanged shell progress", + "does not repeat shell ID progress", () => Effect.acquireUseRelease( Effect.promise(() => tmpdir()), @@ -475,16 +467,12 @@ describe("ShellTool", () => { return withSession(tmp.path, (registry) => Effect.gen(function* () { const updates: ToolRegistry.Progress[] = [] - yield* settleTool(registry, { + yield* executeTool(registry, { ...call({ command: steadyProgressCommand }, "call-steady-progress"), progress: (update) => Effect.sync(() => updates.push(update)), }) - expect(updates).toEqual([ - { - structured: { truncated: false }, - content: [{ type: "text", text: "steady" }], - }, - ]) + expect(updates).toHaveLength(1) + expect(updates[0]?.shellID).toMatch(/^sh_/) }), ) }, @@ -493,18 +481,18 @@ describe("ShellTool", () => { { timeout: 10_000 }, ) - it.live("returns a useful timeout settlement", () => + it.live("returns a useful timeout outcome", () => Effect.acquireUseRelease( Effect.promise(() => tmpdir()), (tmp) => { reset() return withSession(tmp.path, (registry) => - settleTool(registry, call({ command: idleCommand, timeout: 50 })), + executeTool(registry, call({ command: idleCommand, timeout: 50 })), ).pipe( Effect.andThen((settled) => Effect.sync(() => { - expect(settled.output?.structured).toMatchObject({ timeout: true, truncated: false }) - expect(settled.output?.content[1]).toMatchObject({ + expect(settled.metadata).toMatchObject({ timeout: true, truncated: false }) + expect(settled.content?.[1]).toMatchObject({ type: "text", text: expect.stringContaining("Command timed out"), }) @@ -529,10 +517,9 @@ describe("ShellTool", () => { Stream.runHead, Effect.forkScoped({ startImmediately: true }), ) - const settled = yield* settleTool(registry, call({ command: idleCommand, timeout: 50, background: true })) - const structured = settled.output?.structured as Record | 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 | 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 | 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 | 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"), }) diff --git a/packages/core/test/tool-skill.test.ts b/packages/core/test/tool-skill.test.ts index 72f5565dee..eab58f75d7 100644 --- a/packages/core/test/tool-skill.test.ts +++ b/packages/core/test/tool-skill.test.ts @@ -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)) }), ), diff --git a/packages/core/test/tool-subagent.test.ts b/packages/core/test/tool-subagent.test.ts index 8c70525a9a..8ec8ecec16 100644 --- a/packages/core/test/tool-subagent.test.ts +++ b/packages/core/test/tool-subagent.test.ts @@ -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(` { 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 " ToolFailure to its cause message. expect(yield* executeTool(registry, call({ url: "file:///etc/passwd", format: "text" }))).toEqual({ - type: "error", - value: "Unable to fetch file:///etc/passwd", + status: "error", + error: { type: "unknown", message: "URL must use http:// or https://" }, }) expect(assertions).toEqual([]) expect(requests).toEqual([]) @@ -178,13 +178,13 @@ describe("WebFetchTool registration", () => { ) const registry = yield* ToolRegistry.Service - expect(yield* executeTool(registry, call({ url: "https://1.1.1.1", format: "markdown" }))).toEqual({ - type: "text", - value: "# Hello\n\nworld", + expect(yield* executeTool(registry, call({ url: "https://1.1.1.1", format: "markdown" }))).toMatchObject({ + status: "completed", + content: [{ type: "text", text: "# Hello\n\nworld" }], }) - expect(yield* executeTool(registry, call({ url: "https://1.1.1.1", format: "text" }))).toEqual({ - type: "text", - value: "Helloworld", + expect(yield* executeTool(registry, call({ url: "https://1.1.1.1", format: "text" }))).toMatchObject({ + status: "completed", + content: [{ type: "text", text: "Helloworld" }], }) }), ) @@ -201,9 +201,9 @@ describe("WebFetchTool registration", () => { const registry = yield* ToolRegistry.Service const url = "https://1.1.1.1/deep-html" - expect(yield* executeTool(registry, call({ url, format: "markdown" }))).toEqual({ - type: "error", - value: `Unable to fetch ${url}`, + expect(yield* executeTool(registry, call({ url, format: "markdown" }))).toMatchObject({ + status: "error", + error: { type: "unknown" }, }) }), ) @@ -219,8 +219,11 @@ describe("WebFetchTool registration", () => { }), ) expect(yield* executeTool(registry, call({ url: "https://1.1.1.1/declared", format: "text" }))).toEqual({ - type: "error", - value: "Unable to fetch https://1.1.1.1/declared", + status: "error", + error: { + type: "unknown", + message: `Response too large (exceeds ${WebFetchTool.MAX_RESPONSE_BYTES} byte limit)`, + }, }) respond = () => @@ -228,26 +231,29 @@ describe("WebFetchTool registration", () => { new Response("x".repeat(WebFetchTool.MAX_RESPONSE_BYTES + 1), { headers: { "content-type": "text/plain" } }), ) expect(yield* executeTool(registry, call({ url: "https://1.1.1.1/streamed", format: "text" }))).toEqual({ - type: "error", - value: "Unable to fetch https://1.1.1.1/streamed", + status: "error", + error: { + type: "unknown", + message: `Response too large (exceeds ${WebFetchTool.MAX_RESPONSE_BYTES} byte limit)`, + }, }) }), ) - it.effect("keeps images and files unsupported until typed settlement can carry attachments", () => + it.effect("keeps images and files unsupported until typed outcomes can carry attachments", () => Effect.gen(function* () { reset() const registry = yield* ToolRegistry.Service respond = () => Effect.succeed(new Response("png", { headers: { "content-type": "image/png" } })) expect(yield* executeTool(registry, call({ url: "https://1.1.1.1/image", format: "html" }))).toEqual({ - type: "error", - value: "Unable to fetch https://1.1.1.1/image", + status: "error", + error: { type: "unknown", message: "Unsupported fetched image content type: image/png" }, }) respond = () => Effect.succeed(new Response("pdf", { headers: { "content-type": "application/pdf" } })) expect(yield* executeTool(registry, call({ url: "https://1.1.1.1/file", format: "html" }))).toEqual({ - type: "error", - value: "Unable to fetch https://1.1.1.1/file", + status: "error", + error: { type: "unknown", message: "Unsupported fetched file content type: application/pdf" }, }) }), ) @@ -264,9 +270,9 @@ describe("WebFetchTool registration", () => { ) const registry = yield* ToolRegistry.Service - expect(yield* executeTool(registry, call({ url: "https://1.1.1.1", format: "text" }))).toEqual({ - type: "text", - value: "ok", + expect(yield* executeTool(registry, call({ url: "https://1.1.1.1", format: "text" }))).toMatchObject({ + status: "completed", + content: [{ type: "text", text: "ok" }], }) expect(requests).toHaveLength(2) expect(requests[0]?.headers["user-agent"]).toContain("Mozilla/5.0") @@ -285,7 +291,10 @@ describe("WebFetchTool registration", () => { ).pipe(Effect.forkChild) yield* TestClock.adjust(Duration.seconds(1)) - expect(yield* Fiber.join(fiber)).toEqual({ type: "error", value: "Unable to fetch https://1.1.1.1/slow" }) + expect(yield* Fiber.join(fiber)).toEqual({ + status: "error", + error: { type: "unknown", message: "Request timed out" }, + }) }), ) }) diff --git a/packages/core/test/tool-websearch.test.ts b/packages/core/test/tool-websearch.test.ts index 37d3960ab7..7a8c2bb09c 100644 --- a/packages/core/test/tool-websearch.test.ts +++ b/packages/core/test/tool-websearch.test.ts @@ -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 " 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) }), diff --git a/packages/core/test/tool-write.test.ts b/packages/core/test/tool-write.test.ts index 67a001e0eb..3cd3dcb4b6 100644 --- a/packages/core/test/tool-write.test.ts +++ b/packages/core/test/tool-write.test.ts @@ -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([]) diff --git a/packages/docs/build/plugins.mdx b/packages/docs/build/plugins.mdx index 01a8b5746b..e358a3ccae 100644 --- a/packages/docs/build/plugins.mdx +++ b/packages/docs/build/plugins.mdx @@ -248,7 +248,7 @@ mutable fields: | `ctx.aisdk.hook("language", callback)` | `language`, after inspecting `model`, `sdk`, and `options` | | `ctx.session.hook("request", callback)` | `system`, `messages`, and the `tools` record immediately before model dispatch | | `ctx.tool.hook("execute.before", callback)` | `input`, before the selected tool executes | -| `ctx.tool.hook("execute.after", callback)` | `result`, `output`, and `outputPaths`, after execution settles | +| `ctx.tool.hook("execute.after", callback)` | Terminal `content`, `metadata`, and `outputPaths`; `error` on failure | For example, remove a tool from selected model requests and normalize another tool's input: @@ -278,52 +278,66 @@ handle expected errors inside the callback. ### Add a tool -Pass a tool declaration to `tools.add`. Define its input with JSON Schema and -use an async executor: +Create an executable tool with `Tool.make`, then register it with a name +and registration options. Define its input with JSON Schema and use an async +executor: ```js title=".opencode/plugins/greeting.js" import { Plugin } from "@opencode-ai/plugin/v2" +import { Tool } from "@opencode-ai/plugin/v2/tool" export default Plugin.define({ id: "acme.greeting", setup: async (ctx) => { await ctx.tool.transform((tools) => { - tools.add({ - name: "greeting", - description: "Create a greeting", - jsonSchema: { - type: "object", - properties: { - name: { type: "string" }, + tools.add( + "greeting", + Tool.make({ + description: "Create a greeting", + input: { + type: "object", + properties: { + name: { type: "string" }, + }, + required: ["name"], + additionalProperties: false, }, - required: ["name"], - additionalProperties: false, - }, - execute: async ({ name }) => { - const text = `Hello, ${name}!` - return { - structured: { greeting: text }, - content: [{ type: "text", text }], - } - }, - }) + output: { + type: "object", + properties: { greeting: { type: "string" } }, + required: ["greeting"], + additionalProperties: false, + }, + execute: async ({ name }) => { + const text = `Hello, ${name}!` + return { + output: { greeting: text }, + content: text, + } + }, + }), + ) }) }, }) ``` -Unsupported characters in tool and group names are normalized to underscores. -The resulting exposed key must begin with a letter and contain at most 64 -letters, digits, underscores, or hyphens. Set `options` on the declaration to -configure registration with `{ group, codemode }`: +Unsupported characters in tool names are normalized to underscores. Namespace +segments must begin with a letter, contain at most 64 letters, digits, +underscores, or hyphens, and are joined with dots. Pass the optional third +argument to `tools.add` to configure the registration with +`{ namespace, codemode }`: -- `group` prefixes and groups the exposed tool name. +- `namespace` prefixes and groups the exposed tool name. - `codemode` defaults to `true` and makes the tool available through the `execute` CodeMode tool. Set `codemode: false` to expose it directly to the provider. The executor receives a second context argument containing `sessionID`, -`agent`, `assistantMessageID`, and `toolCallID`. +`agent`, `messageID`, `callID`, and `progress`. A tool with `output` +must return `output`; Effect and Standard Schema codecs validate it, while raw +JSON Schema definitions enforce JSON compatibility only. A tool +without `output` returns model-visible `content` instead. ### Add a command @@ -426,6 +440,7 @@ fibers, and registrations are released when the plugin reloads or unloads. OpenCode does not expose its private Core services to the plugin; use the capabilities on `ctx`. -Typed tools can use `Schema` from `effect` and the contracts exported from -`@opencode-ai/plugin/v2/effect/tool`. Their executors return an Effect and may -fail with the typed tool failure channel. +Typed tools can use `Schema` from `effect` and `Tool.make` from +`@opencode-ai/plugin/v2/effect/tool`. Effect and Promise plugins use the same +`tools.add(name, tool, options?)` registration shape. Effect executors +return an Effect and may fail with the typed tool failure channel. diff --git a/packages/plugin/src/v2/effect/internal/tool.ts b/packages/plugin/src/v2/effect/internal/tool.ts new file mode 100644 index 0000000000..abc92c6a7d --- /dev/null +++ b/packages/plugin/src/v2/effect/internal/tool.ts @@ -0,0 +1,315 @@ +import { Agent } from "@opencode-ai/schema/agent" +import { LLM } from "@opencode-ai/schema/llm" +import { Session } from "@opencode-ai/schema/session" +import { SessionError } from "@opencode-ai/schema/session-error" +import { SessionMessage } from "@opencode-ai/schema/session-message" +import type { StandardJSONSchemaV1, StandardSchemaV1 } from "@standard-schema/spec" +import { Effect, JsonSchema, Schema } from "effect" +import type { Hooks, Transform } from "../registration.js" + +// Tools + +/** A JSON-compatible value. Tool metadata and encoded outputs must be JSON. */ +export type JsonValue = typeof Schema.Json.Type + +/** Compact JSON metadata for tool-specific UI and client behavior. */ +export type Metadata = Readonly> + +export interface Context { + readonly sessionID: Session.ID + readonly agent: Agent.ID + readonly messageID: SessionMessage.ID + readonly callID: string + readonly progress: (update: Progress) => Effect.Effect +} + +/** Live replacement metadata for a running tool. */ +export type Progress = Metadata + +export type StandardSchemaType = StandardSchemaV1 & + StandardJSONSchemaV1 +export type SchemaType = Schema.Codec | StandardSchemaType | JsonSchema.JsonSchema +type IsAny = 0 extends 1 & A ? true : false +export type InputValue = + IsAny extends true + ? any + : S extends Schema.Codec + ? A + : S extends StandardSchemaV1 + ? A + : unknown +export type OutputValue = + IsAny extends true + ? any + : S extends Schema.Codec + ? A + : S extends StandardSchemaV1 + ? A + : unknown +export type EncodedValue = + IsAny extends true + ? any + : S extends Schema.Codec + ? A + : S extends StandardSchemaV1 + ? A + : unknown + +type ToolDefinition = { + readonly name: string + readonly description: string + readonly inputSchema: JsonSchema.JsonSchema + readonly outputSchema?: JsonSchema.JsonSchema +} + +export class Failure extends Schema.TaggedErrorClass()("LLM.ToolFailure", { + message: Schema.String, + error: Schema.optional(Schema.Defect()), +}) {} + +export class RegistrationError extends Schema.TaggedErrorClass()("Tool.RegistrationError", { + name: Schema.String, + message: Schema.String, +}) {} + +export type Content = + | { readonly type: "text"; readonly text: string } + | { readonly type: "file"; readonly data: string; readonly mime: string; readonly name?: string } + +/** Model-facing tool content: plain text or non-empty rich content. */ +export type ModelOutput = string | readonly [Content, ...Content[]] + +type BaseTool> = { + readonly description: string + readonly input: Input +} + +export type Response> = { + readonly output: OutputValue + readonly content?: ModelOutput + readonly metadata?: Metadata +} + +export type ContentResponse = { + readonly content: ModelOutput + readonly metadata?: Metadata +} + +export type Tool< + Input extends SchemaType, + Output extends SchemaType | undefined = undefined, +> = BaseTool & + (Output extends SchemaType + ? { + readonly output: Output + readonly execute: (input: InputValue, context: Context) => Effect.Effect, Failure> + } + : { + readonly output?: undefined + readonly execute: (input: InputValue, context: Context) => Effect.Effect + }) + +export type Any = BaseTool & { + readonly output?: SchemaType + readonly execute: (input: any, context: Context) => Effect.Effect | ContentResponse, Failure> +} + +export function make, Output extends SchemaType>( + config: Tool, +): Tool +export function make>(config: Tool): Tool +export function make(config: Any): Any +export function make(config: Any): Any { + return config +} + +// Registration + +export interface RegisterOptions { + readonly namespace?: string + /** Defaults to true. False exposes the tool directly to the provider. */ + readonly codemode?: boolean + /** Permission action used for whole-tool visibility filtering. */ + readonly permission?: string +} + +export interface Registration { + readonly tool: Any + readonly name: string + readonly namespace?: string + readonly permission: string +} + +export const validateName = (name: string) => + /^[A-Za-z][A-Za-z0-9_-]{0,63}$/.test(name) + ? Effect.void + : Effect.fail(new RegistrationError({ name, message: `Invalid tool name: ${name}` })) + +export const registrationEntries = ( + tools: Readonly>, + options?: RegisterOptions, +): Array => + Object.entries(tools).map(([name, tool]) => { + const normalized = name.replace(/[^a-zA-Z0-9_-]/g, "_") + const key = + options?.namespace === undefined ? normalized : `${options.namespace.replaceAll(".", "_")}_${normalized}` + return { + key, + name: normalized, + namespace: options?.namespace, + tool, + permission: options?.permission ?? key, + } + }) + +export const validateNamespace = (namespace: string) => + namespace.split(".").every((segment) => /^[A-Za-z][A-Za-z0-9_-]{0,63}$/.test(segment)) + ? Effect.void + : Effect.fail( + new RegistrationError({ name: namespace, message: `Invalid tool namespace: ${JSON.stringify(namespace)}` }), + ) + +export const toLLMDefinition = (name: string, tool: Any): ToolDefinition => ({ + name, + description: tool.description, + inputSchema: inputJsonSchema(tool.input), + ...(tool.output === undefined ? {} : { outputSchema: outputJsonSchema(tool.output) }), +}) + +// Schema interpretation + +export function decodeInput(schema: SchemaType, value: unknown): Effect.Effect { + if (Schema.isSchema(schema)) + return Schema.decodeUnknownEffect(schema)(value).pipe( + Effect.mapError((error) => new Failure({ message: `Invalid tool input: ${error.message}` })), + ) + if (isStandardSchema(schema)) return validateStandard(schema, value, "Invalid tool input") + return Effect.succeed(value) +} + +export function encodeOutput(schema: SchemaType, value: unknown): Effect.Effect { + if (Schema.isSchema(schema)) + return Schema.encodeEffect(schema)(value).pipe( + Effect.mapError( + (error) => new Failure({ message: `Tool returned an invalid value for its output schema: ${error.message}` }), + ), + ) + if (isStandardSchema(schema)) + return validateStandard(schema, value, "Tool returned an invalid value for its output schema") + return Schema.decodeUnknownEffect(Schema.Json)(value).pipe( + Effect.mapError( + (error) => new Failure({ message: `Tool returned a non-JSON value for its output schema: ${error.message}` }), + ), + ) +} + +function isStandardSchema(schema: SchemaType): schema is StandardSchemaType { + return "~standard" in schema +} + +function validateStandard(schema: StandardSchemaType, value: unknown, prefix: string): Effect.Effect { + return Effect.gen(function* () { + const pending = yield* Effect.try({ + try: () => schema["~standard"].validate(value), + catch: (error) => standardFailure(prefix, error), + }) + const result = + pending instanceof Promise + ? yield* Effect.tryPromise({ try: () => pending, catch: (error) => standardFailure(prefix, error) }) + : pending + if (result.issues) + return yield* Effect.fail( + new Failure({ message: `${prefix}: ${result.issues.map((issue) => issue.message).join(", ")}` }), + ) + return result.value + }) +} + +function standardFailure(prefix: string, error: unknown) { + return new Failure({ message: `${prefix}: ${error instanceof Error ? error.message : String(error)}` }) +} + +function inputJsonSchema(schema: SchemaType): JsonSchema.JsonSchema { + if (isStandardSchema(schema)) + return schema["~standard"].jsonSchema.input({ target: "draft-2020-12" }) as JsonSchema.JsonSchema + return Schema.isSchema(schema) ? toJsonSchema(schema) : (schema as JsonSchema.JsonSchema) +} + +function outputJsonSchema(schema: SchemaType): JsonSchema.JsonSchema { + if (isStandardSchema(schema)) + return schema["~standard"].jsonSchema.output({ target: "draft-2020-12" }) as JsonSchema.JsonSchema + return Schema.isSchema(schema) ? toJsonSchema(schema) : (schema as JsonSchema.JsonSchema) +} + +function toJsonSchema(schema: Schema.Top): JsonSchema.JsonSchema { + const document = Schema.toJsonSchemaDocument(schema) + if (Object.keys(document.definitions).length === 0) return document.schema + return { ...document.schema, $defs: document.definitions } +} + +// Plugin events + +export interface ToolExecuteBeforeEvent { + readonly tool: string + readonly sessionID: Session.ID + readonly agent: Agent.ID + readonly messageID: SessionMessage.ID + readonly callID: string + input: unknown +} + +type ToolHookBase = { + readonly tool: string + readonly sessionID: Session.ID + readonly agent: Agent.ID + readonly messageID: SessionMessage.ID + readonly callID: string + readonly input: unknown +} + +export const ExecuteAfterOutcome = Schema.Union([ + Schema.Struct({ + status: Schema.Literal("completed"), + content: Schema.NonEmptyArray(LLM.ToolContent), + metadata: Schema.optional(Schema.Record(Schema.String, Schema.Json)), + outputPaths: Schema.optional(Schema.Array(Schema.String)), + }), + Schema.Struct({ + status: Schema.Literal("error"), + error: SessionError.Error, + content: Schema.optional(Schema.NonEmptyArray(LLM.ToolContent)), + metadata: Schema.optional(Schema.Record(Schema.String, Schema.Json)), + outputPaths: Schema.optional(Schema.Array(Schema.String)), + }), +]).pipe(Schema.toTaggedUnion("status")) + +type Mutable = { -readonly [K in keyof A]: A[K] } +type HookOutcome = Omit, "status"> & Pick + +/** The bounded terminal outcome exposed to tool hooks. */ +export type Outcome = typeof ExecuteAfterOutcome.Type extends infer A + ? A extends { readonly status: string } + ? HookOutcome + : never + : never + +/** + * The canonical execution outcome as seen by `execute.after` hooks. Hooks + * observe bounded model content, optional metadata, and managed output paths; + * they never observe the raw domain output. + */ +export type ToolExecuteAfterEvent = ToolHookBase & Outcome + +export interface ToolDraft { + add(name: string, tool: Any, options?: RegisterOptions): void +} + +export interface ToolHooks { + readonly "execute.before": ToolExecuteBeforeEvent + readonly "execute.after": ToolExecuteAfterEvent +} + +export interface ToolDomain { + readonly transform: Transform + readonly hook: Hooks +} diff --git a/packages/plugin/src/v2/effect/tool.ts b/packages/plugin/src/v2/effect/tool.ts index 879e5badef..cbc634f8e5 100644 --- a/packages/plugin/src/v2/effect/tool.ts +++ b/packages/plugin/src/v2/effect/tool.ts @@ -1,314 +1,2 @@ -export * as Tool from "./tool.js" - -import { Agent } from "@opencode-ai/schema/agent" -import type { LLM } from "@opencode-ai/schema/llm" -import { Session } from "@opencode-ai/schema/session" -import { SessionMessage } from "@opencode-ai/schema/session-message" -import type { StandardJSONSchemaV1, StandardSchemaV1 } from "@standard-schema/spec" -import { Effect, JsonSchema, Schema } from "effect" -import type { Hooks, Transform } from "./registration.js" - -export interface Context { - readonly sessionID: Session.ID - readonly agent: Agent.ID - readonly messageID: SessionMessage.ID - readonly callID: string - readonly progress: (update: Progress) => Effect.Effect -} - -export interface Progress { - readonly structured: Readonly> - readonly content?: ReadonlyArray -} - -export type StandardSchemaType = StandardSchemaV1 & - StandardJSONSchemaV1 -export type SchemaType = Schema.Codec | StandardSchemaType -type IsAny = 0 extends 1 & A ? true : false -export type InputValue = - IsAny extends true - ? any - : S extends Schema.Codec - ? A - : S extends StandardSchemaV1 - ? A - : never -export type OutputValue = - IsAny extends true - ? any - : S extends Schema.Codec - ? A - : S extends StandardSchemaV1 - ? A - : never -export type EncodedValue = - IsAny extends true - ? any - : S extends Schema.Codec - ? A - : S extends StandardSchemaV1 - ? A - : never - -type ToolDefinition = { - readonly name: string - readonly description: string - readonly inputSchema: JsonSchema.JsonSchema - readonly outputSchema?: JsonSchema.JsonSchema -} - -type ToolCall = { - readonly input: unknown - readonly [key: string]: unknown -} - -type ToolResultValue = - | { readonly type: "json"; readonly value: unknown } - | { readonly type: "text"; readonly value: unknown } - | { readonly type: "error"; readonly value: unknown } - | { readonly type: "content"; readonly value: ReadonlyArray } - -type ToolOutput = { - readonly structured: unknown - readonly content: ReadonlyArray -} - -export class Failure extends Schema.TaggedErrorClass()("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()("Tool.RegistrationError", { - name: Schema.String, - message: Schema.String, -}) {} - -export type Content = - | { readonly type: "text"; readonly text: string } - | { readonly type: "file"; readonly data: string; readonly mime: string; readonly name?: string } - -export type Definition< - Input extends SchemaType, - Structured extends SchemaType, - Output extends SchemaType = any, -> = { - readonly description: string - readonly input: Input - readonly output: Output - readonly structured?: Structured - readonly permission?: string - readonly toStructuredOutput?: (input: { - readonly input: InputValue - readonly output: EncodedValue - }) => OutputValue - readonly execute: (input: InputValue, context: Context) => Effect.Effect, Failure> - readonly toModelOutput?: (input: { - readonly input: InputValue - readonly output: EncodedValue - }) => ReadonlyArray -} - -export type DynamicOutput = { - readonly structured: unknown - readonly content: ReadonlyArray -} - -/** - * 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 -} - -export type AnyTool = Definition | DynamicDefinition - -export function make< - Input extends SchemaType, - Output extends SchemaType, - Structured extends SchemaType = Output, ->(config: Definition): Definition -export function make(config: DynamicDefinition): DynamicDefinition -export function make(config: AnyTool): AnyTool -export function make(config: AnyTool): AnyTool { - return config -} - -function toModelContent(part: Content) { - if (part.type === "text") return { type: "text" as const, text: part.text } - return { type: "file" as const, uri: `data:${part.mime};base64,${part.data}`, mime: part.mime, name: part.name } -} - -export const validateName = (name: string) => - /^[A-Za-z][A-Za-z0-9_-]{0,63}$/.test(name) - ? Effect.void - : Effect.fail(new RegistrationError({ name, message: `Invalid tool name: ${name}` })) - -export const registrationEntries = (tools: Readonly>, namespace?: string) => - Object.entries(tools).map(([name, tool]) => { - const normalized = name.replace(/[^a-zA-Z0-9_-]/g, "_") - return { - key: namespace === undefined ? normalized : `${namespace.replaceAll(".", "_")}_${normalized}`, - name: normalized, - namespace, - tool, - } - }) - -export const validateNamespace = (namespace: string) => - namespace.split(".").every((segment) => /^[A-Za-z][A-Za-z0-9_-]{0,63}$/.test(segment)) - ? Effect.void - : Effect.fail( - new RegistrationError({ name: namespace, message: `Invalid tool namespace: ${JSON.stringify(namespace)}` }), - ) - -export const withPermission = ( - tool: T, - permission: string, -): Omit & { - readonly permission: string -} => ({ ...tool, permission }) - -export const permission = (tool: AnyTool, name: string) => tool.permission ?? name - -export const definition = (name: string, tool: AnyTool): ToolDefinition => - "jsonSchema" in tool - ? { - name, - description: tool.description, - inputSchema: tool.jsonSchema, - outputSchema: tool.outputSchema, - } - : { - name, - description: tool.description, - inputSchema: inputJsonSchema(tool.input), - outputSchema: outputJsonSchema(tool.structured ?? tool.output), - } - -export const settle = (tool: AnyTool, call: ToolCall, context: Context): Effect.Effect => - Effect.gen(function* () { - if ("jsonSchema" in tool) { - const output = yield* tool.execute(call.input, context) - return { structured: output.structured, content: output.content.map(toModelContent) } - } - - const input = yield* decodeInput(tool.input, call.input) - const value = yield* tool.execute(input, context) - const output = yield* encodeOutput(tool.output, value) - const structured = - tool.structured && tool.toStructuredOutput - ? yield* encodeOutput(tool.structured, tool.toStructuredOutput({ input, output })) - : output - return { - structured, - content: - tool.toModelOutput?.({ input, output }).map(toModelContent) ?? - (typeof output === "string" ? [{ type: "text" as const, text: output }] : []), - } - }) - -function decodeInput(schema: SchemaType, value: unknown): Effect.Effect { - if (Schema.isSchema(schema)) - return Schema.decodeUnknownEffect(schema)(value).pipe( - Effect.mapError((error) => new Failure({ message: `Invalid tool input: ${error.message}` })), - ) - return validateStandard(schema, value, "Invalid tool input") -} - -function encodeOutput(schema: SchemaType, value: unknown): Effect.Effect { - if (Schema.isSchema(schema)) - return Schema.encodeEffect(schema)(value).pipe( - Effect.mapError( - (error) => new Failure({ message: `Tool returned an invalid value for its output schema: ${error.message}` }), - ), - ) - return validateStandard(schema, value, "Tool returned an invalid value for its output schema") -} - -function validateStandard(schema: StandardSchemaType, value: unknown, prefix: string): Effect.Effect { - return Effect.gen(function* () { - const pending = yield* Effect.try({ - try: () => schema["~standard"].validate(value), - catch: (error) => standardFailure(prefix, error), - }) - const result = - pending instanceof Promise - ? yield* Effect.tryPromise({ try: () => pending, catch: (error) => standardFailure(prefix, error) }) - : pending - if (result.issues) - return yield* Effect.fail( - new Failure({ message: `${prefix}: ${result.issues.map((issue) => issue.message).join(", ")}` }), - ) - return result.value - }) -} - -function standardFailure(prefix: string, error: unknown) { - return new Failure({ message: `${prefix}: ${error instanceof Error ? error.message : String(error)}` }) -} - -function inputJsonSchema(schema: SchemaType): JsonSchema.JsonSchema { - if (!Schema.isSchema(schema)) - return schema["~standard"].jsonSchema.input({ target: "draft-2020-12" }) as JsonSchema.JsonSchema - return toJsonSchema(schema) -} - -function outputJsonSchema(schema: SchemaType): JsonSchema.JsonSchema { - if (!Schema.isSchema(schema)) - return schema["~standard"].jsonSchema.output({ target: "draft-2020-12" }) as JsonSchema.JsonSchema - return toJsonSchema(schema) -} - -function toJsonSchema(schema: Schema.Top): JsonSchema.JsonSchema { - const document = Schema.toJsonSchemaDocument(schema) - if (Object.keys(document.definitions).length === 0) return document.schema - return { ...document.schema, $defs: document.definitions } -} - -export interface ToolExecuteBeforeEvent { - readonly tool: string - readonly sessionID: Session.ID - readonly agent: Agent.ID - readonly messageID: SessionMessage.ID - readonly callID: string - input: unknown -} - -export interface ToolExecuteAfterEvent { - readonly tool: string - readonly sessionID: Session.ID - readonly agent: Agent.ID - readonly messageID: SessionMessage.ID - readonly callID: string - readonly input: unknown - result: ToolResultValue - output?: ToolOutput - outputPaths?: ReadonlyArray -} - -export interface RegisterOptions { - readonly namespace?: string - /** Defaults to true. False exposes the tool directly to the provider. */ - readonly codemode?: boolean -} - -export interface ToolDraft { - add(name: string, tool: AnyTool, options?: RegisterOptions): void -} - -export interface ToolHooks { - readonly "execute.before": ToolExecuteBeforeEvent - readonly "execute.after": ToolExecuteAfterEvent -} - -export interface ToolDomain { - readonly transform: Transform - readonly hook: Hooks -} +export * as Tool from "./internal/tool.js" +export * from "./internal/tool.js" diff --git a/packages/plugin/src/v2/promise/README.md b/packages/plugin/src/v2/promise/README.md index e23a231e4f..c7ffbfd251 100644 --- a/packages/plugin/src/v2/promise/README.md +++ b/packages/plugin/src/v2/promise/README.md @@ -94,19 +94,23 @@ await ctx.session.hook("context", (event) => { }) ``` -Promise tools use plain object declarations with async executors: +Promise tools use executable tool values with async executors. Registration +supplies the tool's name and options separately: ```ts import { Schema } from "effect" +import { Tool } from "@opencode-ai/plugin/v2/tool" await ctx.tool.transform((tools) => { - tools.add({ - name: "echo", - description: "Echo text", - input: Schema.Struct({ text: Schema.String }), - output: Schema.Struct({ text: Schema.String }), - execute: async ({ text }) => ({ text }), - }) + tools.add( + "echo", + Tool.make({ + description: "Echo text", + input: Schema.Struct({ text: Schema.String }), + output: Schema.Struct({ text: Schema.String }), + execute: async ({ text }) => ({ output: { text }, content: text }), + }), + ) }) ``` diff --git a/packages/plugin/src/v2/promise/internal/tool.ts b/packages/plugin/src/v2/promise/internal/tool.ts new file mode 100644 index 0000000000..3d5def019b --- /dev/null +++ b/packages/plugin/src/v2/promise/internal/tool.ts @@ -0,0 +1,64 @@ +import type { Hooks, Transform } from "../registration.js" + +export type Context = Omit & { + readonly progress: (update: import("../../effect/internal/tool.js").Progress) => Promise +} +export type SchemaType = import("../../effect/internal/tool.js").SchemaType +export type Content = import("../../effect/internal/tool.js").Content +export type Metadata = import("../../effect/internal/tool.js").Metadata +export type ModelOutput = import("../../effect/internal/tool.js").ModelOutput + +export type Tool, Output extends SchemaType | undefined = undefined> = Omit< + import("../../effect/internal/tool.js").Tool, + "execute" +> & { + readonly execute: ( + input: import("../../effect/internal/tool.js").InputValue, + context: Context, + ) => Promise< + Output extends SchemaType + ? import("../../effect/internal/tool.js").Response + : import("../../effect/internal/tool.js").ContentResponse + > +} + +export type Any = Omit & { + readonly execute: ( + input: any, + context: Context, + ) => Promise< + import("../../effect/internal/tool.js").Response | import("../../effect/internal/tool.js").ContentResponse + > +} + +export function make, Output extends SchemaType>( + tool: Tool, +): Tool +export function make>(tool: Tool): Tool +export function make(tool: Any): Any +export function make(tool: Any): Any { + return tool +} + +export type ToolExecuteBeforeEvent = import("../../effect/internal/tool.js").ToolExecuteBeforeEvent +export type ToolExecuteAfterEvent = import("../../effect/internal/tool.js").ToolExecuteAfterEvent +export type RegisterOptions = import("../../effect/internal/tool.js").RegisterOptions + +export interface ToolDraft { + add, Output extends SchemaType>( + name: string, + tool: Tool, + options?: RegisterOptions, + ): void + add>(name: string, tool: Tool, options?: RegisterOptions): void +} + +export interface ToolHooks { + readonly "execute.before": ToolExecuteBeforeEvent + readonly "execute.after": ToolExecuteAfterEvent +} + +export interface ToolDomain { + readonly transform: Transform + readonly hook: Hooks +} diff --git a/packages/plugin/src/v2/promise/tool.ts b/packages/plugin/src/v2/promise/tool.ts index 91b9b2c1e2..cbc634f8e5 100644 --- a/packages/plugin/src/v2/promise/tool.ts +++ b/packages/plugin/src/v2/promise/tool.ts @@ -1,48 +1,2 @@ -import type { Tool } from "../effect/tool.js" -import type { Hooks, Transform } from "./registration.js" - -export type Context = Omit & { - readonly progress: (update: Tool.Progress) => Promise -} -export type SchemaType = Tool.SchemaType -export type Content = Tool.Content -export type DynamicOutput = Tool.DynamicOutput - -export type Definition< - Input extends SchemaType, - Output extends SchemaType, - Structured extends SchemaType = Output, -> = Omit, "execute" | "permission"> & { - readonly name: string - readonly options?: RegisterOptions - readonly execute: (input: Tool.InputValue, context: Context) => Promise> -} - -export type DynamicDefinition = Omit & { - readonly name: string - readonly options?: RegisterOptions - readonly execute: (input: unknown, context: Context) => Promise -} - -export type AnyTool = Definition | DynamicDefinition - -export type ToolExecuteBeforeEvent = Tool.ToolExecuteBeforeEvent -export type ToolExecuteAfterEvent = Tool.ToolExecuteAfterEvent -export type RegisterOptions = Tool.RegisterOptions - -export interface ToolDraft { - add, Output extends SchemaType, Structured extends SchemaType = Output>( - tool: Definition, - ): void - add(tool: DynamicDefinition): void -} - -export interface ToolHooks { - readonly "execute.before": ToolExecuteBeforeEvent - readonly "execute.after": ToolExecuteAfterEvent -} - -export interface ToolDomain { - readonly transform: Transform - readonly hook: Hooks -} +export * as Tool from "./internal/tool.js" +export * from "./internal/tool.js" diff --git a/packages/plugin/test/tool.test.ts b/packages/plugin/test/tool.test.ts index c22ddbec9a..19017cab20 100644 --- a/packages/plugin/test/tool.test.ts +++ b/packages/plugin/test/tool.test.ts @@ -1,29 +1,18 @@ import { expect, test } from "bun:test" -import { Agent } from "@opencode-ai/schema/agent" -import { Session } from "@opencode-ai/schema/session" -import { SessionMessage } from "@opencode-ai/schema/session-message" import { Effect, Schema } from "effect" import * as Tool from "../src/v2/effect/tool" -const context = { - sessionID: Session.ID.make("ses_test"), - agent: Agent.ID.make("build"), - messageID: SessionMessage.ID.make("msg_test"), - callID: "call_test", - progress: () => Effect.void, -} satisfies Tool.Context - test("tools remain valid across separate module instances", async () => { const ForeignTool = await import(`${new URL("../src/v2/effect/tool.ts", import.meta.url).href}?foreign`) const config = { description: "Foreign tool", input: Schema.Struct({ value: Schema.String }), output: Schema.Struct({ ok: Schema.Boolean }), - execute: () => Effect.succeed({ ok: true }), + execute: () => Effect.succeed({ output: { ok: true } }), } const tool = ForeignTool.make(config) - expect(Tool.definition("foreign", tool)).toEqual({ + expect(Tool.toLLMDefinition("foreign", tool)).toEqual({ name: "foreign", description: "Foreign tool", inputSchema: { @@ -39,10 +28,7 @@ test("tools remain valid across separate module instances", async () => { additionalProperties: false, }, }) - expect(await Effect.runPromise(Tool.settle(tool, { input: { value: "input" } }, context))).toEqual({ - structured: { ok: true }, - content: [], - }) + expect(await Effect.runPromise(Tool.decodeInput(tool.input, { value: "input" }))).toEqual({ value: "input" }) }) test("portable schemas validate and describe typed tools", async () => { @@ -77,19 +63,18 @@ test("portable schemas validate and describe typed tools", async () => { description: "Portable tool", input, output, - execute: ({ count }) => Effect.succeed(count + 1), + execute: ({ count }) => Effect.succeed({ output: count + 1 }), }) - expect(Tool.definition("portable", tool)).toEqual({ + expect(Tool.toLLMDefinition("portable", tool)).toEqual({ name: "portable", description: "Portable tool", inputSchema: { type: "object", properties: { count: { type: "string" } } }, outputSchema: { type: "string" }, }) - expect(await Effect.runPromise(Tool.settle(tool, { input: { count: "41" } }, context))).toEqual({ - structured: "42", - content: [{ type: "text", text: "42" }], - }) + const decoded = await Effect.runPromise(Tool.decodeInput(tool.input, { count: "41" })) + expect(decoded).toEqual({ count: 41 }) + expect(await Effect.runPromise(Tool.encodeOutput(tool.output, 42))).toBe("42") }) test("portable schema failures become tool failures", async () => { @@ -104,29 +89,39 @@ test("portable schema failures become tool failures", async () => { }, }, } - const tool = Tool.make({ - description: "Failing tool", - input, - output: input, - execute: Effect.succeed, - }) - const error = await Effect.runPromiseExit(Tool.settle(tool, { input: 1 }, context)) + const error = await Effect.runPromiseExit(Tool.decodeInput(input, 1)) expect(error.toString()).toContain("Invalid tool input: expected a string") }) -test("two-parameter Definition annotations retain their original meaning", () => { +test("canonical results carry metadata with typed output", async () => { const input = Schema.Struct({ value: Schema.String }) const output = Schema.Struct({ value: Schema.String, internal: Schema.Boolean }) - const structured = Schema.Struct({ value: Schema.String }) - const tool: Tool.Definition = Tool.make({ + const tool = Tool.make({ description: "Annotated tool", input, output, - structured, - toStructuredOutput: ({ output }) => ({ value: output.value }), - execute: ({ value }) => Effect.succeed({ value, internal: true }), + execute: ({ value }) => Effect.succeed({ output: { value, internal: true }, metadata: { value }, content: value }), }) - expect(tool.structured).toBe(structured) + expect(await Effect.runPromise(tool.execute({ value: "out" }, {} as Tool.Context))).toEqual({ + output: { value: "out", internal: true }, + metadata: { value: "out" }, + content: "out", + }) +}) + +test("raw JSON schemas are render-only and omitted output means model-only", async () => { + const tool = Tool.make({ + description: "Raw tool", + input: { type: "object", properties: { value: { type: "string" } } }, + execute: (input) => Effect.succeed({ content: JSON.stringify(input) }), + }) + + expect(Tool.toLLMDefinition("raw", tool)).toEqual({ + name: "raw", + description: "Raw tool", + inputSchema: { type: "object", properties: { value: { type: "string" } } }, + }) + expect(await Effect.runPromise(Tool.decodeInput(tool.input, { value: 1 }))).toEqual({ value: 1 }) }) diff --git a/packages/schema/src/session-event.ts b/packages/schema/src/session-event.ts index cc56a83eab..faa4853ade 100644 --- a/packages/schema/src/session-event.ts +++ b/packages/schema/src/session-event.ts @@ -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), }, diff --git a/packages/schema/src/session-message.ts b/packages/schema/src/session-message.ts index 3d45e283e6..c30ffc6aa4 100644 --- a/packages/schema/src/session-message.ts +++ b/packages/schema/src/session-message.ts @@ -110,27 +110,24 @@ export interface ToolStateRunning extends Schema.Schema.Type {} 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 {} 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( diff --git a/packages/schema/test/event-manifest.test.ts b/packages/schema/test/event-manifest.test.ts index 89766d3d26..bd08c653a5 100644 --- a/packages/schema/test/event-manifest.test.ts +++ b/packages/schema/test/event-manifest.test.ts @@ -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", diff --git a/packages/sdk-next/src/tool.ts b/packages/sdk-next/src/tool.ts index eff4b809aa..978ea6d0cb 100644 --- a/packages/sdk-next/src/tool.ts +++ b/packages/sdk-next/src/tool.ts @@ -1,2 +1,2 @@ export { Failure, RegistrationError, make } from "@opencode-ai/plugin/v2/effect/tool" -export type { AnyTool, Content, Context, Definition } from "@opencode-ai/plugin/v2/effect/tool" +export type { Any, Content, Context, Tool } from "@opencode-ai/plugin/v2/effect/tool" diff --git a/packages/sdk-next/test/embedded.test.ts b/packages/sdk-next/test/embedded.test.ts index 09980bd8cb..68b8ef8e6c 100644 --- a/packages/sdk-next/test/embedded.test.ts +++ b/packages/sdk-next/test/embedded.test.ts @@ -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 } }), }), ), ) diff --git a/packages/simulation/src/backend/simulated-provider.ts b/packages/simulation/src/backend/simulated-provider.ts index ba64f21bca..3bb9014df8 100644 --- a/packages/simulation/src/backend/simulated-provider.ts +++ b/packages/simulation/src/backend/simulated-provider.ts @@ -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 => + ): Effect.Effect, 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,11 +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 }), - ...(registration.permission === undefined ? {} : { permission: registration.permission }), + input: registration.inputSchema, + output: registration.outputSchema ?? {}, execute: (input, context) => invoke( generation, @@ -559,7 +565,9 @@ const makeToolDriver = Effect.fn("SimulatedProvider.makeToolDriver")(function* ( context, ), }), - registration.options, + registration.permission === undefined + ? registration.options + : { ...registration.options, permission: registration.permission }, ) }) .pipe(Scope.provide(nextScope)), diff --git a/packages/simulation/src/protocol/index.ts b/packages/simulation/src/protocol/index.ts index d97b808686..573baf1f83 100644 --- a/packages/simulation/src/protocol/index.ts +++ b/packages/simulation/src/protocol/index.ts @@ -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 {} export const ToolOutput = Schema.Struct({ diff --git a/packages/simulation/test/protocol.test.ts b/packages/simulation/test/protocol.test.ts index b293f35439..34893914c6 100644 --- a/packages/simulation/test/protocol.test.ts +++ b/packages/simulation/test/protocol.test.ts @@ -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" }) diff --git a/packages/simulation/test/simulated-provider.test.ts b/packages/simulation/test/simulated-provider.test.ts index 8aac0cd723..82238bae02 100644 --- a/packages/simulation/test/simulated-provider.test.ts +++ b/packages/simulation/test/simulated-provider.test.ts @@ -241,6 +241,7 @@ test("controls arbitrary tools through scoped SDK overlays", async () => { additionalProperties: false, }, outputSchema: { type: "object" }, + permission: "simulate_lookup", options: { codemode: false }, } const locations = yield* LocationServiceMap.Service @@ -264,19 +265,22 @@ 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(), + expect( + (yield* registry.snapshot([{ action: "simulate_lookup", resource: "*", effect: "deny" }])).definitions, + ).not.toContainEqual(expect.objectContaining({ name: "lookup" })) + 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 +293,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 +313,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 +335,7 @@ test("controls arbitrary tools through scoped SDK overlays", async () => { params: { id: successID, sequence: 0, - update: { structured: { phase: "different" } }, + update: { phase: "different" }, }, }), ) @@ -350,7 +351,7 @@ test("controls arbitrary tools through scoped SDK overlays", async () => { params: { id: successID, sequence: 2, - update: { structured: { phase: "skipped" } }, + update: { phase: "skipped" }, }, }), ) @@ -389,20 +390,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 +409,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 +442,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 +477,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 +505,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 +547,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 +563,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 +584,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 +603,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 +641,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 +659,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)) diff --git a/packages/tui/src/context/data.tsx b/packages/tui/src/context/data.tsx index a5a18ff077..8eb4a19002 100644 --- a/packages/tui/src/context/data.tsx +++ b/packages/tui/src/context/data.tsx @@ -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" @@ -605,7 +606,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": @@ -615,8 +616,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": @@ -629,9 +629,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 @@ -649,9 +648,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 diff --git a/packages/tui/src/mini/demo.ts b/packages/tui/src/mini/demo.ts index a16ae991ac..dcf6bf3537 100644 --- a/packages/tui/src/mini/demo.ts +++ b/packages/tui/src/mini/demo.ts @@ -342,13 +342,13 @@ function make(state: State, tool: string, input: Record): Ref } } -function startTool(state: State, ref: Ref, structured: Record = {}): SessionMessageAssistantTool { +function startTool(state: State, ref: Ref, metadata: Record = {}): 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 diff --git a/packages/tui/src/mini/permission.shared.ts b/packages/tui/src/mini/permission.shared.ts index e293d3ef17..8294a5abe5 100644 --- a/packages/tui/src/mini/permission.shared.ts +++ b/packages/tui/src/mini/permission.shared.ts @@ -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 }), ) diff --git a/packages/tui/src/mini/stream-v2.subagent.ts b/packages/tui/src/mini/stream-v2.subagent.ts index ea3b566af4..95e9719f86 100644 --- a/packages/tui/src/mini/stream-v2.subagent.ts +++ b/packages/tui/src/mini/stream-v2.subagent.ts @@ -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 | undefined) { - const sessionID = text(structured?.sessionID) +function childSessionID(metadata: Record | 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() - // 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>() // 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) => { 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 = child && !child.detailStale ? { [child.sessionID]: { commits: child.frames.map((item) => item.commit) } } : {} diff --git a/packages/tui/src/mini/stream-v2.transport.ts b/packages/tui/src/mini/stream-v2.transport.ts index 496bda83b3..e0e767ca56 100644 --- a/packages/tui/src/mini/stream-v2.transport.ts +++ b/packages/tui/src/mini/stream-v2.transport.ts @@ -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) { +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 : "", diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index 9b03a40422..d4f9f2f00a 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -42,6 +42,7 @@ import { canonicalToolName, finiteNumber, primitiveInputSummary, + toolDisplayContent, toolDisplayMetadata, webSearchProviderLabel, } from "../../util/tool-display" @@ -2146,7 +2147,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") }, @@ -2548,6 +2549,8 @@ function BlockToolContent(props: BlockToolProps & { borderColor: RGBA }) { ) } +const SHELL_DISPLAY_LIMIT = 1024 * 1024 + function Shell(props: ToolProps) { const { themeV2 } = useTheme() const ctx = use() @@ -2559,6 +2562,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)) @@ -2567,31 +2571,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 @@ -2607,7 +2653,7 @@ function Shell(props: ToolProps) { const toggle = () => { const next = !expanded() setExpanded(next) - if (next) void loadBackgroundOutput() + if (next) void loadBackgroundOutput(!backgroundRunning()) } return ( @@ -2638,7 +2684,7 @@ function Shell(props: ToolProps) { - + Background @@ -3154,7 +3200,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}`] diff --git a/packages/tui/src/routes/session/permission.tsx b/packages/tui/src/routes/session/permission.tsx index 403e682f6f..a6a746d881 100644 --- a/packages/tui/src/routes/session/permission.tsx +++ b/packages/tui/src/routes/session/permission.tsx @@ -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, ) diff --git a/packages/tui/src/util/permission.ts b/packages/tui/src/util/permission.ts index 966ee670a0..165943c413 100644 --- a/packages/tui/src/util/permission.ts +++ b/packages/tui/src/util/permission.ts @@ -17,7 +17,7 @@ export type PermissionPresentationInput = { resources: ReadonlyArray 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") { diff --git a/packages/tui/src/util/tool-display.ts b/packages/tui/src/util/tool-display.ts index d181545341..25e333456b 100644 --- a/packages/tui/src/util/tool-display.ts +++ b/packages/tui/src/util/tool-display.ts @@ -28,7 +28,19 @@ export function webSearchProviderLabel(provider: unknown) { export function toolDisplayMetadata(state: unknown): Record { if (!state || typeof state !== "object" || Array.isArray(state)) return {} if (!("status" in state) || state.status === "streaming") return {} - if (!("structured" in state) || !state.structured || typeof state.structured !== "object") return {} - if (Array.isArray(state.structured)) return {} - return state.structured as Record + if (!("metadata" in state) || !state.metadata || typeof state.metadata !== "object") return {} + if (Array.isArray(state.metadata)) return {} + return state.metadata as Record } + +export function toolDisplayContent(state: SessionMessageAssistantTool["state"]) { + if (state.status === "streaming" || state.status === "running") return [] + return state.content ?? [] +} + +export function nonEmptyToolContent(content: ReadonlyArray | undefined): [T, ...T[]] | undefined { + if (!content) return undefined + const [first, ...rest] = content + return first === undefined ? undefined : [first, ...rest] +} +import type { SessionMessageAssistantTool } from "@opencode-ai/client/promise" diff --git a/packages/tui/test/cli/tui/data.test.tsx b/packages/tui/test/cli/tui/data.test.tsx index e7e6146f4b..3b4e5569f8 100644 --- a/packages/tui/test/cli/tui/data.test.tsx +++ b/packages/tui/test/cli/tui/data.test.tsx @@ -2342,8 +2342,7 @@ test("settles pending tools when a live failure arrives", async () => { sessionID: "session-1", assistantMessageID: "msg_explicit_assistant_9", callID: "call-1", - structured: { sessionID: "session-child", status: "running" }, - content: [], + metadata: { sessionID: "session-child", status: "running" }, }, }) @@ -2353,7 +2352,7 @@ test("settles pending tools when a live failure arrives", async () => { assistant?.type === "assistant" && assistant.content[0]?.type === "tool" && assistant.content[0].state.status === "running" && - assistant.content[0].state.structured.sessionID === "session-child" + assistant.content[0].state.metadata.sessionID === "session-child" ) }) @@ -2361,7 +2360,7 @@ test("settles pending tools when a live failure arrives", async () => { id: "evt_failed_1", created: 0, type: "session.tool.failed", - durable: durable("session-1", 6), + durable: durable("session-1", 6, 2), data: { sessionID: "session-1", assistantMessageID: "msg_explicit_assistant_9", @@ -2392,8 +2391,8 @@ test("settles pending tools when a live failure arrives", async () => { if (tool.state.status !== "error") return expect(tool.state.error).toEqual({ type: "unknown", message: "aborted" }) expect(tool.state.input).toEqual({}) - expect(tool.state.structured).toEqual({ sessionID: "session-child", status: "running" }) - expect(tool.state.content).toEqual([]) + expect(tool.state.metadata).toBeUndefined() + expect(tool.state.content).toBeUndefined() expect(tool.executed).toBe(false) expect(tool.providerState).toEqual({ call: true }) expect(tool.providerResultState).toEqual({ result: true }) diff --git a/packages/tui/test/mini/entry.body.test.ts b/packages/tui/test/mini/entry.body.test.ts index a5695305c4..14b1a035b9 100644 --- a/packages/tui/test/mini/entry.body.test.ts +++ b/packages/tui/test/mini/entry.body.test.ts @@ -133,8 +133,8 @@ describe("run entry body", () => { path: "src/a.ts", content: "const x = 1\n", }, - structured: {}, - content: [], + metadata: {}, + content: [{ type: "text", text: "" }], }, }), snapshot: { @@ -153,10 +153,10 @@ describe("run entry body", () => { input: { path: "src/a.ts", }, - structured: { + metadata: { files: [{ file: "src/a.ts", status: "modified", patch: "@@ -1 +1 @@\n-old\n+new\n" }], }, - content: [], + content: [{ type: "text", text: "" }], }, }), snapshot: { @@ -177,8 +177,8 @@ describe("run entry body", () => { state: { status: "completed", input: {}, - content: [], - structured: { + content: [{ type: "text", text: "" }], + metadata: { files: [ { status: "modified", @@ -221,8 +221,7 @@ describe("run entry body", () => { description: "Inspect reducer", agent: "explore", }, - structured: { sessionID: "ses-child-1", status: "running" }, - content: [], + metadata: { sessionID: "ses-child-1", status: "running" }, }, }), ), @@ -243,7 +242,7 @@ describe("run entry body", () => { agent: "explore", }, content: [{ type: "text", text: "# Findings\n\n- Footer stays live" }], - structured: { + metadata: { sessionID: "ses-child-1", status: "completed", output: "# Findings\n\n- Footer stays live", @@ -266,8 +265,8 @@ describe("run entry body", () => { description: "Inspect reducer", agent: "explore", }, - content: [], - structured: { + content: [{ type: "text", text: "" }], + metadata: { sessionID: "ses-child-1", status: "completed", output: "", @@ -341,7 +340,7 @@ describe("run entry body", () => { workdir: "/tmp/demo", }, content: [{ type: "text", text: output }], - structured: { exit: 0, truncated: false }, + metadata: { exit: 0, truncated: false }, }, }), ), @@ -364,8 +363,7 @@ describe("run entry body", () => { input: { command: "ls", }, - structured: {}, - content: [], + metadata: {}, }, }), ), @@ -435,8 +433,8 @@ describe("run entry body", () => { input: { patchText: "*** Begin Patch\n*** End Patch", }, - content: [], - structured: { + content: [{ type: "text", text: "" }], + metadata: { files: [ { status: "modified", @@ -463,8 +461,8 @@ describe("run entry body", () => { input: { patchText: "*** Begin Patch\n*** End Patch", }, - content: [], - structured: { + content: [{ type: "text", text: "" }], + metadata: { files: [ { status: "modified", @@ -499,8 +497,7 @@ describe("run entry body", () => { path: "/tmp/demo/run", }, error: { type: "unknown", message: "No such file or directory: '/tmp/demo/run'" }, - structured: {}, - content: [], + metadata: {}, }, }), ), @@ -520,11 +517,11 @@ describe("run entry body", () => { state: { status: "completed", input: { target: "demo" }, - structured: { + metadata: { result: { ok: true, nested: { values: Array.from({ length: 40 }, (_, index) => ({ index })) } }, large: "x".repeat(8_000), }, - content: [], + content: [{ type: "text", text: "" }], }, }), ) diff --git a/packages/tui/test/mini/permission.shared.test.ts b/packages/tui/test/mini/permission.shared.test.ts index 5a5058523a..2b73f0aae7 100644 --- a/packages/tui/test/mini/permission.shared.test.ts +++ b/packages/tui/test/mini/permission.shared.test.ts @@ -95,8 +95,7 @@ describe("run permission shared", () => { { status: "running", input: { command: "git status --short" }, - structured: {}, - content: [], + metadata: {}, }, "call-shell", ), @@ -141,8 +140,7 @@ describe("run permission shared", () => { { status: "running", input: { query: "current releases" }, - structured: { provider: "exa", retained: true }, - content: [], + metadata: { provider: "exa", retained: true }, }, "call-search", ), @@ -165,8 +163,7 @@ describe("run permission shared", () => { { status: "running", input: { patchText: patch }, - structured: {}, - content: [], + metadata: {}, }, "call-edit", ), diff --git a/packages/tui/test/mini/scrollback.surface.test.ts b/packages/tui/test/mini/scrollback.surface.test.ts index 5db71b1045..e2fe822681 100644 --- a/packages/tui/test/mini/scrollback.surface.test.ts +++ b/packages/tui/test/mini/scrollback.surface.test.ts @@ -217,7 +217,7 @@ test("renders monochrome scrollback as ASCII markdown", async () => { try { await out.scrollback.append(assistant("# H")) expect(Reflect.get(out.scrollback, "active")?.renderable).toBeInstanceOf(MarkdownRenderable) - await out.scrollback.append(assistant('éading →\n\n> “quote”\n\n---\n\n| A | B |\n| - | - |\n| α | β |')) + await out.scrollback.append(assistant("éading →\n\n> “quote”\n\n---\n\n| A | B |\n| - | - |\n| α | β |")) await out.scrollback.complete() out.renderer.writeToScrollback((ctx) => ({ root: new TextRenderable(ctx.renderContext, { @@ -386,8 +386,7 @@ test("renders question summaries without boilerplate footer copy", async () => { }, ], }, - structured: {}, - content: [], + metadata: {}, }, }), final: toolCommit({ @@ -406,10 +405,10 @@ test("renders question summaries without boilerplate footer copy", async () => { }, ], }, - structured: { + metadata: { answers: [["Bug fix"]], }, - content: [], + content: [{ type: "text", text: "" }], }, }), }, @@ -481,8 +480,7 @@ test("inserts spacers for new visible groups", async () => { input: { pattern: "**/run.ts", }, - structured: {}, - content: [], + metadata: {}, }, }), ) @@ -617,8 +615,7 @@ test("does not double-space before completed shell output when inline tool heade command: "ls", workdir: "src/cli/cmd/run", }, - structured: {}, - content: [], + metadata: {}, }, }), ) @@ -634,8 +631,7 @@ test("does not double-space before completed shell output when inline tool heade pattern: "**/*tool*", path: "src/cli/cmd/run", }, - structured: {}, - content: [], + metadata: {}, }, }), ) @@ -651,8 +647,7 @@ test("does not double-space before completed shell output when inline tool heade pattern: "tool", path: "src/cli/cmd/run", }, - structured: {}, - content: [], + metadata: {}, }, }), ) @@ -670,7 +665,7 @@ test("does not double-space before completed shell output when inline tool heade workdir: "src/cli/cmd/run", }, content: [{ type: "text", text: ["src/cli/cmd/run", "ls", "demo.ts", "entry.body.ts", "", ""].join("\n") }], - structured: { exit: 0, truncated: false }, + metadata: { exit: 0, truncated: false }, }, }), ) @@ -735,8 +730,7 @@ test("renders structured write finals once as code blocks", async () => { path: "src/a.ts", content: "const x = 1\nconst y = 2\n", }, - structured: {}, - content: [], + metadata: {}, }, }), ) @@ -755,8 +749,8 @@ test("renders structured write finals once as code blocks", async () => { path: "src/a.ts", content: "const x = 1\nconst y = 2\n", }, - structured: {}, - content: [], + metadata: {}, + content: [{ type: "text", text: "" }], }, }), ) diff --git a/packages/tui/test/mini/stream-v2.transport.test.ts b/packages/tui/test/mini/stream-v2.transport.test.ts index f2c72ea6f0..b4ce61ef8d 100644 --- a/packages/tui/test/mini/stream-v2.transport.test.ts +++ b/packages/tui/test/mini/stream-v2.transport.test.ts @@ -234,8 +234,7 @@ describe("V2 mini transport", () => { sessionID: "ses_1", thinking: false, footer: ui.api, - contextLimit: (model) => - model.providerID === "test" && model.modelID === "model" ? 160_000 : undefined, + contextLimit: (model) => (model.providerID === "test" && model.modelID === "model" ? 160_000 : undefined), }) events.push({ @@ -324,8 +323,7 @@ describe("V2 mini transport", () => { { status: "running" as const, input: { command: "git status --short" }, - structured: {}, - content: [], + metadata: {}, }, "call_child_source", ), @@ -620,8 +618,8 @@ describe("V2 mini transport", () => { expect.objectContaining({ kind: "user", messageID: "msg_queued", text: "follow up" }), ) expect(pending()).toEqual([]) - const prompt = spyOn(client.session, "prompt").mockImplementation((request) => - ok({ ...promptAdmission(request), admittedSeq: 2 }) as never, + const prompt = spyOn(client.session, "prompt").mockImplementation( + (request) => ok({ ...promptAdmission(request), admittedSeq: 2 }) as never, ) await transport.queuePromptTurn({ agent: "review", @@ -631,10 +629,7 @@ describe("V2 mini transport", () => { files: [], includeFiles: false, }) - expect(client.session.switchAgent).toHaveBeenCalledWith( - { sessionID: "ses_1", agent: "review" }, - expect.anything(), - ) + expect(client.session.switchAgent).toHaveBeenCalledWith({ sessionID: "ses_1", agent: "review" }, expect.anything()) expect(prompt).toHaveBeenCalledWith(expect.objectContaining({ delivery: "queue" }), expect.anything()) events.push({ id: "evt_earlier_admission", @@ -1976,13 +1971,13 @@ describe("V2 mini transport", () => { id: `evt_repeated_success_${index}`, created: index * 3 + 3, type: "session.tool.success", - durable: durable("ses_1", index * 3 + 2), + durable: durable("ses_1", index * 3 + 2, 2), data: { sessionID: "ses_1", assistantMessageID: messageID, callID: "call_repeated", - structured: {}, - content: [], + metadata: {}, + content: [{ type: "text", text: "" }], executed: true, }, }) @@ -2048,15 +2043,14 @@ describe("V2 mini transport", () => { sessionID: "ses_1", assistantMessageID: "msg_progress", callID: "call_progress", - structured: { checkpoint: 1 }, - content: [{ type: "text", text: "partial" }], + metadata: { checkpoint: 1 }, }, }) events.push({ id: "evt_progress_failed", created: 4, type: "session.tool.failed", - durable: durable("ses_1", 3), + durable: durable("ses_1", 3, 2), data: { sessionID: "ses_1", assistantMessageID: "msg_progress", @@ -2077,7 +2071,7 @@ describe("V2 mini transport", () => { ]) expect(commits.at(-1)?.part?.state).toMatchObject({ status: "error", - structured: { checkpoint: 1 }, + metadata: { checkpoint: 1 }, content: [{ type: "text", text: "partial" }], }) await transport.close() @@ -2649,9 +2643,7 @@ describe("V2 mini transport", () => { ], command: { name: "deploy", arguments: "prod" }, }, - files: [ - { type: "file", url: "file:///tmp/context.txt", filename: "context.txt", mime: "text/plain" }, - ], + files: [{ type: "file", url: "file:///tmp/context.txt", filename: "context.txt", mime: "text/plain" }], includeFiles: true, }) @@ -2732,10 +2724,7 @@ describe("V2 mini transport", () => { includeFiles: true, }) - expect(client.session.switchAgent).toHaveBeenCalledWith( - { sessionID: "ses_1", agent: "review" }, - expect.anything(), - ) + expect(client.session.switchAgent).toHaveBeenCalledWith({ sessionID: "ses_1", agent: "review" }, expect.anything()) expect(request).toMatchObject({ sessionID: "ses_1", id: "msg_skill", skill: "tigerstyle" }) expect(command).not.toHaveBeenCalled() expect(prompt).not.toHaveBeenCalled() @@ -2886,7 +2875,7 @@ describe("V2 mini transport", () => { id: "evt_failed_subagent", created: 3, type: "session.tool.failed", - durable: durable("ses_1", 2), + durable: durable("ses_1", 2, 2), data: { sessionID: "ses_1", assistantMessageID: "msg_failed_subagent", @@ -2897,8 +2886,7 @@ describe("V2 mini transport", () => { }, }) - while (!states().some((state) => state.tabs.some((tab) => tab.sessionID === "ses_child_failed"))) - await Bun.sleep(0) + while (!states().some((state) => state.tabs.some((tab) => tab.sessionID === "ses_child_failed"))) await Bun.sleep(0) expect(states().at(-1)?.tabs).toMatchObject([ { sessionID: "ses_child_failed", @@ -2954,8 +2942,7 @@ describe("V2 mini transport", () => { sessionID: "ses_1", assistantMessageID: "msg_subagent", callID: "call_subagent", - structured: { sessionID: "ses_child_progress", status: "running" }, - content: [], + metadata: { sessionID: "ses_child_progress", status: "running" }, }, }) while (!states().some((state) => state.tabs.some((tab) => tab.sessionID === "ses_child_progress"))) @@ -3005,8 +2992,7 @@ describe("V2 mini transport", () => { sessionID: "ses_child_progress", assistantMessageID: "msg_child_tool", callID: "call_child_shell", - structured: { checkpoint: "child" }, - content: [{ type: "text", text: "child partial" }], + metadata: { checkpoint: "child" }, }, }) events.push({ @@ -3025,7 +3011,7 @@ describe("V2 mini transport", () => { id: "evt_child_tool_failed", created: 8, type: "session.tool.failed", - durable: durable("ses_child_progress", 3), + durable: durable("ses_child_progress", 3, 2), data: { sessionID: "ses_child_progress", assistantMessageID: "msg_child_tool", @@ -3058,7 +3044,7 @@ describe("V2 mini transport", () => { commits.find((item) => item.part?.id === "call_child_shell" && item.toolState === "error")?.part?.state, ).toMatchObject({ status: "error", - structured: { checkpoint: "child" }, + metadata: { checkpoint: "child" }, content: [{ type: "text", text: "child partial" }], }) expect( @@ -3145,7 +3131,11 @@ describe("V2 mini transport", () => { { sessionID: "ses_child", label: "Explore", title: "Find files", status: "running" }, ]) - expect(states().at(-1)?.details.ses_child?.commits.filter((item) => item.text === "child answer")).toHaveLength(1) + expect( + states() + .at(-1) + ?.details.ses_child?.commits.filter((item) => item.text === "child answer"), + ).toHaveLength(1) events.push({ id: "evt_child_text_replayed", @@ -3159,7 +3149,11 @@ describe("V2 mini transport", () => { }, }) await Bun.sleep(0) - expect(states().at(-1)?.details.ses_child?.commits.filter((item) => item.text === "child answer")).toHaveLength(1) + expect( + states() + .at(-1) + ?.details.ses_child?.commits.filter((item) => item.text === "child answer"), + ).toHaveLength(1) events.push({ id: "evt_child_text_suffix", @@ -3172,7 +3166,9 @@ describe("V2 mini transport", () => { delta: " suffix", }, }) - while (!states().some((state) => state.details.ses_child?.commits.some((item) => item.text === "child answer suffix"))) + while ( + !states().some((state) => state.details.ses_child?.commits.some((item) => item.text === "child answer suffix")) + ) await Bun.sleep(0) events.push({ @@ -3437,7 +3433,7 @@ describe("V2 mini transport", () => { status: "completed" as const, input: { command: "projected" }, content: [{ type: "text" as const, text: "projected result" }], - structured: {}, + metadata: {}, }, time: { created: 1, ran: 1, completed: 2 }, }, @@ -3488,12 +3484,12 @@ describe("V2 mini transport", () => { id: "evt_success_terminal", created: 2, type: "session.tool.success", - durable: durable("ses_child", 2), + durable: durable("ses_child", 2, 2), data: { sessionID: "ses_child", assistantMessageID: "msg_tool_projected", callID: "call_terminal", - structured: {}, + metadata: {}, content: [{ type: "text", text: "found" }], executed: true, }, @@ -3651,13 +3647,13 @@ describe("V2 mini transport", () => { id: "evt_parent_success", created: 0, type: "session.tool.success", - durable: durable("ses_1", 1), + durable: durable("ses_1", 1, 2), data: { sessionID: "ses_1", assistantMessageID: "msg_parent_a", callID: "call_sub", - structured: { sessionID: "ses_child", status: "running", output: "" }, - content: [], + metadata: { sessionID: "ses_child", status: "running", output: "" }, + content: [{ type: "text", text: "" }], executed: true, }, }) @@ -3738,7 +3734,7 @@ describe("V2 mini transport", () => { status: "completed" as const, input: { agent: "explore", description: "Find things", prompt: "go" }, content: [{ type: "text" as const, text: "done" }], - structured: { sessionID: "ses_child", status: "completed", output: "done" }, + metadata: { sessionID: "ses_child", status: "completed", output: "done" }, }, time: { created: 1, ran: 1, completed: 2 }, }, diff --git a/packages/tui/test/mini/tool.test.ts b/packages/tui/test/mini/tool.test.ts index fb005cdf13..62a28c08ce 100644 --- a/packages/tui/test/mini/tool.test.ts +++ b/packages/tui/test/mini/tool.test.ts @@ -28,7 +28,7 @@ describe("Mini tool presentation", () => { state: { status: "completed", input: { patchText: "*** Begin Patch\n*** End Patch" }, - structured: { + metadata: { files: [ { type: "update", @@ -45,7 +45,7 @@ describe("Mini tool presentation", () => { ).toMatchObject({ name: "patch", state: { - structured: { + metadata: { files: [ { status: "modified", @@ -66,27 +66,25 @@ describe("Mini tool presentation", () => { state: { status: "running", input: { subagent_type: "explore", description: "Inspect" }, - structured: {}, - content: [], + metadata: {}, }, time: { created: 1, ran: 1 }, }), ).toMatchObject({ name: "subagent", state: { input: { agent: "explore" } } }) }) - test("renders the skill name from structured metadata with the input id as fallback", () => { - const skill = (structured: { name?: string }) => ({ - type: "tool" as const, - id: "call-skill", - name: "skill", - state: { - status: "completed" as const, - input: { id: "tigerstyle" }, - structured, - content: [], - }, - time: { created: 1, ran: 1, completed: 2 }, - }) + test("renders the skill name from tool metadata with the input id as fallback", () => { + const skill = (metadata: { name?: string }) => + canonicalToolPart( + "skill", + { + status: "completed", + input: { id: "tigerstyle" }, + metadata, + content: [{ type: "text", text: "" }], + }, + "call-skill", + ) expect(toolInlineInfo(skill({ name: "effect" })).title).toBe('Skill "effect"') expect(toolInlineInfo(skill({})).title).toBe('Skill "tigerstyle"') @@ -112,8 +110,8 @@ describe("Mini tool presentation", () => { canonicalToolPart("glob", { status: "completed", input: { pattern: "*.ts" }, - structured: { count: 3 }, - content: [], + metadata: { count: 3 }, + content: [{ type: "text", text: "" }], }), ).description, ).toBe("3 matches") @@ -122,8 +120,8 @@ describe("Mini tool presentation", () => { canonicalToolPart("grep", { status: "completed", input: { pattern: "needle" }, - structured: { matches: 1 }, - content: [], + metadata: { matches: 1 }, + content: [{ type: "text", text: "" }], }), ).description, ).toBe("1 match") diff --git a/packages/tui/test/util/tool-display.test.ts b/packages/tui/test/util/tool-display.test.ts index 1201dba257..cedde400c3 100644 --- a/packages/tui/test/util/tool-display.test.ts +++ b/packages/tui/test/util/tool-display.test.ts @@ -40,19 +40,19 @@ describe("webSearchProviderLabel", () => { }) describe("toolDisplayMetadata", () => { - test("returns structured metadata for non-pending states", () => { - const structured = { provider: "parallel", numResults: 3 } + test("returns tool metadata for non-pending states", () => { + const metadata = { provider: "parallel", numResults: 3 } - expect(toolDisplayMetadata({ status: "running", structured })).toBe(structured) - expect(toolDisplayMetadata({ status: "completed", structured })).toBe(structured) - expect(toolDisplayMetadata({ status: "error", structured })).toBe(structured) + expect(toolDisplayMetadata({ status: "running", metadata })).toBe(metadata) + expect(toolDisplayMetadata({ status: "completed", metadata })).toBe(metadata) + expect(toolDisplayMetadata({ status: "error", metadata })).toBe(metadata) }) test("does not expose pending or malformed metadata", () => { - expect(toolDisplayMetadata({ status: "streaming", structured: { provider: "exa" } })).toEqual({}) + expect(toolDisplayMetadata({ status: "streaming", metadata: { provider: "exa" } })).toEqual({}) expect(toolDisplayMetadata({ status: "completed" })).toEqual({}) - expect(toolDisplayMetadata({ status: "completed", structured: null })).toEqual({}) - expect(toolDisplayMetadata({ status: "completed", structured: [] })).toEqual({}) + expect(toolDisplayMetadata({ status: "completed", metadata: null })).toEqual({}) + expect(toolDisplayMetadata({ status: "completed", metadata: [] })).toEqual({}) expect(toolDisplayMetadata(undefined)).toEqual({}) }) }) diff --git a/packages/www/content/docs/build/plugins.mdx b/packages/www/content/docs/build/plugins.mdx index e5adbc92d7..74c7a6e5e9 100644 --- a/packages/www/content/docs/build/plugins.mdx +++ b/packages/www/content/docs/build/plugins.mdx @@ -248,7 +248,7 @@ mutable fields: | `ctx.aisdk.hook("language", callback)` | `language`, after inspecting `model`, `sdk`, and `options` | | `ctx.session.hook("request", callback)` | `system`, `messages`, and the `tools` record immediately before model dispatch | | `ctx.tool.hook("execute.before", callback)` | `input`, before the selected tool executes | -| `ctx.tool.hook("execute.after", callback)` | `result`, `output`, and `outputPaths`, after execution settles | +| `ctx.tool.hook("execute.after", callback)` | Terminal `content`, `metadata`, and `outputPaths`; `error` on failure | For example, remove a tool from selected model requests and normalize another tool's input: @@ -278,51 +278,66 @@ handle expected errors inside the callback. ### Add a tool -Pass a tool declaration to `tools.add`. Define its input with JSON Schema and -use an async executor: +Create an executable tool with `Tool.make`, then register it with a name +and registration options. Define its input with JSON Schema and use an async +executor: ```js title=".opencode/plugins/greeting.js" import { Plugin } from "@opencode-ai/plugin/v2" +import { Tool } from "@opencode-ai/plugin/v2/tool" export default Plugin.define({ id: "acme.greeting", setup: async (ctx) => { await ctx.tool.transform((tools) => { - tools.add({ - name: "greeting", - description: "Create a greeting", - jsonSchema: { - type: "object", - properties: { - name: { type: "string" }, + tools.add( + "greeting", + Tool.make({ + description: "Create a greeting", + input: { + type: "object", + properties: { + name: { type: "string" }, + }, + required: ["name"], + additionalProperties: false, }, - required: ["name"], - additionalProperties: false, - }, - execute: async ({ name }) => { - const text = `Hello, ${name}!` - return { - structured: { greeting: text }, - content: [{ type: "text", text }], - } - }, - }) + output: { + type: "object", + properties: { greeting: { type: "string" } }, + required: ["greeting"], + additionalProperties: false, + }, + execute: async ({ name }) => { + const text = `Hello, ${name}!` + return { + output: { greeting: text }, + content: text, + } + }, + }), + ) }) }, }) ``` -Unsupported characters in tool and group names are normalized to underscores. -The resulting exposed key must begin with a letter and contain at most 64 -letters, digits, underscores, or hyphens. Set `options` on the declaration to -configure registration with `{ group, deferred }`: +Unsupported characters in tool names are normalized to underscores. Namespace +segments must begin with a letter, contain at most 64 letters, digits, +underscores, or hyphens, and are joined with dots. Pass the optional third +argument to `tools.add` to configure the registration with +`{ namespace, codemode }`: -- `group` prefixes and groups the exposed tool name. -- `deferred: true` makes the tool available through the deferred `execute` - tool instead of exposing it directly. +- `namespace` prefixes and groups the exposed tool name. +- `codemode` defaults to `true` and makes the tool available through the + `execute` CodeMode tool. Set `codemode: false` to expose it directly to the + provider. The executor receives a second context argument containing `sessionID`, -`agent`, `assistantMessageID`, and `toolCallID`. +`agent`, `messageID`, `callID`, and `progress`. A tool with `output` +must return `output`; Effect and Standard Schema codecs validate it, while raw +JSON Schema definitions enforce JSON compatibility only. A tool +without `output` returns model-visible `content` instead. ### Add a command diff --git a/specs/v2/README.md b/specs/v2/README.md index 49fe529fa6..33a91df321 100644 --- a/specs/v2/README.md +++ b/specs/v2/README.md @@ -23,7 +23,7 @@ Generated clients follow the assembled public `HttpApi`. GitHub issues own activ | Document | Job | | ----------------------- | --------------------------------------------------------------------------------------- | | [Session](./session.md) | Explain prompt admission, execution, instructions, compaction, and recovery boundaries. | -| [Tools](./tools.md) | Explain tool construction, registration, execution, and settlement laws. | +| [Tools](./tools.md) | Explain tool construction, registration, execution, and outcome laws. | ## Decisions And Proposals diff --git a/specs/v2/schema-changelog.md b/specs/v2/schema-changelog.md index 3e760ad00d..1ad9151a01 100644 --- a/specs/v2/schema-changelog.md +++ b/specs/v2/schema-changelog.md @@ -2,6 +2,19 @@ Status: **Historical pre-release compatibility ledger.** Older entries retain the names and behavior that were accurate when written; current contracts live in Protocol, Schema, Core, and the indexed specifications. +## 2026-07-22: Canonical Tool Results + +- Bump `session.tool.success` and `session.tool.failed` to version 2. Success stores exactly non-empty model `content` plus optional JSON `metadata`; failure stores one `error` plus the final bounded partial snapshot (`content?`, `metadata?`). The generic `structured` and `result` fields are removed. +- Rename the ephemeral `session.tool.progress` field `structured` to `metadata`; progress is one metadata replacement snapshot. Model content belongs exclusively to terminal outcomes. +- Change projected `SessionMessage.ToolState`: completed is `{ input, content, metadata? }` with non-empty content, error is `{ input, error, content?, metadata? }`, running renames `structured` to `metadata`. Provider replay derives wire values from canonical content. +- Move provider-hosted result payloads into provider-owned result state (`providerResultState.result`); Anthropic server-tool round-trips read it during lowering. OpenAI continues replaying from item references. +- Public Plugin API: remove `structured`, projection callbacks, the `Structured` generic, `Tool.Failure.metadata`, and the exported `Tool.settle`; tool responses carry schema-validated `output`, model-visible `content`, and optional JSON `metadata`. Code Mode receives the validated encoded output. + +Compatibility: + +- `20260722170000_canonical_tool_results` rewrites projected assistant tool rows in place: terminal content is preserved (or synthesized once from the old `structured`/`result`), compact values move to `metadata` only where tools now declare projections, and hosted payloads are copied into `providerResultState.result`. Old-version tool events fall out of the durable manifest and are skipped on read; no event rows are deleted. +- Promise and Effect client surfaces are regenerated. The legacy JavaScript SDK regenerates on the branch where the V1 package exists. + ## 2026-07-10: Replace Instruction Checkpoints With Value Deltas - Replace rendered `session.instructions.updated.1` prose with `session.instructions.updated.2 { delta }`, where values are SHA-256 hashes and the literal `"removed"` means removal. @@ -74,7 +87,7 @@ Compatibility: - No stored event row, database, or runtime publish behavior change; runtime already attaches the envelope only after durable commit/replay. - Generated clients now model the existing invariant: durable events carry `durable`, live-only events do not. -## 2026-07-03: Declare Event Durability At Definition Level +## 2026-07-03: Declare Event Durability At Tool Level - Add explicit `Event.durable(...)` and `Event.ephemeral(...)` definition constructors. - Preserve the existing durable and live-only event classifications while deriving durable inventories from definition metadata instead of hand-maintained lists. diff --git a/specs/v2/session.md b/specs/v2/session.md index 763d580719..63dcadcac1 100644 --- a/specs/v2/session.md +++ b/specs/v2/session.md @@ -43,13 +43,13 @@ The managed server provides graceful restart continuity through private Session Before each Step, the runner reloads Session History, resolves the selected agent and model, prepares instructions, and materializes tools. Most Steps make one Physical Attempt; overflow-triggered compaction recovery may rebuild the same Step for one additional provider request. -Each complete local tool call is durable before side effects begin. Local calls start eagerly and may run concurrently, but settlement publication remains serialized. Every local and hosted call reaches durable success or failure before the Step publishes its single terminal ended or failed event. +Each complete local tool call is durable before side effects begin. Local calls start eagerly and may run concurrently, but terminal outcome publication remains serialized. Every local and hosted call reaches durable success or failure before the Step publishes its single terminal ended or failed event. Tool calls belong to their assistant message. `callID` is unique only within that Step, so durable tool events also carry `assistantMessageID`. Before `runStep` assembles its provider request, orphan reconciliation fails tool calls still projected as streaming or running from an earlier process. It preserves the original assistant attribution and never replays ambiguous side effects. -After local settlement, continuation reloads projected history and begins a new Step. The runner never delegates orchestration to an in-memory tool loop. +After a local outcome, continuation reloads projected history and begins a new Step. The runner never delegates orchestration to an in-memory tool loop. ## Retry Is Narrow And Observable diff --git a/specs/v2/tools.md b/specs/v2/tools.md index 2b30c01991..592223a20c 100644 --- a/specs/v2/tools.md +++ b/specs/v2/tools.md @@ -1,28 +1,28 @@ # V2 Tools -Status: **Current semantic overview.** The Plugin package owns the public tool type; Core owns registration, settlement, and generic output bounding. +Status: **Current semantic overview.** The Plugin package owns the public tool type; Core owns registration, execution, and generic output bounding. -## Tool Declarations +## Tools -V2 has one structural declaration for locally executable tools. Typed tools declare schemas, execution, and optional model-facing projection together: +V2 has one structural tool value for locally executable tools. Typed tools declare schemas and execution together: ```ts const read = Tool.make({ description: "Read a file", input: Schema.Struct({ path: Schema.String }), output: Schema.Struct({ content: Schema.String }), - execute: ({ path }, context) => readFile(path, context), - toModelOutput: ({ output }) => [{ type: "text", text: output.content }], + execute: ({ path }, context) => + readFile(path, context).pipe(Effect.map((output) => ({ output, content: output.content }))), }) ``` -`structured` and `toStructuredOutput` may expose a smaller validated result than the complete execution output. Dynamic MCP and manifest tools use the same declaration with runtime JSON Schema. +One tool response may carry three values: the declared, schema-validated `output` is the ephemeral machine value Code Mode receives; `content` is the model-facing value stored durably; and optional `metadata` is compact JSON for tool-specific UI. A tool without `output` intentionally returns only model-visible `content` and optional `metadata`. Dynamic MCP and manifest tools use the same tool shape with runtime JSON Schema. Built-ins and statically authored plugin tools use this same constructor and execution contract. -`Tool.Definition` is a transparent structural value with exactly one executor. Effect schemas and schemas implementing both Standard Schema V1 and Standard JSON Schema V1 are accepted. The Tool module derives model definitions and interprets invocations for the registry; callers normally rely on `Tool.make` inference rather than naming the declaration type. +`Tool.Tool` is a transparent structural value with exactly one `execute` function. Effect schemas and schemas implementing both Standard Schema V1 and Standard JSON Schema V1 are accepted. The Tool module derives inert model-facing `LLM.ToolDefinition` values and executes tools for the registry; callers normally rely on `Tool.make` inference rather than naming the nested type. -Standard input schemas validate model input into the handler value. Standard output schemas validate the handler result into the model-facing value. Effect codecs retain their native decode-input and encode-output directions. +Standard input schemas validate model input into the tool input. Standard output schemas validate the tool response's `output` into the Code Mode machine value. Effect codecs retain their native decode-input and encode-output directions. Input and output codecs are self-contained. Schema conversion cannot require services. Tool dependencies are acquired during construction and captured by `execute`. @@ -34,14 +34,13 @@ Every local tool receives the same concrete invocation context: interface Tool.Context { readonly sessionID: Session.ID readonly agent: Agent.ID - readonly assistantMessageID: SessionMessage.ID - readonly toolCallID: string + readonly messageID: SessionMessage.ID + readonly callID: string + readonly progress: (update: Progress) => Effect.Effect } ``` -`assistantMessageID` is the durable ID of the assistant message containing the call. The Session runner owns this association and supplies the complete context to the registry; the registry does not infer it. - -Durable events call the invocation identifier `callID`; `Tool.Context.toolCallID` is the same value at the executor boundary. +`messageID` is the durable ID of the assistant message containing the call. The Session runner owns this association and supplies the complete context to the registry; the registry does not infer it. `callID` carries the same invocation identifier durable events use. Decoded tool input is passed separately to `execute`. Raw provider input and domain services do not belong in the invocation context. @@ -65,7 +64,8 @@ The record key is the authored name. Registration normalizes it before deriving ```ts interface Tools { readonly register: ( - tools: Readonly>, + tools: Readonly>, + options?: Tool.RegisterOptions, ) => Effect.Effect } ``` @@ -105,8 +105,8 @@ yield * agent: context.agent, source: { type: "tool", - messageID: context.assistantMessageID, - callID: context.toolCallID, + messageID: context.messageID, + callID: context.callID, }, action: "grep", resources: [input.pattern], @@ -126,30 +126,32 @@ Sharing a tool type does not imply equal authority. Built-ins and trusted Locati ## Requests Capture Tool Values -The Location-scoped registry owns effective lookup and settlement. For each local call it: +The Location-scoped registry owns effective lookup and execution through one request-scoped snapshot pairing advertised LLM definitions with captured tools. For each local call it: 1. Resolves one effective named registration. 2. Decodes provider input with the input codec. -3. Invokes the tool with the runner-supplied context. -4. Encodes the returned output with the output codec. -5. Projects encoded output into model-facing content. -6. Bounds the complete model-facing output. -7. Runs `execute.after` hooks with the bounded settlement. -8. Returns the settlement to the runner for durable publication. +3. Executes the tool with the runner-supplied context. +4. Encodes the returned output with the output codec; the encoded value is the ephemeral machine output for Code Mode. +5. Normalizes the tool response into canonical non-empty model content and optional JSON metadata. +6. Bounds the model content; validates metadata, dropping invalid or oversized values with a warning rather than failing the call. +7. Runs `execute.after` hooks with the canonical outcome and managed output paths. +8. Returns one `ToolOutcome` — completed with output, content, and optional metadata, or an error with an optional final partial snapshot — to the runner for durable publication. -Invalid input never invokes the tool. Invalid output never produces a successful settlement. +Invalid input never executes the tool. Invalid output never produces a successful execution. -`toModelOutput` is pure and total. When omitted, the encoded output remains structured output; an encoded string is also projected as text. Projection does not receive invocation identity because presentation depends only on validated input and output. +When an output-bearing tool omits `content`, an encoded string becomes one text item and any other encoded JSON is serialized once. A tool without `output` must provide non-empty model content. -Each model request captures the effective registered `Tool` value for every advertised name. Settlement executes those captured values; later registration changes affect later requests. +Each model request captures the effective registration for every advertised name. Execution uses those captured tools; later registration changes affect later requests. Unknown, hook-removed, and final-Step calls fail individually through the same execution seam; the final Step retains tool definitions with `toolChoice: "none"` where the provider supports it so the cached prompt prefix survives. + +Durable terminal events are self-contained: success stores exactly the non-empty model content plus optional metadata; failure stores one error plus the final bounded snapshot of partial progress. Provider replay derives its wire value from canonical content; provider-hosted payloads that a protocol requires verbatim live in provider-owned result state, never in a generic result field. ## Producers And The Registry Own Different Limits Producers may cap capture or spool data before a complete tool result exists. For example, a process tool may retain output it cannot keep in memory. Producer limits must report their own loss accurately; they are separate from registry bounding and cannot claim to reconstruct bytes already discarded. -After projection, the registry bounds the channel sent to the provider. When content exists, only its textual parts are measured; structured metadata is retained unchanged without being double-counted, and native media remains unchanged under producer-owned limits. When content is empty, the structured output is measured. Oversized provider-facing text or structured output is retained in managed storage and replaced with a bounded text preview while structured metadata and media are preserved; if complete retention fails, settlement fails operationally rather than publishing lossy success. Managed paths never appear in `Tool.make`, tool output schemas, or projection callbacks solely for retention bookkeeping. +After tool execution, the registry bounds the model content sent to the provider: only textual parts are measured, native media remains unchanged under producer-owned limits, and the default cut keeps a head-plus-tail split with the omission marker in the middle. Oversized text is retained in managed storage and replaced with a bounded preview; if complete retention fails, execution fails operationally rather than publishing lossy success. Metadata is validated and measured independently and never becomes an unbounded side channel. Managed paths never appear in `Tool.make` or tool output schemas solely for retention bookkeeping. -`execute.after` hooks receive the bounded settlement and its internal managed paths. Hooks may deliberately transform that settlement; the registry does not apply a second bounding pass afterward. +`execute.after` hooks receive the canonical bounded outcome and its internal managed paths. Hooks may deliberately transform that outcome; changed content is normalized and bounded again before publication. ## Failures Preserve Interruptions @@ -158,15 +160,18 @@ Outcomes remain distinct: - `ToolFailure` is an expected model-visible failure. - Interruption cancels the invocation and is not a tool result. - Unexpected typed errors and defects follow the runner's operational failure policy. -- Unknown and invalid calls become explicit model-visible settlement errors without invoking a handler. +- Unknown and invalid calls become explicit model-visible execution errors without executing a tool. -Leaf tools translate only errors they deliberately classify as recoverable. Broad cause-catching around an executor is invalid because it consumes interruption and defects. +Tools translate only errors they deliberately classify as recoverable. Broad cause-catching around `execute` is invalid because it consumes interruption and defects. ## Laws -- **Single executor:** `Tool.make(config)` can invoke only `config.execute`. -- **Codec boundary:** execution observes decoded input; projection observes encoded output. +- **Single execution:** `Tool.make(config)` can execute only `config.execute`. +- **Codec boundary:** a tool observes decoded input; Code Mode observes the validated encoded output; model content and metadata come from the tool response. +- **Canonical representation:** a completed call has exactly one stored model representation; a failed call has exactly one stored error plus at most one final partial snapshot. Every other view is derived at a named boundary. +- **Metadata opt-in:** absent response metadata produces absent metadata, never a copied output. - **Durable identity:** invocation-owned records use the exact Session, agent, assistant message, and call IDs supplied by the runner. - **Scoped registration:** closing a Scope removes exactly its registration and reveals any prior active overlay. -- **Captured execution:** a call executes the registered `Tool` value advertised in its model request. +- **Captured execution:** a call executes the registered tool advertised in its model request. +- **Per-call rejection:** rejecting one unavailable call cannot fail another call. - **Storage encapsulation:** domain output does not change according to model-output bounding or retention policy. From 02f27251540efeb414dd526f227a27c78000841a Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" <219766164+opencode-agent[bot]@users.noreply.github.com> Date: Thu, 23 Jul 2026 16:20:37 -0500 Subject: [PATCH 10/11] chore: merge dev into v2 (#38563) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Aiden Cline Co-authored-by: opencode-agent[bot] <219766164+opencode-agent[bot]@users.noreply.github.com> Co-authored-by: Dax Raad Co-authored-by: Dax Co-authored-by: opencode-agent[bot] Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Co-authored-by: Nabs Co-authored-by: usrnk1 <7547651+usrnk1@users.noreply.github.com> Co-authored-by: Brendan Allan <14191578+Brendonovich@users.noreply.github.com> Co-authored-by: Aarav Sareen <96787824+arvsrn@users.noreply.github.com> Co-authored-by: Brendan Allan Co-authored-by: Victor Navarro Co-authored-by: Vladimir Glafirov Co-authored-by: AidenGeunGeun Co-authored-by: Mark Co-authored-by: Aiden Cline Co-authored-by: Adam <2363879+adamdotdevin@users.noreply.github.com> Co-authored-by: opencode Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com> Co-authored-by: David Hill <1879069+iamdavidhill@users.noreply.github.com> Co-authored-by: Jay Co-authored-by: Jay <53023+jayair@users.noreply.github.com> Co-authored-by: BB84 <110078428+BB-84C@users.noreply.github.com> Co-authored-by: Dustin Deus Co-authored-by: Frank Co-authored-by: Jack Co-authored-by: Sebastian Co-authored-by: Jérôme Benoit Co-authored-by: Test User Co-authored-by: Simon Klee Co-authored-by: Rahul A Mistry <149420892+ProdigyRahul@users.noreply.github.com> Co-authored-by: Qiping Li Co-authored-by: liqiping Co-authored-by: OpeOginni <107570612+OpeOginni@users.noreply.github.com> Co-authored-by: Matthias Reso <13337103+mreso@users.noreply.github.com> Co-authored-by: tobwen <1864057+tobwen@users.noreply.github.com> Co-authored-by: Daniel Polito Co-authored-by: opencode --- bun.lock | 21 +- nix/hashes.json | 8 +- package.json | 1 + .../performance/timeline-stability/fixture.ts | 2 + .../session-timeline-transport.spec.ts | 6 +- packages/app/e2e/utils/mock-server.ts | 278 ++++++- packages/app/e2e/utils/sse-transport.ts | 38 +- packages/app/package.json | 1 + packages/app/src/context/server-sdk.test.ts | 38 +- packages/app/src/context/server-sdk.tsx | 194 +++-- packages/app/src/utils/server-compat.test.ts | 113 +++ packages/app/src/utils/server-compat.ts | 495 ++++++++++++ packages/app/src/utils/server-health.test.ts | 38 +- packages/app/src/utils/server-health.ts | 28 +- .../app/src/utils/server-protocol.test.ts | 40 + packages/app/src/utils/server-protocol.ts | 35 + packages/app/src/utils/server.ts | 21 + .../app/vendor/opencode-ai-client-1.17.13.tgz | Bin 0 -> 75585 bytes packages/core/package.json | 2 +- packages/core/test/provider-mistral.test.ts | 282 +++++++ packages/desktop/src/main/server.ts | 23 +- packages/session-ui/package.json | 1 + patches/@ai-sdk%2Fmistral@3.0.51.patch | 709 ++++++++++++++++++ 23 files changed, 2280 insertions(+), 94 deletions(-) create mode 100644 packages/app/src/utils/server-compat.test.ts create mode 100644 packages/app/src/utils/server-compat.ts create mode 100644 packages/app/src/utils/server-protocol.test.ts create mode 100644 packages/app/src/utils/server-protocol.ts create mode 100644 packages/app/vendor/opencode-ai-client-1.17.13.tgz create mode 100644 packages/core/test/provider-mistral.test.ts create mode 100644 patches/@ai-sdk%2Fmistral@3.0.51.patch diff --git a/bun.lock b/bun.lock index 24e3c97c4e..fb36652e94 100644 --- a/bun.lock +++ b/bun.lock @@ -61,6 +61,7 @@ "@dnd-kit/helpers": "0.5.0", "@dnd-kit/solid": "0.5.0", "@kobalte/core": "catalog:", + "@opencode-ai/client": "file:vendor/opencode-ai-client-1.17.13.tgz", "@opencode-ai/core": "workspace:*", "@opencode-ai/schema": "workspace:*", "@opencode-ai/sdk": "workspace:*", @@ -350,7 +351,7 @@ "@ai-sdk/google": "3.0.73", "@ai-sdk/google-vertex": "4.0.128", "@ai-sdk/groq": "3.0.31", - "@ai-sdk/mistral": "3.0.27", + "@ai-sdk/mistral": "3.0.51", "@ai-sdk/openai": "3.0.84", "@ai-sdk/openai-compatible": "2.0.41", "@ai-sdk/perplexity": "3.0.26", @@ -470,7 +471,9 @@ "packages/docs": { "name": "@opencode-ai/docs", "devDependencies": { + "effect": "catalog:", "mint": "4.2.666", + "prettier": "3.6.2", }, }, "packages/effect-drizzle-sqlite": { @@ -706,6 +709,7 @@ "version": "1.18.4", "dependencies": { "@kobalte/core": "catalog:", + "@opencode-ai/client": "file:../app/vendor/opencode-ai-client-1.17.13.tgz", "@opencode-ai/core": "workspace:*", "@opencode-ai/sdk": "workspace:*", "@opencode-ai/ui": "workspace:*", @@ -1079,6 +1083,7 @@ "@ai-sdk/xai@3.0.102": "patches/@ai-sdk%2Fxai@3.0.102.patch", "@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch", "@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch", + "@ai-sdk/mistral@3.0.51": "patches/@ai-sdk%2Fmistral@3.0.51.patch", "@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch", "@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch", "solid-js@1.9.10": "patches/solid-js@1.9.10.patch", @@ -1205,7 +1210,7 @@ "@ai-sdk/groq": ["@ai-sdk/groq@3.0.31", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-XbbugpnFmXGu2TlXiq8KUJskP6/VVbuFcnFIGDzDIB/Chg6XHsNnqrTF80Zxkh0Pd3+NvbM+2Uqrtsndk6bDAg=="], - "@ai-sdk/mistral": ["@ai-sdk/mistral@3.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ZXe7nZQgliDdjz5ufH5RKpHWxbN72AzmzzKGbF/z+0K9GN5tUCnftrQRvTRFHA5jAzTapcm2BEevmGLVbMkW+A=="], + "@ai-sdk/mistral": ["@ai-sdk/mistral@3.0.51", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@ai-sdk/provider-utils": "4.0.40" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-83eXY6p0lUFhSuMvNDmTKDuMciK5XDAWDlNh5c0L80tKjmtCFRItA1MZHp4IKe1r7eK8Rb5nN7qtxqMLUFRIRw=="], "@ai-sdk/openai": ["@ai-sdk/openai@3.0.48", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ALmj/53EXpcRqMbGpPJPP4UOSWw0q4VGpnDo7YctvsynjkrKDmoneDG/1a7VQnSPYHnJp6tTRMf5ZdxZ5whulg=="], @@ -6151,7 +6156,9 @@ "@ai-sdk/groq/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="], - "@ai-sdk/mistral/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="], + "@ai-sdk/mistral/@ai-sdk/provider": ["@ai-sdk/provider@3.0.14", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-5X1k57JBJ4H7H1QjX7CnJYAB1I19r/trVZTMcSms7/kLNZ8RaU4Nt2agcwZzv82Hfx6Q7/TOLU7agAKeFfc8cA=="], + + "@ai-sdk/mistral/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.40", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-OL5IrpUm9Y8Dwy+w/vvFwPotS6m52O9W0op2oXgXdCROMJIBalBI0oro6OIBYkPxvm5Xg02GSkoQN25RlR0bnw=="], "@ai-sdk/openai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="], @@ -6581,6 +6588,8 @@ "@openauthjs/openauth/jose": ["jose@5.9.6", "", {}, "sha512-AMlnetc9+CV9asI19zHmrgS/WYsWUwCn2R7RzlbJWD7F9eWYUTGyBmU9o6PxngtLGOiDGPRu+Uc4fhKzbpteZQ=="], + "@opencode-ai/app/@opencode-ai/client": ["@opencode-ai/client@vendor/opencode-ai-client-1.17.13.tgz", {}, "sha512-1cYJikTCrXNhnS2qQ3P3rtdbGqhvJKksswG0amJNqgaeUfz3xDlKEDx+YoIosT7Cqk/AtO18jtip0lYL3TVdHQ=="], + "@opencode-ai/cli/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], "@opencode-ai/console-app/@smithy/eventstream-codec": ["@smithy/eventstream-codec@4.2.7", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.11.0", "@smithy/util-hex-encoding": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-DrpkEoM3j9cBBWhufqBwnbbn+3nf1N9FP6xuVJ+e220jbactKuQgaZwjwP5CP1t+O94brm2JgVMD2atMGX3xIQ=="], @@ -6599,6 +6608,8 @@ "@opencode-ai/desktop/typescript": ["typescript@5.6.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw=="], + "@opencode-ai/session-ui/@opencode-ai/client": ["@opencode-ai/client@../app/vendor/opencode-ai-client-1.17.13.tgz", {}, "sha512-1cYJikTCrXNhnS2qQ3P3rtdbGqhvJKksswG0amJNqgaeUfz3xDlKEDx+YoIosT7Cqk/AtO18jtip0lYL3TVdHQ=="], + "@opencode-ai/session-ui/@solid-primitives/resize-observer": ["@solid-primitives/resize-observer@2.1.3", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.3", "@solid-primitives/rootless": "^1.5.2", "@solid-primitives/static-store": "^0.1.2", "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-zBLje5E06TgOg93S7rGPldmhDnouNGhvfZVKOp+oG2XU8snA+GoCSSCz1M+jpNAg5Ek2EakU5UVQqL152WmdXQ=="], "@opencode-ai/storybook/@types/react": ["@types/react@18.0.25", "", { "dependencies": { "@types/prop-types": "*", "@types/scheduler": "*", "csstype": "^3.0.2" } }, "sha512-xD6c0KDT4m7n9uD4ZHi02lzskaiqcBxf4zi+tXZY98a04wvc0hi/TcCPC2FOESZi51Nd7tlUeOJY8RofL799/g=="], @@ -6927,6 +6938,8 @@ "aggregate-error/indent-string": ["indent-string@5.0.0", "", {}, "sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg=="], + "ai-gateway-provider/@ai-sdk/mistral": ["@ai-sdk/mistral@3.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ZXe7nZQgliDdjz5ufH5RKpHWxbN72AzmzzKGbF/z+0K9GN5tUCnftrQRvTRFHA5jAzTapcm2BEevmGLVbMkW+A=="], + "ai-gateway-provider/@ai-sdk/openai": ["@ai-sdk/openai@3.0.84", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@ai-sdk/provider-utils": "4.0.38" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-cmgbeJL0bbY0yTJH4/AdmP5E7MjWRL9G8UdhIi0JlV/So03o82ORJofW8OzwCZPTORVQblFbpZXYGDcUd9NdUQ=="], "ansi-align/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], @@ -8047,6 +8060,8 @@ "@vitest/expect/@vitest/utils/@vitest/pretty-format": ["@vitest/pretty-format@3.2.4", "", { "dependencies": { "tinyrainbow": "^2.0.0" } }, "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA=="], + "ai-gateway-provider/@ai-sdk/mistral/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="], + "ai-gateway-provider/@ai-sdk/openai/@ai-sdk/provider": ["@ai-sdk/provider@3.0.14", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-5X1k57JBJ4H7H1QjX7CnJYAB1I19r/trVZTMcSms7/kLNZ8RaU4Nt2agcwZzv82Hfx6Q7/TOLU7agAKeFfc8cA=="], "ai-gateway-provider/@ai-sdk/openai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.38", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-/HHGmtKllqjg1OLc023v9w9kK3laW7Z6TzfZukYQWCsGBbzB9p60zTvvpXFVcs44NZBVXL3viOa1HRKUbeee8g=="], diff --git a/nix/hashes.json b/nix/hashes.json index 963d46ecf4..407d7812fb 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,8 +1,8 @@ { "nodeModules": { - "x86_64-linux": "sha256-qt11SKmOjq0KU542QFbs+u7YyJicn4drCcwCdg325yk=", - "aarch64-linux": "sha256-z68doReXTrWS7HeiAjc0btIjAsvzeZZ7hXAlHr0c77Q=", - "aarch64-darwin": "sha256-PILYH1Pi8XBvSkuZ+1sNnUTao5kba+m5Z8iJKx6YXPo=", - "x86_64-darwin": "sha256-KpcJzP4m0SUavu/WaSffgzOxrHq8ljdy0GOzs9p16lo=" + "x86_64-linux": "sha256-0kcwV34P2C3yKg2eG9W2nW+OedrSBb+1TdpuUeYtauY=", + "aarch64-linux": "sha256-yHVygApQchAB34wrtFR4GU0CkmZOlLsl3wsp15u0xzs=", + "aarch64-darwin": "sha256-DyalcwyK2Wn5R6249keFcNVECbgtjYNjscOFqTi88FI=", + "x86_64-darwin": "sha256-BkGw0GWN9W9q+/g4FYR0MqxUuFP80BPoERO+ypz/arQ=" } } diff --git a/package.json b/package.json index 2110c4d9af..6332861c91 100644 --- a/package.json +++ b/package.json @@ -160,6 +160,7 @@ "@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch", "solid-js@1.9.10": "patches/solid-js@1.9.10.patch", "@ai-sdk/xai@3.0.102": "patches/@ai-sdk%2Fxai@3.0.102.patch", + "@ai-sdk/mistral@3.0.51": "patches/@ai-sdk%2Fmistral@3.0.51.patch", "gcp-metadata@8.1.2": "patches/gcp-metadata@8.1.2.patch", "pacote@21.5.0": "patches/pacote@21.5.0.patch", "@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch", diff --git a/packages/app/e2e/performance/timeline-stability/fixture.ts b/packages/app/e2e/performance/timeline-stability/fixture.ts index 5095d95db0..df67da5a66 100644 --- a/packages/app/e2e/performance/timeline-stability/fixture.ts +++ b/packages/app/e2e/performance/timeline-stability/fixture.ts @@ -97,6 +97,7 @@ export async function setupTimeline( locale?: string deviceScaleFactor?: number seedHistory?: boolean + protocol?: "v1" | "v2" } = {}, ) { const sessions = input.sessions ?? [session()] @@ -114,6 +115,7 @@ export async function setupTimeline( retry: input.eventRetry ?? 20, }) await mockOpenCodeServer(page, { + protocol: input.protocol, directory, project: project(), provider: provider(), diff --git a/packages/app/e2e/regression/session-timeline-transport.spec.ts b/packages/app/e2e/regression/session-timeline-transport.spec.ts index 850e966d0b..778ff3a3af 100644 --- a/packages/app/e2e/regression/session-timeline-transport.spec.ts +++ b/packages/app/e2e/regression/session-timeline-transport.spec.ts @@ -89,8 +89,8 @@ test("reconnects after a stream error", async ({ page }) => { expect((await timeline.transport.connections())[0]?.endedBy).toBe("error") }) -test("records event IDs and reconnect Last-Event-ID headers", async ({ page }) => { - const timeline = await setupTimeline(page, { eventRetry: 10 }) +test("does not request replay when reconnecting the volatile V2 event stream", async ({ page }) => { + const timeline = await setupTimeline(page, { eventRetry: 10, protocol: "v2" }) const first = await timeline.transport.send(partUpdated(textPart("prt_transport_id", "event with id")), { id: "timeline-event-7", }) @@ -100,7 +100,7 @@ test("records event IDs and reconnect Last-Event-ID headers", async ({ page }) = const connection = await timeline.transport.waitForConnection({ after: first.connectionID }) expect(first.eventID).toBe("timeline-event-7") - expect(connection.headers["last-event-id"]).toBe("timeline-event-7") + expect(connection.headers["last-event-id"]).toBeUndefined() }) test("passes through non-event fetches", async ({ page }) => { diff --git a/packages/app/e2e/utils/mock-server.ts b/packages/app/e2e/utils/mock-server.ts index 34c60ba7f4..5cca83895f 100644 --- a/packages/app/e2e/utils/mock-server.ts +++ b/packages/app/e2e/utils/mock-server.ts @@ -4,6 +4,7 @@ const emptyList = new Set(["/skill", "/command", "/lsp", "/formatter", "/vcs/sta const emptyObject = new Set(["/global/config", "/config", "/provider/auth", "/mcp", "/experimental/resource"]) export interface MockServerConfig { + protocol?: "v1" | "v2" provider: unknown directory: string project: unknown @@ -53,8 +54,20 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) { if (url.port !== targetPort && url.port !== appPort) return route.fallback() const path = url.pathname - if (path === "/global/event" || path === "/event") return sse(route, config.events?.(), config.eventRetry) - if (path === "/global/health") return json(route, { healthy: true }) + if (path === "/global/event" || path === "/event" || path === "/api/event") { + const events = config.events?.() + return sse( + route, + path === "/api/event" + ? [{ id: "evt_mock_connected", type: "server.connected", data: {} }, ...(events?.map(currentEvent) ?? [])] + : events, + config.eventRetry, + ) + } + if (path === "/global/health") + return config.protocol === "v2" ? json(route, {}, undefined, 404) : json(route, { healthy: true }) + if (path === "/api/health" && config.protocol === "v2") + return json(route, { healthy: true, version: "2.0.0", pid: 1 }) if (path === "/experimental/capabilities") return json(route, { backgroundSubagents: true }) if (path === "/permission") return json(route, typeof config.permissions === "function" ? config.permissions() : (config.permissions ?? [])) @@ -83,10 +96,129 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) { }, data: [], }) + if (path === "/api/agent") + return json(route, { + location: location(config), + data: [ + { + id: "build", + name: "Build", + mode: "primary", + hidden: false, + request: { settings: {}, headers: {}, body: {} }, + permissions: [], + }, + ], + }) + if (path === "/api/command") return json(route, { location: location(config), data: [] }) + if (path === "/api/mcp") return json(route, { location: location(config), data: [] }) + if (path === "/api/mcp/resource") + return json(route, { location: location(config), data: { resources: [], templates: [] } }) + const integration = path.match(/^\/api\/integration\/([^/]+)$/)?.[1] + if (integration && route.request().method() === "GET") + return json(route, { + location: location(config), + data: { id: integration, name: integration, methods: [{ type: "key", label: "API key" }], connections: [] }, + }) + if (/^\/api\/integration\/[^/]+\/connect\/key$/.test(path) && route.request().method() === "POST") + return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } }) + if (path === "/api/project") return json(route, [config.project]) + if (path === "/api/project/current") + return json(route, { id: (config.project as { id?: string }).id, directory: config.directory }) + if (path.startsWith("/api/project/") && route.request().method() === "PATCH") return json(route, config.project) + if (path === "/api/path") + return json(route, { + state: config.directory, + config: config.directory, + worktree: config.directory, + directory: config.directory, + home: "C:/OpenCode", + }) + if (path === "/api/permission/request") + return json(route, { + location: location(config), + data: (typeof config.permissions === "function" ? config.permissions() : (config.permissions ?? [])).map( + currentPermission, + ), + }) + if (path === "/api/question/request") + return json(route, { + location: location(config), + data: typeof config.questions === "function" ? config.questions() : (config.questions ?? []), + }) + if (path === "/api/vcs") + return json(route, { location: location(config), data: { branch: "main", defaultBranch: "main" } }) + if (path === "/api/vcs/status") return json(route, { location: location(config), data: [] }) + if (path === "/api/vcs/diff") return json(route, { location: location(config), data: config.vcsDiff ?? [] }) + if (path === "/api/pty/shells") return json(route, { location: location(config), data: [] }) + if (/^\/api\/pty\/[^/]+\/connect-token$/.test(path)) + return json(route, { location: location(config), data: { ticket: "e2e-ticket", expires_in: 60 } }) if (emptyObject.has(path)) return json(route, {}) if (emptyList.has(path)) return json(route, []) + if (path === "/api/session") { + const directory = url.searchParams.get("directory") + const parentID = url.searchParams.get("parentID") + const limit = Number(url.searchParams.get("limit") ?? 50) + const offset = Number(url.searchParams.get("cursor") ?? 0) + const sessions = config.sessions + .filter((session) => !directory || session.directory === directory) + .filter((session) => parentID !== "null" || session.parentID === undefined) + .filter((session) => { + const search = url.searchParams.get("search")?.toLowerCase() + return ( + !search || + String(session.title ?? "") + .toLowerCase() + .includes(search) + ) + }) + const ordered = url.searchParams.get("order") === "asc" ? sessions.toReversed() : sessions + const data = ordered.slice(offset, offset + limit) + const next = offset + limit < ordered.length ? String(offset + limit) : undefined + return json(route, { + data: data.map((session) => currentSession(session, config.directory)), + cursor: { next }, + }) + } + if (path === "/api/session/active") { + const statuses = (config.sessionStatus ?? {}) as Record + return json(route, { + data: Object.fromEntries( + Object.entries(statuses).flatMap(([id, status]) => + status.type === "idle" ? [] : [[id, { type: "running" }]], + ), + ), + }) + } + if (/^\/api\/session\/[^/]+\/shell$/.test(path) && route.request().method() === "POST") { + return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } }) + } + if (/^\/api\/session\/[^/]+\/question\/[^/]+\/(reply|reject)$/.test(path) && route.request().method() === "POST") { + return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } }) + } + if (/^\/api\/session\/[^/]+\/permission\/[^/]+\/reply$/.test(path) && route.request().method() === "POST") { + return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } }) + } + if ( + /^\/api\/session\/[^/]+\/(archive|rename|interrupt|revert\/clear|revert\/commit)$/.test(path) && + route.request().method() === "POST" + ) { + return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } }) + } + if (/^\/api\/session\/[^/]+$/.test(path) && route.request().method() === "DELETE") { + return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } }) + } if (path in staticRoutes) return json(route, staticRoutes[path]) + const currentSessionMatch = path.match(/^\/api\/session\/([^/]+)$/) + if (currentSessionMatch) { + const session = config.sessions.find((item) => item.id === currentSessionMatch[1]) + if (!session) return json(route, { error: "Session not found" }, undefined, 404) + return json(route, { + data: currentSession(session, config.directory), + }) + } + const sessionMatch = path.match(/^\/session\/([^/]+)$/) if (sessionMatch) { const session = config.sessions.find((s) => s.id === sessionMatch[1]) @@ -107,6 +239,24 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) { if (/^\/session\/[^/]+\/(children|diff)$/.test(path)) return json(route, []) + const currentMessagesMatch = path.match(/^\/api\/session\/([^/]+)\/message$/) + if (currentMessagesMatch) { + const token = url.searchParams.get("cursor") ?? undefined + const before = token ? cursors.get(token) : undefined + if (token && !before) return json(route, { error: "Invalid cursor" }, undefined, 400) + config.onMessages?.({ sessionID: currentMessagesMatch[1], before, phase: "start" }) + await config.beforeMessagesResponse?.({ sessionID: currentMessagesMatch[1]!, before }) + if (config.messageDelay !== undefined) await new Promise((resolve) => setTimeout(resolve, config.messageDelay)) + const pageData = config.pageMessages(currentMessagesMatch[1], Number(url.searchParams.get("limit") ?? 50), before) + config.onMessages?.({ sessionID: currentMessagesMatch[1], before, phase: "end" }) + const cursor = pageData.cursor ? `cursor_${++nextCursor}` : undefined + if (cursor) cursors.set(cursor, pageData.cursor!) + return json(route, { + data: pageData.items.map(currentMessage).reverse(), + cursor: { next: cursor }, + }) + } + const messagesMatch = path.match(/^\/session\/([^/]+)\/message$/) if (messagesMatch) { const token = url.searchParams.get("before") ?? undefined @@ -129,6 +279,115 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) { }) } +function location(config: MockServerConfig) { + return { + directory: config.directory, + project: { id: (config.project as { id?: string }).id, directory: config.directory }, + } +} + +function currentPermission(value: unknown) { + const permission = value as Record + if (permission.action) return permission + const tool = permission.tool as { messageID?: string; callID?: string } | undefined + return { + id: permission.id, + sessionID: permission.sessionID, + action: permission.permission, + resources: permission.patterns ?? [], + save: permission.always, + metadata: permission.metadata, + source: + tool?.messageID && tool.callID ? { type: "tool", messageID: tool.messageID, callID: tool.callID } : undefined, + } +} + +export function currentSession(session: { id: string } & Record, fallbackDirectory?: string) { + const time = session.time && typeof session.time === "object" ? session.time : {} + return { + id: session.id, + parentID: session.parentID, + projectID: session.projectID ?? "project", + agent: session.agent ?? "build", + model: session.model ?? { id: "mock-model", providerID: "mock-provider" }, + cost: session.cost ?? 0, + tokens: session.tokens ?? { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { + created: "created" in time && typeof time.created === "number" ? time.created : 0, + updated: "updated" in time && typeof time.updated === "number" ? time.updated : 0, + ...(session.time && typeof session.time === "object" && "archived" in session.time + ? { archived: session.time.archived } + : {}), + }, + title: session.title ?? session.id, + location: { + directory: typeof session.directory === "string" ? session.directory : fallbackDirectory, + ...(typeof session.workspaceID === "string" ? { workspaceID: session.workspaceID } : {}), + }, + subpath: session.path, + revert: session.revert, + } +} + +function currentMessage(value: unknown) { + const item = value as { + info: Record & { id: string; role: "user" | "assistant"; time: { created: number } } + parts: Array & { type: string }> + } + if (item.info.role === "user") { + return { + id: item.info.id, + type: "user", + time: item.info.time, + text: item.parts + .flatMap((part) => (part.type === "text" && typeof part.text === "string" ? [part.text] : [])) + .join("\n"), + } + } + return { + id: item.info.id, + type: "assistant", + time: item.info.time, + agent: item.info.agent ?? "build", + model: { id: item.info.modelID ?? "model", providerID: item.info.providerID ?? "provider" }, + cost: item.info.cost, + tokens: item.info.tokens, + error: item.info.error, + content: item.parts.flatMap((part) => { + if (part.type === "text" || part.type === "reasoning") return [{ type: part.type, text: part.text ?? "" }] + if (part.type !== "tool") return [] + const state = part.state as Record + return [ + { + type: "tool", + id: part.id, + name: part.tool, + time: state.time ?? { created: item.info.time.created }, + state: + state.status === "pending" + ? { status: "streaming", input: state.raw ?? JSON.stringify(state.input ?? {}) } + : state.status === "completed" + ? { + status: "completed", + input: state.input ?? {}, + structured: state.metadata ?? {}, + content: [{ type: "text", text: state.output ?? "" }], + } + : state.status === "error" + ? { + status: "error", + input: state.input ?? {}, + structured: state.metadata ?? {}, + content: [], + error: { type: "ToolError", message: state.error ?? "Tool failed" }, + } + : { status: "running", input: state.input ?? {}, structured: state.metadata ?? {}, content: [] }, + }, + ] + }), + } +} + function json(route: Route, body: unknown, headers?: Record, status = 200) { return route.fulfill({ status, @@ -149,3 +408,18 @@ function sse(route: Route, events?: unknown[], retry?: number) { body: `${retry === undefined ? "" : `retry: ${retry}\n\n`}${events?.map((event) => `data: ${JSON.stringify(event)}\n\n`).join("") || ": ok\n\n"}`, }) } + +function currentEvent(input: unknown) { + if (!input || typeof input !== "object" || !("payload" in input)) return input + const envelope = input as { directory?: string; payload?: unknown } + if (!envelope.payload || typeof envelope.payload !== "object") return input + const payload = envelope.payload as { id?: string; type?: string; properties?: unknown } + if (!payload.type) return input + return { + id: payload.id ?? `evt_mock_${Date.now()}`, + created: Date.now(), + type: payload.type, + data: payload.properties ?? {}, + location: envelope.directory && envelope.directory !== "global" ? { directory: envelope.directory } : undefined, + } +} diff --git a/packages/app/e2e/utils/sse-transport.ts b/packages/app/e2e/utils/sse-transport.ts index 55420485f3..15c3577279 100644 --- a/packages/app/e2e/utils/sse-transport.ts +++ b/packages/app/e2e/utils/sse-transport.ts @@ -3,7 +3,7 @@ import type { Page } from "@playwright/test" export type SseConnectionRecord = { id: number url: string - path: "/global/event" | "/event" + path: "/global/event" | "/event" | "/api/event" headers: Record openedAt: number endedAt?: number @@ -93,6 +93,21 @@ export async function installSseTransport( eventOptions.retry === undefined ? "" : `retry: ${eventOptions.retry}\n`, `data: ${JSON.stringify(payload)}\n\n`, ].join("") + const currentEvent = (input: unknown) => { + if (!input || typeof input !== "object" || !("payload" in input)) return input + const envelope = input as { directory?: string; payload?: unknown } + if (!envelope.payload || typeof envelope.payload !== "object") return input + const payload = envelope.payload as { id?: string; type?: string; properties?: unknown } + if (!payload.type) return input + return { + id: payload.id ?? `evt_mock_${Date.now()}`, + created: Date.now(), + type: payload.type, + data: payload.properties ?? {}, + location: + envelope.directory && envelope.directory !== "global" ? { directory: envelope.directory } : undefined, + } + } const acknowledge = ( connection: Connection, bytes: number, @@ -140,15 +155,13 @@ export async function installSseTransport( output.forEach((chunk) => connection.controller.enqueue(chunk)) return acknowledge(connection, input.bytes.length, output.length) } - const encoded = input.deliveries.map((delivery) => ({ - delivery, - bytes: encoder.encode(frame(delivery.payload, delivery.options)), - })) + const encoded = input.deliveries.map((delivery) => { + const payload = connection.path === "/api/event" ? currentEvent(delivery.payload) : delivery.payload + return { delivery, payload, bytes: encoder.encode(frame(payload, delivery.options)) } + }) encoded.forEach((item) => marker(item.delivery.options?.marker)) if (input.burst) { - const bytes = encoder.encode( - encoded.map((item) => frame(item.delivery.payload, item.delivery.options)).join(""), - ) + const bytes = encoder.encode(encoded.map((item) => frame(item.payload, item.delivery.options)).join("")) connection.controller.enqueue(bytes) return encoded.map((item) => acknowledge(connection, item.bytes.byteLength, 1, item.delivery.options?.id)) } @@ -161,7 +174,10 @@ export async function installSseTransport( const fetch = (input: RequestInfo | URL, init?: RequestInit) => { const request = new Request(input, init) const url = new URL(request.url) - if (url.origin !== server || (url.pathname !== "/global/event" && url.pathname !== "/event")) + if ( + url.origin !== server || + (url.pathname !== "/global/event" && url.pathname !== "/event" && url.pathname !== "/api/event") + ) return originalFetch(request) const id = ++nextConnectionID @@ -177,6 +193,10 @@ export async function installSseTransport( record.controller = controller connections.push(record) if (retry !== undefined) controller.enqueue(encoder.encode(`retry: ${retry}\n\n`)) + if (url.pathname === "/api/event") + controller.enqueue( + encoder.encode(frame({ id: `evt_mock_connected_${id}`, type: "server.connected", data: {} })), + ) request.signal.addEventListener( "abort", () => { diff --git a/packages/app/package.json b/packages/app/package.json index faf0194461..f6a1bf9d2e 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -53,6 +53,7 @@ "@dnd-kit/helpers": "0.5.0", "@dnd-kit/solid": "0.5.0", "@kobalte/core": "catalog:", + "@opencode-ai/client": "file:vendor/opencode-ai-client-1.17.13.tgz", "@opencode-ai/core": "workspace:*", "@opencode-ai/schema": "workspace:*", "@opencode-ai/sdk": "workspace:*", diff --git a/packages/app/src/context/server-sdk.test.ts b/packages/app/src/context/server-sdk.test.ts index 052faf496a..767d16ffa0 100644 --- a/packages/app/src/context/server-sdk.test.ts +++ b/packages/app/src/context/server-sdk.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test" -import { coalesceServerEvents, enqueueServerEvent, resumeStreamAfterPageShow } from "./server-sdk" +import { adaptServerEvent, coalesceServerEvents, enqueueServerEvent, resumeStreamAfterPageShow } from "./server-sdk" +import type { OpenCodeEvent } from "@opencode-ai/client/promise" import type { Event } from "@opencode-ai/sdk/v2/client" describe("resumeStreamAfterPageShow", () => { @@ -14,6 +15,23 @@ describe("resumeStreamAfterPageShow", () => { }) }) +describe("adaptServerEvent", () => { + test("preserves V2 events while adapting permission requests for existing consumers", () => { + const current = { + id: "evt_1", + created: 1, + type: "permission.v2.asked", + data: { id: "perm_1", sessionID: "ses_1", action: "read", resources: ["src/**"] }, + } as OpenCodeEvent + + expect(adaptServerEvent(current)).toMatchObject({ + type: "permission.asked", + properties: { id: "perm_1", sessionID: "ses_1", permission: "read", patterns: ["src/**"] }, + current, + }) + }) +}) + describe("coalesceServerEvents", () => { const delta = (value: string, field = "text", partID = "part") => ({ directory: "/repo", @@ -34,6 +52,24 @@ describe("coalesceServerEvents", () => { expect(result[0]?.payload).toMatchObject({ id: "second", properties: { delta: "hello world" } }) }) + test("merges adjacent current text deltas", () => { + const current = (id: string, value: string) => + adaptServerEvent({ + id, + created: 1, + type: "session.text.delta", + location: { directory: "/repo" }, + data: { sessionID: "ses", assistantMessageID: "msg", ordinal: 0, delta: value }, + } as OpenCodeEvent) + const result = coalesceServerEvents([ + { directory: "/repo", payload: current("evt_1", "hello ") }, + { directory: "/repo", payload: current("evt_2", "world") }, + ]) + + expect(result).toHaveLength(1) + expect(result[0]?.payload.current).toMatchObject({ id: "evt_2", data: { delta: "hello world" } }) + }) + test("preserves event boundaries and distinct fields", () => { const status = { directory: "/repo", diff --git a/packages/app/src/context/server-sdk.tsx b/packages/app/src/context/server-sdk.tsx index 06597e56e7..62c5857794 100644 --- a/packages/app/src/context/server-sdk.tsx +++ b/packages/app/src/context/server-sdk.tsx @@ -1,21 +1,60 @@ +import type { OpenCodeEvent } from "@opencode-ai/client/promise" import type { Event } from "@opencode-ai/sdk/v2/client" import { createSimpleContext } from "@opencode-ai/ui/context" import { createGlobalEmitter } from "@solid-primitives/event-bus" import { makeEventListener } from "@solid-primitives/event-listener" import { type Accessor, batch, createMemo, onCleanup, onMount } from "solid-js" -import { createSdkForServer } from "@/utils/server" +import { createApiForServer, createSdkForServer, type ServerApi } from "@/utils/server" import { useLanguage } from "./language" import { usePlatform } from "./platform" import { ServerConnection, useServer } from "./server" import { createRefCountMap } from "@/utils/refcount" import { useGlobal } from "./global" import { ServerScope } from "@/utils/server-scope" +import { detectServerProtocol, type ServerProtocol } from "@/utils/server-protocol" +import { createCompatibleApi, type CompatibleApi } from "@/utils/server-compat" const isAbortError = (error: unknown) => error !== null && typeof error === "object" && "name" in error && error.name === "AbortError" const isStreamClosed = (error: unknown, signal?: AbortSignal) => isAbortError(error) || signal?.aborted === true -type QueuedServerEvent = { directory: string; payload: Event } +export type ServerEvent = Event & { current?: OpenCodeEvent } +type QueuedServerEvent = { directory: string; payload: ServerEvent } +type CurrentDelta = Extract< + OpenCodeEvent, + { type: "session.text.delta" | "session.reasoning.delta" | "session.tool.input.delta" | "session.compaction.delta" } +> + +export function adaptServerEvent(event: OpenCodeEvent): ServerEvent { + if (event.type === "permission.v2.asked") { + return { + id: event.id, + type: "permission.asked", + properties: { + id: event.data.id, + sessionID: event.data.sessionID, + permission: event.data.action, + patterns: event.data.resources, + always: event.data.save ?? [], + metadata: event.data.metadata ?? {}, + tool: + event.data.source?.type === "tool" + ? { messageID: event.data.source.messageID, callID: event.data.source.callID } + : undefined, + }, + current: event, + } as ServerEvent + } + if (event.type === "permission.v2.replied") + return { id: event.id, type: "permission.replied", properties: event.data, current: event } as ServerEvent + if (event.type === "question.v2.asked") + return { id: event.id, type: "question.asked", properties: event.data, current: event } as ServerEvent + if (event.type === "question.v2.replied") + return { id: event.id, type: "question.replied", properties: event.data, current: event } as ServerEvent + if (event.type === "question.v2.rejected") + return { id: event.id, type: "question.rejected", properties: event.data, current: event } as ServerEvent + return { id: event.id, type: event.type, properties: event.data, current: event } as ServerEvent +} const coalescedKey = (event: QueuedServerEvent) => { if (event.payload.type === "lsp.updated") return `lsp.updated:${event.directory}` @@ -40,6 +79,34 @@ export function enqueueServerEvent(queue: QueuedServerEvent[], event: QueuedServ export function coalesceServerEvents(events: QueuedServerEvent[]) { const output: QueuedServerEvent[] = [] events.forEach((event) => { + const current = currentDelta(event.payload.current) + if (current) { + const previous = output[output.length - 1] + const prior = currentDelta(previous?.payload.current) + if ( + previous && + prior && + previous.directory === event.directory && + currentDeltaKey(prior) === currentDeltaKey(current) + ) { + const fragment = currentDeltaFragment(prior) + currentDeltaFragment(current) + const data = + current.type === "session.compaction.delta" + ? { ...current.data, text: fragment } + : { ...current.data, delta: fragment } + output[output.length - 1] = { + directory: event.directory, + payload: { + ...event.payload, + properties: data, + current: { ...current, data } as CurrentDelta, + } as ServerEvent, + } + return + } + output.push(event) + return + } if (event.payload.type !== "message.part.delta") { output.push(event) return @@ -71,12 +138,52 @@ export function coalesceServerEvents(events: QueuedServerEvent[]) { return output } +function currentDelta(event: OpenCodeEvent | undefined): CurrentDelta | undefined { + if ( + event?.type === "session.text.delta" || + event?.type === "session.reasoning.delta" || + event?.type === "session.tool.input.delta" || + event?.type === "session.compaction.delta" + ) + return event +} + +function currentDeltaKey(event: CurrentDelta) { + if (event.type === "session.tool.input.delta") + return `${event.type}:${event.data.sessionID}:${event.data.assistantMessageID}:${event.data.callID}` + if (event.type === "session.compaction.delta") return `${event.type}:${event.data.sessionID}` + return `${event.type}:${event.data.sessionID}:${event.data.assistantMessageID}:${event.data.ordinal}` +} + +function currentDeltaFragment(event: CurrentDelta) { + return event.type === "session.compaction.delta" ? event.data.text : event.data.delta +} + export function resumeStreamAfterPageShow(event: PageTransitionEvent, start: () => unknown) { if (!event.persisted) return start() } -function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerScope) { +type ServerEventEmitter = ReturnType> +type ServerSDKBase = { + server: ServerConnection.Any + scope: ServerScope + protocol: Promise + url: string + client: ReturnType + api: CompatibleApi + currentApi: ServerApi + event: { + on: ServerEventEmitter["on"] + listen: ServerEventEmitter["listen"] + start: () => Promise | undefined + } + createClient: ( + opts: Omit[0], "server" | "fetch">, + ) => ReturnType +} + +function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerScope): ServerSDKBase { const platform = usePlatform() const abort = new AbortController() @@ -91,13 +198,15 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS } })() + const eventApi = createApiForServer({ server: server.http, fetch: eventFetch }) const eventSdk = createSdkForServer({ signal: abort.signal, fetch: eventFetch, server: server.http, }) + const protocol = detectServerProtocol(server.http, platform.fetch ?? globalThis.fetch) const emitter = createGlobalEmitter<{ - [key: string]: Event + [key: string]: ServerEvent }>() type Queued = QueuedServerEvent @@ -142,21 +251,6 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS let run: Promise | undefined let started = false let generation = 0 - const HEARTBEAT_TIMEOUT_MS = 15_000 - let lastEventAt = Date.now() - let heartbeat: ReturnType | undefined - const resetHeartbeat = () => { - lastEventAt = Date.now() - if (heartbeat) clearTimeout(heartbeat) - heartbeat = setTimeout(() => { - attempt?.abort() - }, HEARTBEAT_TIMEOUT_MS) - } - const clearHeartbeat = () => { - if (!heartbeat) return - clearTimeout(heartbeat) - heartbeat = undefined - } const start = () => { if (started) return run @@ -168,35 +262,24 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS // oxlint-disable-next-line no-unmodified-loop-condition -- `started` is set to false by stop() which also aborts; both flags are checked to allow graceful exit while (!abort.signal.aborted && started && generation === active) { attempt = new AbortController() - lastEventAt = Date.now() const onAbort = () => { attempt?.abort() } abort.signal.addEventListener("abort", onAbort) try { - const events = await eventSdk.global.event({ - signal: attempt.signal, - onSseError: (error) => { - if (isStreamClosed(error, attempt?.signal)) return - if (streamErrorLogged) return - streamErrorLogged = true - console.error("[global-sdk] event stream error", { - url: server.http.url, - fetch: eventFetch ? "platform" : "webview", - error, - }) - }, - }) + const kind = await protocol + const events = + kind === "v1" + ? (await eventSdk.global.event({ signal: attempt.signal })).stream + : eventApi.event.subscribe({ signal: attempt.signal }) let yielded = Date.now() - resetHeartbeat() - for await (const event of events.stream) { - resetHeartbeat() + for await (const event of events) { streamErrorLogged = false - if (event.payload.type !== "sync") { - const directory = event.directory ?? "global" - const payload = event.payload as Event - if (enqueueServerEvent(queue, { directory, payload })) schedule() - } + const legacy = "payload" in event + if (legacy && event.payload.type === "sync") continue + const directory = legacy ? (event.directory ?? "global") : (event.location?.directory ?? "global") + const payload = legacy ? (event.payload as Event) : adaptServerEvent(event) + if (enqueueServerEvent(queue, { directory, payload })) schedule() if (Date.now() - yielded < STREAM_YIELD_MS) continue yielded = Date.now() @@ -214,7 +297,6 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS } finally { abort.signal.removeEventListener("abort", onAbort) attempt = undefined - clearHeartbeat() } if (abort.signal.aborted || !started || generation !== active) return @@ -233,18 +315,11 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS started = false generation++ attempt?.abort() - clearHeartbeat() } onMount(() => { makeEventListener(window, "pagehide", stop) makeEventListener(window, "pageshow", (event) => resumeStreamAfterPageShow(event, start)) - makeEventListener(document, "visibilitychange", () => { - if (document.visibilityState !== "visible") return - if (!started) return - if (Date.now() - lastEventAt < HEARTBEAT_TIMEOUT_MS) return - attempt?.abort() - }) }) onCleanup(() => { @@ -258,12 +333,24 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS fetch: platform.fetch, throwOnError: true, }) + const currentApi: ServerApi = createApiForServer({ server: server.http, fetch: platform.fetch }) + const legacy = (directory?: string) => + createSdkForServer({ + server: server.http, + fetch: platform.fetch, + throwOnError: true, + directory, + }) + const api = createCompatibleApi({ protocol, current: currentApi, legacy }) return { server, scope, + protocol, url: server.http.url, client: sdk, + api, + currentApi, event: { on: emitter.on.bind(emitter), listen: emitter.listen.bind(emitter), @@ -279,7 +366,6 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS } } -type ServerSDKBase = ReturnType export type ServerSDK = ServerSDKBase & { ensureDirSdkContext: (directory: string) => ReturnType } @@ -309,7 +395,7 @@ export const { use: useServerSDK, provider: ServerSDKProvider } = createSimpleCo }) type SDKEventMap = { - [key in Event["type"]]: Extract + [key in Event["type"]]: Extract } function createDirSdkContext(directory: string, serverSDK: ServerSDKBase) { @@ -329,6 +415,12 @@ function createDirSdkContext(directory: string, serverSDK: ServerSDKBase) { scope: serverSDK.scope, directory, client, + api: createCompatibleApi({ + protocol: serverSDK.protocol, + current: serverSDK.currentApi, + legacy: (next) => serverSDK.createClient({ directory: next ?? directory, throwOnError: true }), + directory, + }), event: emitter, get url() { return serverSDK.url diff --git a/packages/app/src/utils/server-compat.test.ts b/packages/app/src/utils/server-compat.test.ts new file mode 100644 index 0000000000..4907eb41eb --- /dev/null +++ b/packages/app/src/utils/server-compat.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, test } from "bun:test" +import { createApiForServer, createSdkForServer } from "./server" +import { createCompatibleApi } from "./server-compat" + +function setup(protocol: "v1" | "v2" | Promise<"v1" | "v2">) { + const requests: Request[] = [] + const fetcher = Object.assign( + async (input: string | URL | Request, init?: RequestInit) => { + const request = new Request(input, init) + requests.push(request) + if (request.method === "PATCH") { + return Response.json({ + id: "ses_1", + slug: "ses_1", + projectID: "project", + directory: "/repo", + title: "Session", + version: "1", + time: { created: 1, updated: 1 }, + }) + } + if (request.method === "POST" && request.url.endsWith("/prompt_async")) + return new Response(undefined, { status: 204 }) + if (request.method === "POST" && request.url.endsWith("/prompt")) { + return Response.json({ + admittedSeq: 1, + id: "msg_1", + sessionID: "ses_1", + timeCreated: 1, + type: "user", + data: { text: "hello" }, + delivery: "steer", + }) + } + if (request.method === "GET") return Response.json([]) + return new Response(undefined, { status: 204 }) + }, + { preconnect: globalThis.fetch.preconnect }, + ) + const server = { url: "http://localhost:4096" } + const api = createCompatibleApi({ + protocol: typeof protocol === "string" ? Promise.resolve(protocol) : protocol, + current: createApiForServer({ server, fetch: fetcher }), + legacy: (directory) => createSdkForServer({ server, fetch: fetcher, directory, throwOnError: true }), + directory: "/repo", + }) + return { api, requests } +} + +describe("createCompatibleApi", () => { + test("routes V1 archive through the legacy session update", async () => { + const { api, requests } = setup("v1") + await api.session.archive({ sessionID: "ses_1", directory: "/repo" }) + + const url = new URL(requests[0]!.url) + expect(url.pathname).toBe("/session/ses_1") + expect(requests[0]!.headers.get("x-opencode-directory")).toBe("%2Frepo") + expect(requests[0]!.method).toBe("PATCH") + expect(await requests[0]!.json()).toMatchObject({ time: { archived: expect.any(Number) } }) + }) + + test("converts current prompts to the V1 prompt contract", async () => { + const { api, requests } = setup("v1") + await api.session.prompt({ + sessionID: "ses_1", + id: "msg_1", + text: "hello", + agent: "build", + model: { providerID: "provider", modelID: "model" }, + }) + + expect(new URL(requests[0]!.url).pathname).toBe("/session/ses_1/prompt_async") + expect(await requests[0]!.json()).toMatchObject({ + messageID: "msg_1", + agent: "build", + model: { providerID: "provider", modelID: "model" }, + parts: [{ type: "text", text: "hello" }], + }) + }) + + test("keeps V2 session actions on the current API", async () => { + const { api, requests } = setup("v2") + await api.session.archive({ sessionID: "ses_1" }) + + expect(new URL(requests[0]!.url).pathname).toBe("/api/session/ses_1/archive") + expect(requests[0]!.method).toBe("POST") + }) + + test("resolves protocol detection once across implementation methods", async () => { + let detections = 0 + const resolved = Promise.resolve<"v1" | "v2">("v2") + const protocol = new Proxy(resolved, { + get(target, property) { + if (property !== "then") return Reflect.get(target, property, target) + detections++ + return target.then.bind(target) + }, + }) + const { api } = setup(protocol) + + await api.session.archive({ sessionID: "ses_1" }) + await api.session.list() + + expect(detections).toBe(1) + }) + + test("uses the global V1 session search endpoint", async () => { + const { api, requests } = setup("v1") + await api.session.list({ parentID: null, search: "session", limit: 50 }) + + expect(new URL(requests[0]!.url).pathname).toBe("/experimental/session") + }) +}) diff --git a/packages/app/src/utils/server-compat.ts b/packages/app/src/utils/server-compat.ts new file mode 100644 index 0000000000..1772516900 --- /dev/null +++ b/packages/app/src/utils/server-compat.ts @@ -0,0 +1,495 @@ +import type { ServerApi } from "./server" +import type { ServerProtocol } from "./server-protocol" +import type { OpencodeClient, Session } from "@opencode-ai/sdk/v2/client" +import type { + Project, + ProjectCurrent, + SessionApi, + SessionCommandInput, + SessionCommandOutput, + SessionCompactInput, + SessionCompactOutput, + SessionInfo, + SessionPromptInput, + SessionPromptOutput, + SessionShellInput, + SessionShellOutput, +} from "@opencode-ai/client/promise" + +type LegacyClient = OpencodeClient +type LegacyFor = (directory?: string) => LegacyClient +type CompatibleSessionApi = Omit< + SessionApi, + "prompt" | "command" | "shell" | "compact" | "rename" | "archive" | "remove" +> & { + prompt: (input: SessionPromptInput & LegacyPrompt) => Promise + command: (input: SessionCommandInput) => Promise + shell: (input: SessionShellInput & LegacyPrompt) => Promise + compact: (input: SessionCompactInput & { model?: LegacyPrompt["model"] }) => Promise + rename: (input: Parameters[0] & LegacyLocation) => ReturnType + archive: (input: Parameters[0] & LegacyLocation) => ReturnType + remove: (input: Parameters[0] & LegacyLocation) => ReturnType +} +export type CompatibleApi = Omit & { readonly session: CompatibleSessionApi } +type LegacyPrompt = { + agent?: string + model?: { providerID: string; modelID: string } + variant?: string +} +type LegacyLocation = { directory?: string } +type CompatibleInput = { + protocol: Promise + current: ServerApi + legacy: LegacyFor + directory?: string +} + +function mime(uri: string) { + const match = /^data:([^;,]+)/.exec(uri) + return match?.[1] ?? "application/octet-stream" +} + +function sessionInfo(session: Session): SessionInfo { + return { + id: session.id, + parentID: session.parentID, + projectID: session.projectID, + agent: session.agent, + model: session.model && { + id: session.model.id, + providerID: session.model.providerID, + variant: session.model.variant, + }, + cost: session.cost ?? 0, + tokens: session.tokens ?? { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + time: session.time, + title: session.title, + location: { directory: session.directory, workspaceID: session.workspaceID }, + subpath: session.path, + revert: session.revert && { + messageID: session.revert.messageID, + partID: session.revert.partID, + snapshot: session.revert.snapshot, + }, + } +} + +export function createCompatibleApi(input: CompatibleInput): CompatibleApi { + const v1 = createV1Api(input) + return lazyApi( + input.protocol.then((protocol) => (protocol === "v1" ? v1 : input.current)), + input.current, + ) +} + +function lazyApi(implementation: Promise, shape: T): T { + const cache = new Map() + return new Proxy(shape, { + get(target, property, receiver) { + const sample = Reflect.get(target, property, receiver) + if (typeof sample === "function") { + return (...args: unknown[]) => + implementation.then((value) => { + const method = Reflect.get(value, property) + if (typeof method !== "function") throw new Error(`API method unavailable: ${String(property)}`) + return Reflect.apply(method, value, args) + }) + } + if (sample === null || typeof sample !== "object") return sample + if (cache.has(property)) return cache.get(property) + const nested = lazyApi( + implementation.then((value) => { + const result = Reflect.get(value, property) + if (result === null || typeof result !== "object") { + throw new Error(`API namespace unavailable: ${String(property)}`) + } + return result + }), + sample, + ) + cache.set(property, nested) + return nested + }, + }) +} + +function createV1Api(input: CompatibleInput): CompatibleApi { + const directory = (location?: { directory?: string }) => location?.directory ?? input.directory + const legacy = (location?: { directory?: string }) => input.legacy(directory(location)) + const located = (data: T, value?: { directory?: string }) => ({ + location: { + directory: directory(value) ?? "", + project: { id: "", directory: directory(value) ?? "" }, + }, + data, + }) + + return { + ...input.current, + session: { + ...input.current.session, + async list( + value?: Parameters[0], + options?: Parameters[1], + ) { + if (!value?.directory && value?.search !== undefined) { + const result = await legacy().experimental.session.list( + { + roots: value.parentID === null ? true : undefined, + search: value.search, + limit: value.limit, + }, + options, + ) + return { data: (result.data ?? []).map(sessionInfo), cursor: {} } + } + const result = await legacy({ directory: value?.directory }).session.list({ + directory: value?.directory, + roots: value?.parentID === null ? true : undefined, + search: value?.search, + limit: value?.limit, + }) + return { data: (result.data ?? []).map(sessionInfo), cursor: {} } + }, + async create(value?: Parameters[0]) { + const result = await legacy(value?.location ?? undefined).session.create({ + directory: directory(value?.location ?? undefined), + }) + if (!result.data) throw new Error("Failed to create session") + return sessionInfo(result.data) + }, + async get(value: Parameters[0]) { + const result = await legacy().session.get(value) + if (!result.data) throw new Error(`Session not found: ${value.sessionID}`) + return sessionInfo(result.data) + }, + async active() { + const result = await legacy().session.status() + return Object.fromEntries( + Object.entries(result.data ?? {}).flatMap(([sessionID, status]) => + status.type === "idle" ? [] : [[sessionID, { type: "running" as const }]], + ), + ) + }, + async rename(value: Parameters[0] & LegacyLocation) { + await legacy(value).session.update({ sessionID: value.sessionID, title: value.title }) + }, + async archive(value: Parameters[0] & LegacyLocation) { + await legacy(value).session.update({ sessionID: value.sessionID, time: { archived: Date.now() } }) + }, + async remove(value: Parameters[0] & LegacyLocation) { + await legacy(value).session.delete(value) + }, + async fork(value: Parameters[0]) { + const result = await legacy().session.fork(value) + if (!result.data) throw new Error("Failed to fork session") + return sessionInfo(result.data) + }, + async interrupt(value: Parameters[0]) { + await legacy().session.abort(value) + }, + async prompt(value: SessionPromptInput & LegacyPrompt) { + await legacy().session.promptAsync({ + sessionID: value.sessionID, + messageID: value.id ?? undefined, + agent: value.agent, + model: value.model, + variant: value.variant, + parts: [ + { type: "text", text: value.text }, + ...(value.files ?? []).map((file) => ({ + type: "file" as const, + mime: mime(file.uri), + url: file.uri, + filename: file.name, + })), + ...(value.agents ?? []).map((agent) => ({ + type: "agent" as const, + name: agent.name, + source: agent.mention + ? { value: agent.mention.text, start: agent.mention.start, end: agent.mention.end } + : undefined, + })), + ], + }) + return { + admittedSeq: 0, + id: value.id ?? "", + sessionID: value.sessionID, + timeCreated: Date.now(), + type: "user", + data: { text: value.text }, + delivery: value.delivery ?? "steer", + } + }, + async command(value: SessionCommandInput) { + await legacy().session.command({ + sessionID: value.sessionID, + messageID: value.id ?? undefined, + command: value.command, + arguments: value.arguments ?? "", + agent: value.agent ?? undefined, + model: value.model ? `${value.model.providerID}/${value.model.id}` : undefined, + variant: value.model?.variant, + parts: value.files?.map((file) => ({ + type: "file" as const, + mime: mime(file.uri), + url: file.uri, + filename: file.name, + })), + }) + return { + admittedSeq: 0, + id: value.id ?? "", + sessionID: value.sessionID, + timeCreated: Date.now(), + type: "user", + data: { text: `/${value.command} ${value.arguments ?? ""}`.trim() }, + delivery: value.delivery ?? "steer", + } + }, + async shell(value: SessionShellInput & LegacyPrompt) { + await legacy().session.shell({ + sessionID: value.sessionID, + command: value.command, + agent: value.agent, + model: value.model, + }) + }, + compact: async (value: SessionCompactInput & { model?: LegacyPrompt["model"] }) => { + if (!value.model) throw new Error("A model is required to compact a V1 session") + await legacy().session.summarize({ + sessionID: value.sessionID, + providerID: value.model.providerID, + modelID: value.model.modelID, + }) + return { + admittedSeq: 0, + id: value.id ?? "", + sessionID: value.sessionID, + timeCreated: Date.now(), + type: "compaction", + } + }, + revert: { + stage: async (value: Parameters[0]) => { + await legacy().session.revert(value) + return { messageID: value.messageID } + }, + clear: async (value: Parameters[0]) => { + await legacy().session.unrevert(value) + }, + commit: input.current.session.revert.commit, + }, + }, + project: { + ...input.current.project, + async list() { + return ((await legacy().project.list()).data ?? []) as Project[] + }, + async current(value?: Parameters[0]) { + const result = await legacy(value?.location).project.current() + if (!result.data) throw new Error("Project not found") + return { id: result.data.id, directory: result.data.worktree } satisfies ProjectCurrent + }, + async update(value: Parameters[0]) { + const project = (await legacy().project.list()).data?.find((item) => item.id === value.projectID) + const result = await legacy({ directory: project?.worktree }).project.update({ + ...value, + directory: project?.worktree, + }) + if (!result.data) throw new Error(`Project not found: ${value.projectID}`) + return result.data as Project + }, + async directories(value: Parameters[0]) { + const result = await legacy(value.location).worktree.list() + return (result.data ?? []).map((item) => ({ directory: item })) + }, + }, + path: { + ...input.current.path, + async get(value?: Parameters[0]) { + const result = await legacy(value?.location).path.get() + if (!result.data) throw new Error("Path unavailable") + return result.data + }, + }, + vcs: { + ...input.current.vcs, + async get(value?: Parameters[0]) { + const result = await legacy(value?.location).vcs.get() + return located({ branch: result.data?.branch, defaultBranch: undefined }, value?.location) + }, + async status(value?: Parameters[0]) { + const result = await legacy(value?.location).vcs.status() + return located(result.data ?? [], value?.location) + }, + async diff(value: Parameters[0]) { + const result = await legacy(value.location).vcs.diff({ + mode: value.mode === "working" ? "git" : value.mode, + context: value.context, + }) + return located( + (result.data ?? []).map((file) => ({ + file: file.file, + patch: file.patch ?? "", + additions: file.additions, + deletions: file.deletions, + status: file.status ?? "modified", + })), + value.location, + ) + }, + }, + file: { + ...input.current.file, + async list(value?: Parameters[0]) { + const result = await legacy(value?.location).file.list({ path: value?.path ?? "" }) + return located(result.data ?? [], value?.location) + }, + async find(value: Parameters[0]) { + const result = await legacy(value.location).find.files({ + query: value.query, + type: value.type, + limit: value.limit, + }) + return located( + (result.data ?? []).map((path) => ({ path, type: value.type ?? "file" })), + value.location, + ) + }, + }, + integration: { + ...input.current.integration, + async get(value: Parameters[0]) { + const methods = ((await legacy(value.location).provider.auth()).data?.[value.integrationID] ?? []).map( + (method, index) => + method.type === "api" + ? { type: "key" as const, label: method.label } + : { type: "oauth" as const, id: String(index), label: method.label, prompts: method.prompts }, + ) + return located( + { + id: value.integrationID, + name: value.integrationID, + methods, + connections: [], + }, + value.location, + ) + }, + connect: { + ...input.current.integration.connect, + key: async (value: Parameters[0]) => { + await legacy(value.location).auth.set({ + providerID: value.integrationID, + auth: { type: "api", key: value.key }, + }) + }, + }, + oauth: { + ...input.current.integration.oauth, + connect: async (value: Parameters[0]) => { + const method = Number(value.methodID) + const result = await legacy(value.location).provider.oauth.authorize( + { providerID: value.integrationID, method, inputs: value.inputs }, + { throwOnError: true }, + ) + if (!result.data) throw new Error("Failed to start OAuth authorization") + return located( + { + attemptID: `${value.integrationID}:${method}`, + url: result.data.url, + instructions: result.data.instructions, + mode: result.data.method, + time: { created: Date.now(), expires: Date.now() + 10 * 60 * 1000 }, + }, + value.location, + ) + }, + complete: async (value: Parameters[0]) => { + const method = Number(value.attemptID.split(":").at(-1)) + await legacy(value.location).provider.oauth.callback( + { providerID: value.integrationID, method, code: value.code }, + { throwOnError: true }, + ) + }, + status: async (value: Parameters[0]) => { + const method = Number(value.attemptID.split(":").at(-1)) + await legacy(value.location).provider.oauth.callback( + { providerID: value.integrationID, method }, + { throwOnError: true }, + ) + return located( + { status: "complete" as const, time: { created: Date.now(), expires: Date.now() } }, + value.location, + ) + }, + }, + }, + pty: { + ...input.current.pty, + async shells(value?: Parameters[0]) { + return located((await legacy(value?.location).pty.shells()).data ?? [], value?.location) + }, + async list(value?: Parameters[0]) { + return located((await legacy(value?.location).pty.list()).data ?? [], value?.location) + }, + async create(value?: Parameters[0]) { + const result = await legacy(value?.location).pty.create({ + command: value?.command, + args: value?.args ? [...value.args] : undefined, + cwd: value?.cwd, + title: value?.title, + env: value?.env, + }) + if (!result.data) throw new Error("Failed to create terminal") + return located(result.data, value?.location) + }, + async get(value: Parameters[0]) { + const result = await legacy(value.location).pty.get({ ptyID: value.ptyID }) + if (!result.data) throw new Error(`Terminal not found: ${value.ptyID}`) + return located(result.data, value.location) + }, + async update(value: Parameters[0]) { + const result = await legacy(value.location).pty.update({ + ptyID: value.ptyID, + title: value.title, + size: value.size, + }) + if (!result.data) throw new Error(`Terminal not found: ${value.ptyID}`) + return located(result.data, value.location) + }, + async remove(value: Parameters[0]) { + await legacy(value.location).pty.remove({ ptyID: value.ptyID }) + }, + async connectToken(value: Parameters[0]) { + const result = await legacy(value.location).pty.connectToken({ ptyID: value.ptyID }) + if (!result.data) throw new Error(`Failed to connect terminal: ${value.ptyID}`) + return located(result.data, value.location) + }, + }, + permission: { + ...input.current.permission, + async reply(value: Parameters[0]) { + await legacy().permission.respond({ + sessionID: value.sessionID, + permissionID: value.requestID, + response: value.reply, + }) + }, + }, + question: { + ...input.current.question, + async reply(value: Parameters[0]) { + await legacy().question.reply({ + requestID: value.requestID, + answers: value.answers.map((answer) => [...answer]), + }) + }, + async reject(value: Parameters[0]) { + await legacy().question.reject({ requestID: value.requestID }) + }, + }, + } +} diff --git a/packages/app/src/utils/server-health.test.ts b/packages/app/src/utils/server-health.test.ts index b1c8f2c7e2..69a8c7b3be 100644 --- a/packages/app/src/utils/server-health.test.ts +++ b/packages/app/src/utils/server-health.test.ts @@ -14,15 +14,45 @@ function abortFromInput(input: RequestInfo | URL, init?: RequestInit) { describe("checkServerHealth", () => { test("returns healthy response with version", async () => { - const fetch = (async () => - new Response(JSON.stringify({ healthy: true, version: "1.2.3" }), { + let request: URL | undefined + const fetch = (async (input: RequestInfo | URL) => { + request = input instanceof URL ? input : new URL(input instanceof Request ? input.url : input) + return new Response(JSON.stringify({ healthy: true, version: "1.2.3" }), { status: 200, headers: { "content-type": "application/json" }, - })) as unknown as typeof globalThis.fetch + }) + }) as unknown as typeof globalThis.fetch const result = await checkServerHealth(server, fetch) expect(result).toEqual({ healthy: true, version: "1.2.3" }) + expect(request?.pathname).toBe("/api/health") + }) + + test("falls back to the V1 health endpoint", async () => { + const paths: string[] = [] + const fetch = (async (input: RequestInfo | URL) => { + const url = input instanceof URL ? input : new URL(input instanceof Request ? input.url : input) + paths.push(url.pathname) + if (url.pathname === "/api/health") return new Response(undefined, { status: 404 }) + return Response.json({ healthy: true, version: "1.18.4" }) + }) as unknown as typeof globalThis.fetch + + expect(await checkServerHealth(server, fetch)).toEqual({ healthy: true, version: "1.18.4" }) + expect(paths).toEqual(["/api/health", "/global/health"]) + }) + + test("falls back when the current health response is malformed", async () => { + const paths: string[] = [] + const fetch = (async (input: RequestInfo | URL) => { + const url = input instanceof URL ? input : new URL(input instanceof Request ? input.url : input) + paths.push(url.pathname) + if (url.pathname === "/api/health") return Response.json({}) + return Response.json({ healthy: true, version: "1.18.4" }) + }) as unknown as typeof globalThis.fetch + + expect(await checkServerHealth(server, fetch)).toEqual({ healthy: true, version: "1.18.4" }) + expect(paths).toEqual(["/api/health", "/global/health"]) }) test("allows slow servers thirty seconds by default", async () => { @@ -142,7 +172,7 @@ describe("checkServerHealth", () => { retryDelayMs: 1, }) - expect(count).toBe(3) + expect(count).toBe(6) expect(result).toEqual({ healthy: false }) }) }) diff --git a/packages/app/src/utils/server-health.ts b/packages/app/src/utils/server-health.ts index 1b684d9af7..1d7d9e4b2e 100644 --- a/packages/app/src/utils/server-health.ts +++ b/packages/app/src/utils/server-health.ts @@ -1,6 +1,7 @@ import { usePlatform } from "@/context/platform" import { ServerConnection } from "@/context/server" -import { createSdkForServer } from "./server" +import { authTokenFromCredentials, createSdkForServer } from "./server" +import { ClientError, OpenCode } from "@opencode-ai/client" import { Accessor, createEffect, onCleanup } from "solid-js" import { createStore, reconcile } from "solid-js/store" @@ -61,6 +62,7 @@ function wait(ms: number, signal?: AbortSignal) { function retryable(error: unknown, signal?: AbortSignal) { if (signal?.aborted) return false + if (error instanceof ClientError) return error.reason === "Transport" if (!(error instanceof Error)) return false if (error.name === "AbortError" || error.name === "TimeoutError") return false if (error instanceof TypeError) return true @@ -82,15 +84,31 @@ export async function checkServerHealth( .then(() => attempt(count + 1)) .catch(() => ({ healthy: false })) } - const attempt = (count: number): Promise => - createSdkForServer({ - server, + const attempt = async (count: number): Promise => { + const current = await OpenCode.make({ + baseUrl: server.url, fetch, - signal, + headers: server.password + ? { + Authorization: `Basic ${authTokenFromCredentials({ username: server.username, password: server.password })}`, + } + : undefined, }) + .health.get({ signal }) + .then((x) => + typeof x.healthy === "boolean" + ? { data: { healthy: x.healthy, version: x.version } } + : { error: new Error("Invalid health response") }, + ) + .catch((error) => ({ error })) + if ("data" in current && current.data) return current.data + if (signal?.aborted) return { healthy: false } + + return createSdkForServer({ server, fetch, signal }) .global.health() .then((x) => (x.error ? next(count, x.error) : { healthy: x.data?.healthy === true, version: x.data?.version })) .catch((error) => next(count, error)) + } return attempt(0).finally(() => timeout?.clear?.()) } diff --git a/packages/app/src/utils/server-protocol.test.ts b/packages/app/src/utils/server-protocol.test.ts new file mode 100644 index 0000000000..2130a968c4 --- /dev/null +++ b/packages/app/src/utils/server-protocol.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, test } from "bun:test" +import { detectServerProtocol } from "./server-protocol" + +const server = { url: "http://localhost:4096" } +const json = (value: unknown, status = 200) => + new Response(JSON.stringify(value), { status, headers: { "content-type": "application/json" } }) +const mockFetch = (run: (input: string | URL | Request) => Promise) => + Object.assign(run, { preconnect: globalThis.fetch.preconnect }) + +describe("detectServerProtocol", () => { + test("prefers the legacy health endpoint when both API generations exist", async () => { + const fetcher = mockFetch((input) => { + const path = new URL(input instanceof Request ? input.url : input).pathname + if (path === "/global/health") return Promise.resolve(json({ healthy: true, version: "1.18.4" })) + return Promise.resolve(json({ healthy: true, version: "2.0.0", pid: 123 })) + }) + + expect(await detectServerProtocol(server, fetcher)).toBe("v1") + }) + + test("recognizes V2 health by its process identifier", async () => { + const fetcher = mockFetch((input) => { + const path = new URL(input instanceof Request ? input.url : input).pathname + if (path === "/global/health") return Promise.resolve(json({}, 404)) + return Promise.resolve(json({ healthy: true, version: "2.0.0", pid: 123 })) + }) + + expect(await detectServerProtocol(server, fetcher)).toBe("v2") + }) + + test("recognizes the transitional V1 API health response", async () => { + const fetcher = mockFetch((input) => { + const path = new URL(input instanceof Request ? input.url : input).pathname + if (path === "/global/health") return Promise.resolve(json({}, 404)) + return Promise.resolve(json({ healthy: true })) + }) + + expect(await detectServerProtocol(server, fetcher)).toBe("v1") + }) +}) diff --git a/packages/app/src/utils/server-protocol.ts b/packages/app/src/utils/server-protocol.ts new file mode 100644 index 0000000000..27b8dc208e --- /dev/null +++ b/packages/app/src/utils/server-protocol.ts @@ -0,0 +1,35 @@ +import type { ServerConnection } from "@/context/server" +import { authTokenFromCredentials } from "./server" + +export type ServerProtocol = "v1" | "v2" + +function headers(server: ServerConnection.HttpBase) { + if (!server.password) return + return { + Authorization: `Basic ${authTokenFromCredentials({ username: server.username, password: server.password })}`, + } +} + +async function probe(server: ServerConnection.HttpBase, fetch: typeof globalThis.fetch, path: string) { + const response = await fetch(new URL(path, server.url), { + headers: headers(server), + signal: AbortSignal.timeout(5_000), + }) + if (!response.ok || !response.headers.get("content-type")?.includes("application/json")) return + const value: unknown = await response.json() + if (!value || typeof value !== "object") return + return value +} + +export async function detectServerProtocol( + server: ServerConnection.HttpBase, + fetch: typeof globalThis.fetch, +): Promise { + const legacy = await probe(server, fetch, "/global/health").catch(() => undefined) + if (legacy && "healthy" in legacy && legacy.healthy === true) return "v1" + + const current = await probe(server, fetch, "/api/health").catch(() => undefined) + if (current && "pid" in current && typeof current.pid === "number") return "v2" + if (current && "healthy" in current && current.healthy === true) return "v1" + return "v2" +} diff --git a/packages/app/src/utils/server.ts b/packages/app/src/utils/server.ts index 603784e4d4..1c8292ca9d 100644 --- a/packages/app/src/utils/server.ts +++ b/packages/app/src/utils/server.ts @@ -1,4 +1,5 @@ import { createOpencodeClient } from "@opencode-ai/sdk/v2/client" +import { OpenCode, type OpenCodeClient } from "@opencode-ai/client/promise" import type { ServerConnection } from "@/context/server" import { decode64 } from "@/utils/base64" @@ -39,3 +40,23 @@ export function createSdkForServer({ baseUrl: server.url, }) } + +export function createApiForServer(input: { + server: ServerConnection.HttpBase + fetch?: typeof globalThis.fetch +}): OpenCodeClient { + return OpenCode.make({ + baseUrl: input.server.url, + fetch: input.fetch, + headers: input.server.password + ? { + Authorization: `Basic ${authTokenFromCredentials({ + username: input.server.username, + password: input.server.password, + })}`, + } + : undefined, + }) +} + +export type ServerApi = OpenCodeClient diff --git a/packages/app/vendor/opencode-ai-client-1.17.13.tgz b/packages/app/vendor/opencode-ai-client-1.17.13.tgz new file mode 100644 index 0000000000000000000000000000000000000000..5939f2cb39c27da205f1f37e9971009a669b83c2 GIT binary patch literal 75585 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 z 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

$=Nes$vsW~pOCtUnEt6JQ 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 literal 0 HcmV?d00001 diff --git a/packages/core/package.json b/packages/core/package.json index 7756c08d7e..86f97f7d50 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -77,7 +77,7 @@ "@ai-sdk/google": "3.0.73", "@ai-sdk/google-vertex": "4.0.128", "@ai-sdk/groq": "3.0.31", - "@ai-sdk/mistral": "3.0.27", + "@ai-sdk/mistral": "3.0.51", "@ai-sdk/openai": "3.0.84", "@ai-sdk/openai-compatible": "2.0.41", "@ai-sdk/perplexity": "3.0.26", diff --git a/packages/core/test/provider-mistral.test.ts b/packages/core/test/provider-mistral.test.ts new file mode 100644 index 0000000000..6e3176695f --- /dev/null +++ b/packages/core/test/provider-mistral.test.ts @@ -0,0 +1,282 @@ +import { createMistral } from "@ai-sdk/mistral" +import { expect, test } from "bun:test" + +test("Mistral sends promptCacheKey as prompt_cache_key", async () => { + let body: Record | undefined + const mockFetch = Object.assign( + async (_input: Parameters[0], init?: RequestInit) => { + body = JSON.parse(String(init?.body)) + return Response.json({ + id: "response-1", + created: 0, + model: "mistral-large-latest", + object: "chat.completion", + choices: [{ index: 0, message: { role: "assistant", content: "Hello" }, finish_reason: "stop" }], + usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }, + }) + }, + { preconnect: fetch.preconnect }, + ) + const model = createMistral({ apiKey: "test", fetch: mockFetch })("mistral-large-latest") + + await model.doGenerate({ + prompt: [{ role: "user", content: [{ type: "text", text: "Hello" }] }], + providerOptions: { mistral: { promptCacheKey: "session-123" } }, + }) + + expect(body?.prompt_cache_key).toBe("session-123") +}) + +test("Mistral round-trips native reasoning in assistant history", async () => { + let body: { messages?: unknown[] } | undefined + const mockFetch = Object.assign( + async (_input: Parameters[0], init?: RequestInit) => { + body = JSON.parse(String(init?.body)) + return Response.json({ + id: "response-1", + created: 0, + model: "mistral-small-latest", + object: "chat.completion", + choices: [ + { + index: 0, + message: { + role: "assistant", + content: [ + { + type: "thinking", + thinking: [ + { type: "text", text: "The user is greeting me." }, + { + type: "tool_reference", + tool: "web_search", + title: "Example result", + url: "https://example.com/tool", + favicon: "https://example.com/favicon.ico", + description: "Example description", + }, + { type: "reference", reference_ids: [1, "source-2"] }, + ], + closed: true, + signature: "sig-123", + }, + { type: "text", text: "Hi" }, + ], + }, + finish_reason: "stop", + }, + ], + usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }, + }) + }, + { preconnect: fetch.preconnect }, + ) + const model = createMistral({ apiKey: "test", fetch: mockFetch })("mistral-small-latest") + + const first = await model.doGenerate({ + prompt: [{ role: "user", content: [{ type: "text", text: "Hello" }] }], + }) + const reasoning = first.content.find((part) => part.type === "reasoning") + const text = first.content.find((part) => part.type === "text") + if (!reasoning || !text) throw new Error("expected reasoning and text") + + await model.doGenerate({ + prompt: [ + { role: "user", content: [{ type: "text", text: "Hello" }] }, + { + role: "assistant", + content: [{ ...reasoning, providerOptions: reasoning.providerMetadata }, text], + }, + { role: "user", content: [{ type: "text", text: "Hello again" }] }, + ], + }) + + expect(body?.messages?.[1]).toEqual({ + role: "assistant", + content: [ + { + type: "thinking", + thinking: [ + { type: "text", text: "The user is greeting me." }, + { + type: "tool_reference", + tool: "web_search", + title: "Example result", + url: "https://example.com/tool", + favicon: "https://example.com/favicon.ico", + description: "Example description", + }, + { type: "reference", reference_ids: [1, "source-2"] }, + ], + closed: true, + signature: "sig-123", + }, + { type: "text", text: "Hi" }, + ], + }) + + await model.doGenerate({ + prompt: [ + { role: "user", content: [{ type: "text", text: "Hello" }] }, + { + role: "assistant", + content: [ + { type: "reasoning", text: "thinking" }, + { type: "text", text: "Hi" }, + ], + }, + { role: "user", content: [{ type: "text", text: "Hello again" }] }, + ], + }) + expect(body?.messages?.[1]).toEqual({ role: "assistant", content: "thinkingHi" }) +}) + +test("Mistral preserves native reasoning metadata while streaming", async () => { + const chunks = [ + { + id: "response-1", + created: 0, + model: "mistral-small-latest", + choices: [ + { + index: 0, + delta: { + role: "assistant", + content: [ + { + type: "thinking", + thinking: [ + { type: "text", text: "thinking" }, + { + type: "tool_reference", + tool: "web_search", + title: "Example result", + url: "https://example.com/tool", + favicon: "https://example.com/favicon.ico", + description: "Example description", + }, + ], + }, + ], + }, + }, + ], + }, + { + id: "response-1", + created: 0, + model: "mistral-small-latest", + choices: [ + { + index: 0, + delta: { + content: [ + { + type: "thinking", + thinking: [{ type: "reference", reference_ids: [1, "source-2"] }], + closed: true, + signature: "sig-123", + }, + ], + }, + }, + ], + }, + { + id: "response-1", + created: 0, + model: "mistral-small-latest", + choices: [{ index: 0, delta: { content: [{ type: "text", text: "answer" }] } }], + }, + { + id: "response-1", + created: 0, + model: "mistral-small-latest", + choices: [{ index: 0, delta: {}, finish_reason: "stop" }], + usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }, + }, + ] + const mockFetch = Object.assign( + async () => + new Response(chunks.map((chunk) => `data: ${JSON.stringify(chunk)}\n\n`).join(""), { + headers: { "Content-Type": "text/event-stream" }, + }), + { preconnect: fetch.preconnect }, + ) + const model = createMistral({ apiKey: "test", fetch: mockFetch })("mistral-small-latest") + const result = await model.doStream({ + prompt: [{ role: "user", content: [{ type: "text", text: "Hello" }] }], + }) + const events = [] + for await (const event of result.stream) events.push(event) + + expect(events.find((event) => event.type === "reasoning-end")?.providerMetadata).toEqual({ + mistral: { + thinking: { + type: "thinking", + thinking: [ + { type: "text", text: "thinking" }, + { + type: "tool_reference", + tool: "web_search", + title: "Example result", + url: "https://example.com/tool", + favicon: "https://example.com/favicon.ico", + description: "Example description", + }, + { type: "reference", reference_ids: [1, "source-2"] }, + ], + closed: true, + signature: "sig-123", + }, + }, + }) + expect( + events + .filter((event) => event.type === "reasoning-start" || event.type === "reasoning-delta") + .every((event) => event.providerMetadata === undefined), + ).toBe(true) +}) + +test("Mistral preserves metadata-only thinking chunks", async () => { + const thinking = { + type: "thinking" as const, + thinking: [ + { + type: "tool_reference", + tool: "web_search", + title: "Example result", + url: "https://example.com/tool", + favicon: "https://example.com/favicon.ico", + description: "Example description", + }, + { type: "reference", reference_ids: [1, "source-2"] }, + ], + closed: true, + signature: "sig-123", + } + const mockFetch = Object.assign( + async () => + Response.json({ + id: "response-1", + created: 0, + model: "mistral-small-latest", + object: "chat.completion", + choices: [{ index: 0, message: { role: "assistant", content: [thinking] }, finish_reason: "stop" }], + usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }, + }), + { preconnect: fetch.preconnect }, + ) + const model = createMistral({ apiKey: "test", fetch: mockFetch })("mistral-small-latest") + const result = await model.doGenerate({ + prompt: [{ role: "user", content: [{ type: "text", text: "Hello" }] }], + }) + + expect(result.content).toEqual([ + { + type: "reasoning", + text: "", + providerMetadata: { mistral: { thinking } }, + }, + ]) +}) diff --git a/packages/desktop/src/main/server.ts b/packages/desktop/src/main/server.ts index 75ff502928..05eeab00d9 100644 --- a/packages/desktop/src/main/server.ts +++ b/packages/desktop/src/main/server.ts @@ -181,9 +181,9 @@ export async function spawnLocalServer( } export async function checkHealth(url: string, password?: string | null): Promise { - let healthUrl: URL + let healthUrls: URL[] try { - healthUrl = new URL("/global/health", url) + healthUrls = [new URL("/api/health", url), new URL("/global/health", url)] } catch { return false } @@ -194,16 +194,17 @@ export async function checkHealth(url: string, password?: string | null): Promis headers.set("authorization", `Basic ${auth}`) } - try { - const res = await fetch(healthUrl, { - method: "GET", - headers, - signal: AbortSignal.timeout(3000), - }) - return res.ok - } catch { - return false + for (const healthUrl of healthUrls) { + try { + const res = await fetch(healthUrl, { + method: "GET", + headers, + signal: AbortSignal.timeout(3000), + }) + if (res.ok) return true + } catch {} } + return false } function createSidecarEnv(): Record { diff --git a/packages/session-ui/package.json b/packages/session-ui/package.json index 96611da331..3cf74b043b 100644 --- a/packages/session-ui/package.json +++ b/packages/session-ui/package.json @@ -39,6 +39,7 @@ }, "dependencies": { "@kobalte/core": "catalog:", + "@opencode-ai/client": "file:../app/vendor/opencode-ai-client-1.17.13.tgz", "@opencode-ai/core": "workspace:*", "@opencode-ai/sdk": "workspace:*", "@opencode-ai/ui": "workspace:*", diff --git a/patches/@ai-sdk%2Fmistral@3.0.51.patch b/patches/@ai-sdk%2Fmistral@3.0.51.patch new file mode 100644 index 0000000000..141b14a689 --- /dev/null +++ b/patches/@ai-sdk%2Fmistral@3.0.51.patch @@ -0,0 +1,709 @@ +diff --git a/dist/index.d.mts b/dist/index.d.mts +index 1bde0b9f8cbe6771a52c1041095c9dddfe8e5b6c..0ca2ffb2a0c9327aed5ddcf0004500dc8b42569f 100644 +--- a/dist/index.d.mts ++++ b/dist/index.d.mts +@@ -14,6 +14,7 @@ declare const mistralLanguageModelOptions: z.ZodObject<{ + none: "none"; + high: "high"; + }>>; ++ promptCacheKey: z.ZodOptional; + }, z.core.$strip>; + type MistralLanguageModelOptions = z.infer; + +diff --git a/dist/index.d.ts b/dist/index.d.ts +index 1bde0b9f8cbe6771a52c1041095c9dddfe8e5b6c..0ca2ffb2a0c9327aed5ddcf0004500dc8b42569f 100644 +--- a/dist/index.d.ts ++++ b/dist/index.d.ts +@@ -14,6 +14,7 @@ declare const mistralLanguageModelOptions: z.ZodObject<{ + none: "none"; + high: "high"; + }>>; ++ promptCacheKey: z.ZodOptional; + }, z.core.$strip>; + type MistralLanguageModelOptions = z.infer; + +diff --git a/dist/index.js b/dist/index.js +index d3f904c12a1d582cc7b9e9a2d30273e1a8505b28..267f34e20ea392b7a85ad5259d72d50605a6f971 100644 +--- a/dist/index.js ++++ b/dist/index.js +@@ -128,11 +128,14 @@ function convertToMistralChatMessages(prompt) { + } + case "assistant": { + let text = ""; ++ const structuredContent = []; ++ let hasNativeReasoning = false; + const toolCalls = []; + for (const part of content) { + switch (part.type) { + case "text": { + text += part.text; ++ structuredContent.push({ type: "text", text: part.text }); + break; + } + case "tool-call": { +@@ -148,6 +151,13 @@ function convertToMistralChatMessages(prompt) { + } + case "reasoning": { + text += part.text; ++ const native = part.providerOptions?.mistral?.thinking; ++ if (native?.type === "thinking") { ++ hasNativeReasoning = true; ++ structuredContent.push(native); ++ break; ++ } ++ structuredContent.push({ type: "text", text: part.text }); + break; + } + default: { +@@ -159,7 +169,7 @@ function convertToMistralChatMessages(prompt) { + } + messages.push({ + role: "assistant", +- content: text, ++ content: hasNativeReasoning ? structuredContent : text, + prefix: isLastMessage ? true : void 0, + tool_calls: toolCalls.length > 0 ? toolCalls : void 0 + }); +@@ -268,7 +278,8 @@ var mistralLanguageModelOptions = import_v4.z.object({ + * - `'high'`: Enable reasoning + * - `'none'`: Disable reasoning + */ +- reasoningEffort: import_v4.z.enum(["high", "none"]).optional() ++ reasoningEffort: import_v4.z.enum(["high", "none"]).optional(), ++ promptCacheKey: import_v4.z.string().optional() + }); + + // src/mistral-error.ts +@@ -407,6 +418,7 @@ var MistralChatLanguageModel = class { + stop: stopSequences, + random_seed: seed, + reasoning_effort: options.reasoningEffort, ++ prompt_cache_key: options.promptCacheKey, + // response format: + response_format: (responseFormat == null ? void 0 : responseFormat.type) === "json" ? structuredOutputs && (responseFormat == null ? void 0 : responseFormat.schema) != null ? { + type: "json_schema", +@@ -465,9 +477,11 @@ var MistralChatLanguageModel = class { + for (const part of choice.message.content) { + if (part.type === "thinking") { + const reasoningText = extractReasoningContent(part.thinking); +- if (reasoningText.length > 0) { +- content.push({ type: "reasoning", text: reasoningText }); +- } ++ content.push({ ++ type: "reasoning", ++ text: reasoningText, ++ providerMetadata: { mistral: { thinking: part } } ++ }); + } else if (part.type === "text") { + if (part.text.length > 0) { + content.push({ type: "text", text: part.text }); +@@ -528,6 +542,7 @@ var MistralChatLanguageModel = class { + let isFirstChunk = true; + let activeText = false; + let activeReasoningId = null; ++ let activeThinking = null; + const generateId2 = this.generateId; + return { + stream: response.pipeThrough( +@@ -561,18 +576,19 @@ var MistralChatLanguageModel = class { + for (const part of delta.content) { + if (part.type === "thinking") { + const reasoningDelta = extractReasoningContent(part.thinking); +- if (reasoningDelta.length > 0) { +- if (activeReasoningId == null) { +- if (activeText) { +- controller.enqueue({ type: "text-end", id: "0" }); +- activeText = false; +- } +- activeReasoningId = generateId2(); +- controller.enqueue({ +- type: "reasoning-start", +- id: activeReasoningId +- }); ++ activeThinking = mergeThinking(activeThinking, part); ++ if (activeReasoningId == null) { ++ if (activeText) { ++ controller.enqueue({ type: "text-end", id: "0" }); ++ activeText = false; + } ++ activeReasoningId = generateId2(); ++ controller.enqueue({ ++ type: "reasoning-start", ++ id: activeReasoningId ++ }); ++ } ++ if (reasoningDelta.length > 0) { + controller.enqueue({ + type: "reasoning-delta", + id: activeReasoningId, +@@ -587,9 +603,11 @@ var MistralChatLanguageModel = class { + if (activeReasoningId != null) { + controller.enqueue({ + type: "reasoning-end", +- id: activeReasoningId ++ id: activeReasoningId, ++ providerMetadata: { mistral: { thinking: activeThinking } } + }); + activeReasoningId = null; ++ activeThinking = null; + } + controller.enqueue({ type: "text-start", id: "0" }); + activeText = true; +@@ -638,7 +656,8 @@ var MistralChatLanguageModel = class { + if (activeReasoningId != null) { + controller.enqueue({ + type: "reasoning-end", +- id: activeReasoningId ++ id: activeReasoningId, ++ providerMetadata: { mistral: { thinking: activeThinking } } + }); + } + if (activeText) { +@@ -660,6 +679,13 @@ var MistralChatLanguageModel = class { + function extractReasoningContent(thinking) { + return thinking.filter((chunk) => chunk.type === "text").map((chunk) => chunk.text).join(""); + } ++function mergeThinking(current, next) { ++ if (current === null) return { ...next, thinking: [...next.thinking] }; ++ current.thinking.push(...next.thinking); ++ if (next.closed !== void 0) current.closed = next.closed; ++ if (next.signature !== void 0) current.signature = next.signature; ++ return current; ++} + function extractTextContent(content) { + if (typeof content === "string") { + return content; +@@ -686,6 +712,30 @@ function extractTextContent(content) { + } + return textContent.length ? textContent.join("") : void 0; + } ++var mistralThinkingContentSchema = import_v43.z.discriminatedUnion("type", [ ++ import_v43.z.object({ ++ type: import_v43.z.literal("text"), ++ text: import_v43.z.string() ++ }), ++ import_v43.z.object({ ++ type: import_v43.z.literal("tool_reference"), ++ tool: import_v43.z.string(), ++ title: import_v43.z.string(), ++ url: import_v43.z.string().nullish(), ++ favicon: import_v43.z.string().nullish(), ++ description: import_v43.z.string().nullish() ++ }), ++ import_v43.z.object({ ++ type: import_v43.z.literal("reference"), ++ reference_ids: import_v43.z.array(import_v43.z.union([import_v43.z.string(), import_v43.z.number().int()])) ++ }) ++]); ++var mistralThinkChunkSchema = import_v43.z.object({ ++ type: import_v43.z.literal("thinking"), ++ thinking: import_v43.z.array(mistralThinkingContentSchema), ++ closed: import_v43.z.boolean().optional(), ++ signature: import_v43.z.string().nullish() ++}); + var mistralContentSchema = import_v43.z.union([ + import_v43.z.string(), + import_v43.z.array( +@@ -708,15 +758,7 @@ var mistralContentSchema = import_v43.z.union([ + type: import_v43.z.literal("reference"), + reference_ids: import_v43.z.array(import_v43.z.union([import_v43.z.string(), import_v43.z.number()])) + }), +- import_v43.z.object({ +- type: import_v43.z.literal("thinking"), +- thinking: import_v43.z.array( +- import_v43.z.object({ +- type: import_v43.z.literal("text"), +- text: import_v43.z.string() +- }) +- ) +- }) ++ mistralThinkChunkSchema + ]) + ) + ]).nullish(); +diff --git a/dist/index.mjs b/dist/index.mjs +index d2eff622c1b84a96bdeb4012cb0206a33012a04d..3bff11ddd6136ada45809568828cbc8f2493a42a 100644 +--- a/dist/index.mjs ++++ b/dist/index.mjs +@@ -116,11 +116,14 @@ function convertToMistralChatMessages(prompt) { + } + case "assistant": { + let text = ""; ++ const structuredContent = []; ++ let hasNativeReasoning = false; + const toolCalls = []; + for (const part of content) { + switch (part.type) { + case "text": { + text += part.text; ++ structuredContent.push({ type: "text", text: part.text }); + break; + } + case "tool-call": { +@@ -136,6 +139,13 @@ function convertToMistralChatMessages(prompt) { + } + case "reasoning": { + text += part.text; ++ const native = part.providerOptions?.mistral?.thinking; ++ if (native?.type === "thinking") { ++ hasNativeReasoning = true; ++ structuredContent.push(native); ++ break; ++ } ++ structuredContent.push({ type: "text", text: part.text }); + break; + } + default: { +@@ -147,7 +157,7 @@ function convertToMistralChatMessages(prompt) { + } + messages.push({ + role: "assistant", +- content: text, ++ content: hasNativeReasoning ? structuredContent : text, + prefix: isLastMessage ? true : void 0, + tool_calls: toolCalls.length > 0 ? toolCalls : void 0 + }); +@@ -256,7 +266,8 @@ var mistralLanguageModelOptions = z.object({ + * - `'high'`: Enable reasoning + * - `'none'`: Disable reasoning + */ +- reasoningEffort: z.enum(["high", "none"]).optional() ++ reasoningEffort: z.enum(["high", "none"]).optional(), ++ promptCacheKey: z.string().optional() + }); + + // src/mistral-error.ts +@@ -397,6 +408,7 @@ var MistralChatLanguageModel = class { + stop: stopSequences, + random_seed: seed, + reasoning_effort: options.reasoningEffort, ++ prompt_cache_key: options.promptCacheKey, + // response format: + response_format: (responseFormat == null ? void 0 : responseFormat.type) === "json" ? structuredOutputs && (responseFormat == null ? void 0 : responseFormat.schema) != null ? { + type: "json_schema", +@@ -455,9 +467,11 @@ var MistralChatLanguageModel = class { + for (const part of choice.message.content) { + if (part.type === "thinking") { + const reasoningText = extractReasoningContent(part.thinking); +- if (reasoningText.length > 0) { +- content.push({ type: "reasoning", text: reasoningText }); +- } ++ content.push({ ++ type: "reasoning", ++ text: reasoningText, ++ providerMetadata: { mistral: { thinking: part } } ++ }); + } else if (part.type === "text") { + if (part.text.length > 0) { + content.push({ type: "text", text: part.text }); +@@ -518,6 +532,7 @@ var MistralChatLanguageModel = class { + let isFirstChunk = true; + let activeText = false; + let activeReasoningId = null; ++ let activeThinking = null; + const generateId2 = this.generateId; + return { + stream: response.pipeThrough( +@@ -551,18 +566,19 @@ var MistralChatLanguageModel = class { + for (const part of delta.content) { + if (part.type === "thinking") { + const reasoningDelta = extractReasoningContent(part.thinking); +- if (reasoningDelta.length > 0) { +- if (activeReasoningId == null) { +- if (activeText) { +- controller.enqueue({ type: "text-end", id: "0" }); +- activeText = false; +- } +- activeReasoningId = generateId2(); +- controller.enqueue({ +- type: "reasoning-start", +- id: activeReasoningId +- }); ++ activeThinking = mergeThinking(activeThinking, part); ++ if (activeReasoningId == null) { ++ if (activeText) { ++ controller.enqueue({ type: "text-end", id: "0" }); ++ activeText = false; + } ++ activeReasoningId = generateId2(); ++ controller.enqueue({ ++ type: "reasoning-start", ++ id: activeReasoningId ++ }); ++ } ++ if (reasoningDelta.length > 0) { + controller.enqueue({ + type: "reasoning-delta", + id: activeReasoningId, +@@ -577,9 +593,11 @@ var MistralChatLanguageModel = class { + if (activeReasoningId != null) { + controller.enqueue({ + type: "reasoning-end", +- id: activeReasoningId ++ id: activeReasoningId, ++ providerMetadata: { mistral: { thinking: activeThinking } } + }); + activeReasoningId = null; ++ activeThinking = null; + } + controller.enqueue({ type: "text-start", id: "0" }); + activeText = true; +@@ -628,7 +646,8 @@ var MistralChatLanguageModel = class { + if (activeReasoningId != null) { + controller.enqueue({ + type: "reasoning-end", +- id: activeReasoningId ++ id: activeReasoningId, ++ providerMetadata: { mistral: { thinking: activeThinking } } + }); + } + if (activeText) { +@@ -650,6 +669,13 @@ var MistralChatLanguageModel = class { + function extractReasoningContent(thinking) { + return thinking.filter((chunk) => chunk.type === "text").map((chunk) => chunk.text).join(""); + } ++function mergeThinking(current, next) { ++ if (current === null) return { ...next, thinking: [...next.thinking] }; ++ current.thinking.push(...next.thinking); ++ if (next.closed !== void 0) current.closed = next.closed; ++ if (next.signature !== void 0) current.signature = next.signature; ++ return current; ++} + function extractTextContent(content) { + if (typeof content === "string") { + return content; +@@ -676,6 +702,30 @@ function extractTextContent(content) { + } + return textContent.length ? textContent.join("") : void 0; + } ++var mistralThinkingContentSchema = z3.discriminatedUnion("type", [ ++ z3.object({ ++ type: z3.literal("text"), ++ text: z3.string() ++ }), ++ z3.object({ ++ type: z3.literal("tool_reference"), ++ tool: z3.string(), ++ title: z3.string(), ++ url: z3.string().nullish(), ++ favicon: z3.string().nullish(), ++ description: z3.string().nullish() ++ }), ++ z3.object({ ++ type: z3.literal("reference"), ++ reference_ids: z3.array(z3.union([z3.string(), z3.number().int()])) ++ }) ++]); ++var mistralThinkChunkSchema = z3.object({ ++ type: z3.literal("thinking"), ++ thinking: z3.array(mistralThinkingContentSchema), ++ closed: z3.boolean().optional(), ++ signature: z3.string().nullish() ++}); + var mistralContentSchema = z3.union([ + z3.string(), + z3.array( +@@ -698,15 +748,7 @@ var mistralContentSchema = z3.union([ + type: z3.literal("reference"), + reference_ids: z3.array(z3.union([z3.string(), z3.number()])) + }), +- z3.object({ +- type: z3.literal("thinking"), +- thinking: z3.array( +- z3.object({ +- type: z3.literal("text"), +- text: z3.string() +- }) +- ) +- }) ++ mistralThinkChunkSchema + ]) + ) + ]).nullish(); +diff --git a/src/convert-to-mistral-chat-messages.ts b/src/convert-to-mistral-chat-messages.ts +index 3c6914f8da615d7517bc43dd56198298d0a50247..8cd6f4c7577f746ef41e8a0aee682234c473667a 100644 +--- a/src/convert-to-mistral-chat-messages.ts ++++ b/src/convert-to-mistral-chat-messages.ts +@@ -3,7 +3,11 @@ import { + type LanguageModelV3DataContent, + type LanguageModelV3Prompt, + } from '@ai-sdk/provider'; +-import type { MistralPrompt } from './mistral-chat-prompt'; ++import type { ++ MistralAssistantMessageContent, ++ MistralPrompt, ++ MistralThinkChunk, ++} from './mistral-chat-prompt'; + import { convertToBase64 } from '@ai-sdk/provider-utils'; + + function formatFileUrl({ +@@ -76,6 +80,8 @@ export function convertToMistralChatMessages( + + case 'assistant': { + let text = ''; ++ const structuredContent: Array = []; ++ let hasNativeReasoning = false; + const toolCalls: Array<{ + id: string; + type: 'function'; +@@ -86,6 +92,7 @@ export function convertToMistralChatMessages( + switch (part.type) { + case 'text': { + text += part.text; ++ structuredContent.push({ type: 'text', text: part.text }); + break; + } + case 'tool-call': { +@@ -101,6 +108,14 @@ export function convertToMistralChatMessages( + } + case 'reasoning': { + text += part.text; ++ const native = part.providerOptions?.mistral ++ ?.thinking as MistralThinkChunk | undefined; ++ if (native?.type === 'thinking') { ++ hasNativeReasoning = true; ++ structuredContent.push(native); ++ break; ++ } ++ structuredContent.push({ type: 'text', text: part.text }); + break; + } + default: { +@@ -113,7 +128,7 @@ export function convertToMistralChatMessages( + + messages.push({ + role: 'assistant', +- content: text, ++ content: hasNativeReasoning ? structuredContent : text, + prefix: isLastMessage ? true : undefined, + tool_calls: toolCalls.length > 0 ? toolCalls : undefined, + }); +diff --git a/src/mistral-chat-language-model.ts b/src/mistral-chat-language-model.ts +index 7e4a7ab552f1b41b7074e1b3cada8a51d791268d..847d26f9dfe03572a969a122f8c96b8bbfda8066 100644 +--- a/src/mistral-chat-language-model.ts ++++ b/src/mistral-chat-language-model.ts +@@ -122,6 +122,7 @@ export class MistralChatLanguageModel implements LanguageModelV3 { + stop: stopSequences, + random_seed: seed, + reasoning_effort: options.reasoningEffort, ++ prompt_cache_key: options.promptCacheKey, + + // response format: + response_format: +@@ -201,9 +202,11 @@ export class MistralChatLanguageModel implements LanguageModelV3 { + for (const part of choice.message.content) { + if (part.type === 'thinking') { + const reasoningText = extractReasoningContent(part.thinking); +- if (reasoningText.length > 0) { +- content.push({ type: 'reasoning', text: reasoningText }); +- } ++ content.push({ ++ type: 'reasoning', ++ text: reasoningText, ++ providerMetadata: { mistral: { thinking: part } }, ++ }); + } else if (part.type === 'text') { + if (part.text.length > 0) { + content.push({ type: 'text', text: part.text }); +@@ -278,6 +281,7 @@ export class MistralChatLanguageModel implements LanguageModelV3 { + let isFirstChunk = true; + let activeText = false; + let activeReasoningId: string | null = null; ++ let activeThinking: z.infer | null = null; + + const generateId = this.generateId; + +@@ -326,20 +330,21 @@ export class MistralChatLanguageModel implements LanguageModelV3 { + for (const part of delta.content) { + if (part.type === 'thinking') { + const reasoningDelta = extractReasoningContent(part.thinking); +- if (reasoningDelta.length > 0) { +- if (activeReasoningId == null) { +- // end any active text before starting reasoning +- if (activeText) { +- controller.enqueue({ type: 'text-end', id: '0' }); +- activeText = false; +- } +- +- activeReasoningId = generateId(); +- controller.enqueue({ +- type: 'reasoning-start', +- id: activeReasoningId, +- }); ++ activeThinking = mergeThinking(activeThinking, part); ++ if (activeReasoningId == null) { ++ // end any active text before starting reasoning ++ if (activeText) { ++ controller.enqueue({ type: 'text-end', id: '0' }); ++ activeText = false; + } ++ ++ activeReasoningId = generateId(); ++ controller.enqueue({ ++ type: 'reasoning-start', ++ id: activeReasoningId, ++ }); ++ } ++ if (reasoningDelta.length > 0) { + controller.enqueue({ + type: 'reasoning-delta', + id: activeReasoningId, +@@ -357,8 +362,12 @@ export class MistralChatLanguageModel implements LanguageModelV3 { + controller.enqueue({ + type: 'reasoning-end', + id: activeReasoningId, ++ providerMetadata: { ++ mistral: { thinking: activeThinking }, ++ }, + }); + activeReasoningId = null; ++ activeThinking = null; + } + controller.enqueue({ type: 'text-start', id: '0' }); + activeText = true; +@@ -416,6 +425,9 @@ export class MistralChatLanguageModel implements LanguageModelV3 { + controller.enqueue({ + type: 'reasoning-end', + id: activeReasoningId, ++ providerMetadata: { ++ mistral: { thinking: activeThinking }, ++ }, + }); + } + if (activeText) { +@@ -437,7 +449,7 @@ export class MistralChatLanguageModel implements LanguageModelV3 { + } + + function extractReasoningContent( +- thinking: Array<{ type: string; text: string }>, ++ thinking: Array>, + ) { + return thinking + .filter(chunk => chunk.type === 'text') +@@ -445,6 +457,17 @@ function extractReasoningContent( + .join(''); + } + ++function mergeThinking( ++ current: z.infer | null, ++ next: z.infer, ++) { ++ if (current === null) return { ...next, thinking: [...next.thinking] }; ++ current.thinking.push(...next.thinking); ++ if (next.closed !== undefined) current.closed = next.closed; ++ if (next.signature !== undefined) current.signature = next.signature; ++ return current; ++} ++ + function extractTextContent(content: z.infer) { + if (typeof content === 'string') { + return content; +@@ -478,6 +501,32 @@ function extractTextContent(content: z.infer) { + return textContent.length ? textContent.join('') : undefined; + } + ++const mistralThinkingContentSchema = z.discriminatedUnion('type', [ ++ z.object({ ++ type: z.literal('text'), ++ text: z.string(), ++ }), ++ z.object({ ++ type: z.literal('tool_reference'), ++ tool: z.string(), ++ title: z.string(), ++ url: z.string().nullish(), ++ favicon: z.string().nullish(), ++ description: z.string().nullish(), ++ }), ++ z.object({ ++ type: z.literal('reference'), ++ reference_ids: z.array(z.union([z.string(), z.number().int()])), ++ }), ++]); ++ ++const mistralThinkChunkSchema = z.object({ ++ type: z.literal('thinking'), ++ thinking: z.array(mistralThinkingContentSchema), ++ closed: z.boolean().optional(), ++ signature: z.string().nullish(), ++}); ++ + const mistralContentSchema = z + .union([ + z.string(), +@@ -501,15 +550,7 @@ const mistralContentSchema = z + type: z.literal('reference'), + reference_ids: z.array(z.union([z.string(), z.number()])), + }), +- z.object({ +- type: z.literal('thinking'), +- thinking: z.array( +- z.object({ +- type: z.literal('text'), +- text: z.string(), +- }), +- ), +- }), ++ mistralThinkChunkSchema, + ]), + ), + ]) +diff --git a/src/mistral-chat-options.ts b/src/mistral-chat-options.ts +index 54b29c08517d348995b6ca093b11160e453d5c8b..de30c3e7d924889339e38b1067cb26e9ada05d11 100644 +--- a/src/mistral-chat-options.ts ++++ b/src/mistral-chat-options.ts +@@ -64,6 +64,11 @@ export const mistralLanguageModelOptions = z.object({ + * - `'none'`: Disable reasoning + */ + reasoningEffort: z.enum(['high', 'none']).optional(), ++ ++ /** ++ * A stable identifier used to route requests with shared prompt prefixes. ++ */ ++ promptCacheKey: z.string().optional(), + }); + + export type MistralLanguageModelOptions = z.infer< +diff --git a/src/mistral-chat-prompt.ts b/src/mistral-chat-prompt.ts +index 13f1dced55ac4be084128127a57fbdd58115bc28..172b11dde3dd326c2f3befd99237474ed8c79285 100644 +--- a/src/mistral-chat-prompt.ts ++++ b/src/mistral-chat-prompt.ts +@@ -23,7 +23,7 @@ export type MistralUserMessageContent = + + export interface MistralAssistantMessage { + role: 'assistant'; +- content: string; ++ content: string | Array; + prefix?: boolean; + tool_calls?: Array<{ + id: string; +@@ -32,6 +32,29 @@ export interface MistralAssistantMessage { + }>; + } + ++export type MistralAssistantMessageContent = ++ | { type: 'text'; text: string } ++ | MistralThinkChunk; ++ ++export type MistralThinkChunk = { ++ type: 'thinking'; ++ thinking: Array; ++ closed?: boolean; ++ signature?: string | null; ++}; ++ ++export type MistralThinkingContent = ++ | { type: 'text'; text: string } ++ | { ++ type: 'tool_reference'; ++ tool: string; ++ title: string; ++ url?: string | null; ++ favicon?: string | null; ++ description?: string | null; ++ } ++ | { type: 'reference'; reference_ids: Array }; ++ + export interface MistralToolMessage { + role: 'tool'; + name: string; From e7ecee5df24ae16d81d01f42f4b6d71ebd70711e Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 23 Jul 2026 17:40:42 -0400 Subject: [PATCH 11/11] fix(core): isolate tool hook outcomes (#38571) --- packages/codemode/src/tool.ts | 2 +- packages/core/src/plugin/host.ts | 24 ++++++-------- packages/core/src/session/runner/llm.ts | 2 +- .../src/session/runner/publish-llm-event.ts | 31 +++++++++---------- packages/core/test/plugin.test.ts | 2 +- .../test/session-runner-tool-events.test.ts | 16 ++++++++-- 6 files changed, 40 insertions(+), 37 deletions(-) diff --git a/packages/codemode/src/tool.ts b/packages/codemode/src/tool.ts index d44e3d0f4e..93b5a23598 100644 --- a/packages/codemode/src/tool.ts +++ b/packages/codemode/src/tool.ts @@ -29,7 +29,7 @@ export type JsonSchema = { /** Either a validating Effect Schema or a render-only JSON Schema document. */ export type SchemaType = Schema.Decoder | JsonSchema -/** Executable tool tool exposed through CodeMode's `tools` object. */ +/** Executable tool exposed through CodeMode's `tools` object. */ export type Tool = { readonly _tag: "CodeModeTool" readonly description: string diff --git a/packages/core/src/plugin/host.ts b/packages/core/src/plugin/host.ts index 7761aa1823..640fdea148 100644 --- a/packages/core/src/plugin/host.ts +++ b/packages/core/src/plugin/host.ts @@ -394,19 +394,15 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int }) } return toolHooks.hook.after((event) => { - // JS plugin boundary: marshal the canonical outcome out, copy mutations back. - const output: Record = { + // Decode first so plugin mutations cannot alias the canonical outcome. + const output = { tool: event.tool, sessionID: event.sessionID, agent: event.agent, messageID: event.messageID, callID: event.callID, input: event.input, - status: event.status, - content: event.content, - metadata: event.metadata, - outputPaths: event.outputPaths, - ...(event.status === "error" ? { error: event.error } : {}), + ...Schema.decodeUnknownSync(Tool.ExecuteAfterOutcome)(event), } return Reflect.apply(callback, undefined, [output]).pipe( Effect.tap(() => { @@ -417,16 +413,16 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int return Effect.logWarning("ignoring execute.after tool status change", { tool: event.tool }) return Effect.sync(() => { if (event.status === "completed" && decoded.value.status === "completed") { - if (output.content !== event.content) event.content = decoded.value.content - if (output.metadata !== event.metadata) event.metadata = decoded.value.metadata - if (output.outputPaths !== event.outputPaths) event.outputPaths = decoded.value.outputPaths + event.content = decoded.value.content + event.metadata = decoded.value.metadata + event.outputPaths = decoded.value.outputPaths return } if (event.status === "error" && decoded.value.status === "error") { - if (output.error !== event.error) event.error = decoded.value.error - if (output.content !== event.content) event.content = decoded.value.content - if (output.metadata !== event.metadata) event.metadata = decoded.value.metadata - if (output.outputPaths !== event.outputPaths) event.outputPaths = decoded.value.outputPaths + event.error = decoded.value.error + event.content = decoded.value.content + event.metadata = decoded.value.metadata + event.outputPaths = decoded.value.outputPaths } }) }), diff --git a/packages/core/src/session/runner/llm.ts b/packages/core/src/session/runner/llm.ts index 3d92a5c813..ff00a8337b 100644 --- a/packages/core/src/session/runner/llm.ts +++ b/packages/core/src/session/runner/llm.ts @@ -130,7 +130,7 @@ const layer = Layer.effect( // Durable publishes are serialized so tool fibers and step settlement never interleave // mid-event. const serialized = (effect: Effect.Effect) => publication.withPermit(effect) - const publish = (event: LLMEvent, error?: SessionError.Error) => serialized(publisher.publish(event, error)) + const publish = (event: LLMEvent) => serialized(publisher.publish(event)) let overflowFailure: ProviderErrorEvent | undefined const providerStream = llm.stream(prepared.request).pipe( Stream.runForEach((event) => diff --git a/packages/core/src/session/runner/publish-llm-event.ts b/packages/core/src/session/runner/publish-llm-event.ts index f84253c39d..d3d21fbfc8 100644 --- a/packages/core/src/session/runner/publish-llm-event.ts +++ b/packages/core/src/session/runner/publish-llm-event.ts @@ -1,5 +1,5 @@ -import { type LLMEvent, type ProviderMetadata, type ToolContent, type ToolResultValue } from "@opencode-ai/ai" -import { Effect, Schema } from "effect" +import { type LLMEvent, type ProviderMetadata, type ToolResultValue } from "@opencode-ai/ai" +import { Effect } from "effect" import { EventV2 } from "../../event" import { ModelV2 } from "../../model" import { SessionEvent } from "../event" @@ -12,7 +12,6 @@ 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 = { @@ -28,9 +27,11 @@ const record = (value: unknown): Record => typeof value === "object" && value !== null && !Array.isArray(value) ? (value as Record) : { value } /** 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[]] +const hostedContent = (result: ToolResultValue): Tool.NonEmptyContent => { + if (result.type === "content") { + const content = Tool.nonEmpty(result.value) + if (content !== undefined) return content + } return [{ type: "text", text: Tool.stringify(result.value) }] } @@ -47,11 +48,8 @@ export const createLLMEventPublisher = (events: Pick() - const failureSnapshot = (tool: { readonly progress?: ToolRegistry.Progress }) => { - if (!tool.progress) return {} - const metadata = Tool.jsonMetadata(tool.progress, MAX_BYTES) - return metadata === undefined ? {} : { metadata } - } + const failureSnapshot = (tool: { readonly progress?: ToolRegistry.Progress }) => + tool.progress === undefined ? {} : { metadata: tool.progress } let assistantMessageID = input.assistantMessageID let stepStarted = false let stepFailed = false @@ -292,7 +290,7 @@ export const createLLMEventPublisher = (events: Pick { yield* ctx.tool .hook("execute.after", (event) => Effect.sync(() => { - if (event.status === "completed") event.content = [] as never + if (event.status === "completed") (event.content as unknown as unknown[]).splice(0) }), ) .pipe(Effect.asVoid) diff --git a/packages/core/test/session-runner-tool-events.test.ts b/packages/core/test/session-runner-tool-events.test.ts index 2ba096b306..702999719c 100644 --- a/packages/core/test/session-runner-tool-events.test.ts +++ b/packages/core/test/session-runner-tool-events.test.ts @@ -118,9 +118,7 @@ test("provider-executed success derives content and retains provider result stat 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, { phase: "visible" }), - ) + const exit = await Effect.runPromiseExit(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" })) @@ -129,6 +127,18 @@ test("interrupted progress metadata remains in the terminal failure snapshot", a }) }) +test("failure snapshot retains canonical progress above the default byte limit", async () => { + const { published, publisher } = capture("anthropic", { interruptProgress: true }) + await Effect.runPromise(publisher.publish(call)) + const detail = "x".repeat(60 * 1024) + await Effect.runPromiseExit(publisher.progress(call.id, { detail })) + await Effect.runPromise(publisher.failUnsettledTools({ type: "aborted", message: "interrupted" })) + + expect(published.find((event) => event.type === "session.tool.failed.2")?.data).toMatchObject({ + metadata: { detail }, + }) +}) + test("failure before progress omits partial output fields", async () => { const { published, publisher } = capture() await Effect.runPromise(publisher.publish(call))