diff --git a/packages/core/src/session/model-request.ts b/packages/core/src/session/model-request.ts index cbd27f531d2..ec1cede967a 100644 --- a/packages/core/src/session/model-request.ts +++ b/packages/core/src/session/model-request.ts @@ -53,7 +53,9 @@ export interface Prepared { * One request-scoped execution operation. Unknown and hook-removed calls * fail individually through the same seam. */ - readonly executeTool: (input: Parameters[0]) => Effect.Effect + readonly executeTool: ( + input: Parameters[0], + ) => Effect.Effect } interface PrepareInput { diff --git a/packages/core/src/session/runner/publish-llm-event.ts b/packages/core/src/session/runner/publish-llm-event.ts index 46e9356fc2f..d7cc98d3ec6 100644 --- a/packages/core/src/session/runner/publish-llm-event.ts +++ b/packages/core/src/session/runner/publish-llm-event.ts @@ -4,7 +4,7 @@ import type { Model } from "@opencode-ai/schema/model" import type { RelativePath } from "@opencode-ai/schema/schema" import type { Snapshot } from "@opencode-ai/schema/snapshot" import { Effect, Fiber, Iterable } from "effect" -import { isArrayNonEmpty, isReadonlyArrayNonEmpty } from "effect/Array" +import { isReadonlyArrayNonEmpty } from "effect/Array" import { Bus } from "../../bus.js" import { SessionEvent } from "../event.js" import { SessionMessage } from "../message.js" @@ -12,7 +12,7 @@ import { SessionSchema } from "../schema.js" import { SessionError } from "@opencode-ai/schema/session-error" import { Money } from "@opencode-ai/schema/money" import { SessionUsage } from "../usage.js" -import { Tool } from "@opencode-ai/schema/tool" +import type { Tool } from "../../tool.js" type Input = { readonly sessionID: SessionSchema.ID @@ -557,20 +557,15 @@ export const createLLMEventPublisher = (bus: Pick, inp }) /** Publishes one canonical terminal event for a locally executed tool call. */ - const toolExecution = Effect.fnUntraced(function* (id: string, name: string, result: Tool.Result) { + const toolExecution = Effect.fnUntraced(function* (id: string, name: string, result: Tool.NormalizedResult) { const tool = tools.get(id) if (!tool?.called) return yield* Effect.die(new Error(`Tool execution before call: ${id}`)) if (tool.name !== name) return yield* Effect.die(new Error(`Tool execution name changed for ${id}: ${tool.name} -> ${name}`)) if (tool.settled) return yield* Effect.die(new Error(`Duplicate tool execution: ${id}`)) tool.settled = true - const content = - typeof result.content === "string" - ? [{ type: "text" as const, text: result.content }] - : result.content === undefined - ? [] - : [...result.content] - if (!isArrayNonEmpty(content)) return yield* Effect.die(new Error(`Tool execution has no content: ${id}`)) + const content = result.content + if (!isReadonlyArrayNonEmpty(content)) return yield* Effect.die(new Error(`Tool execution has no content: ${id}`)) yield* bus.publish(SessionEvent.Tool.Success, { sessionID: input.sessionID, assistantMessageID, diff --git a/packages/core/src/tool-output.ts b/packages/core/src/tool-output.ts index f908cce6f38..53738be14be 100644 --- a/packages/core/src/tool-output.ts +++ b/packages/core/src/tool-output.ts @@ -1,7 +1,7 @@ export * as ToolOutput from "./tool-output.js" import path from "path" -import type { Tool } from "@opencode-ai/schema/tool" +import type { Tool } from "./tool.js" import { Context, Duration, Effect, Layer, Schedule } from "effect" import { makeGlobalNode, makeLocationNode } from "@opencode-ai/util/effect/app-node" import { FSUtil } from "@opencode-ai/util/fs-util" @@ -15,7 +15,7 @@ export const MAX_BYTES = 50 * 1024 // 50 KiB export const RETENTION = Duration.days(7) export const DIRECTORY = "tool-output" -type Result = Tool.Result +type Result = Tool.NormalizedResult type Limits = { maxLines: number @@ -64,8 +64,7 @@ const layer = Layer.effect( const truncate = Effect.fnUntraced(function* (result: Result) { if (result.metadata?.truncated !== undefined) return result - const content = - typeof result.content === "string" ? [{ type: "text" as const, text: result.content }] : (result.content ?? []) + const content = result.content const text = content.flatMap((item) => (item.type === "text" ? [item.text] : [])).join("\n") const limits = state.get() const lines = text.split("\n") diff --git a/packages/core/src/tool.ts b/packages/core/src/tool.ts index 1137d047849..cc66732df8a 100644 --- a/packages/core/src/tool.ts +++ b/packages/core/src/tool.ts @@ -42,6 +42,11 @@ export interface Interface extends State.Transformable { readonly snapshot: (permissions?: Permission.Ruleset) => Effect.Effect } +/** A local execution result after hooks and content normalization. */ +export interface NormalizedResult extends Tool.Result { + readonly content: ReadonlyArray +} + export interface Snapshot { readonly definitions: ReadonlyArray readonly codeModeCatalog?: CodeModeCatalog.Inventory @@ -53,7 +58,7 @@ export interface Snapshot { readonly progress?: (update: Tool.Metadata) => Effect.Effect /** Surviving request definitions, keyed by the names advertised after session context hooks. */ readonly definitions?: ReadonlyMap - }) => Effect.Effect }, Tool.Error> + }) => Effect.Effect } export class Service extends Context.Service()("@opencode/Tool") {} diff --git a/packages/core/test/config/tool-output.test.ts b/packages/core/test/config/tool-output.test.ts index a8cb7544130..81c1a76e6c3 100644 --- a/packages/core/test/config/tool-output.test.ts +++ b/packages/core/test/config/tool-output.test.ts @@ -26,7 +26,9 @@ describe("ConfigToolOutputPlugin.Plugin", () => { const plugins = yield* Plugin.Service yield* ConfigToolOutputPlugin.Plugin.effect(yield* PluginHost.make(plugins)) - expect((yield* output.truncate({ content: "one\ntwo" })).metadata?.truncated).toBe(true) + expect((yield* output.truncate({ content: [{ type: "text", text: "one\ntwo" }] })).metadata?.truncated).toBe( + true, + ) yield* config.setEntries([ new Document({ @@ -38,7 +40,7 @@ describe("ConfigToolOutputPlugin.Plugin", () => { ]) yield* bus.publish(Event.Updated, {}) for (let attempt = 0; attempt < 200; attempt++) { - const result = yield* output.truncate({ content: "one\ntwo" }) + const result = yield* output.truncate({ content: [{ type: "text", text: "one\ntwo" }] }) if (result.metadata?.truncated === false) return yield* Effect.sleep("10 millis") } diff --git a/packages/core/test/session-step.test.ts b/packages/core/test/session-step.test.ts index 063ade46afa..9170c4b97b7 100644 --- a/packages/core/test/session-step.test.ts +++ b/packages/core/test/session-step.test.ts @@ -111,7 +111,7 @@ for (const fixture of [ executeTool: () => Effect.sync(() => { executions++ - return { content: "Completed tool" } + return { content: [{ type: "text", text: "Completed tool" }] } }), }, retry: (_cause, _error, retry) => diff --git a/packages/core/test/tool-output.test.ts b/packages/core/test/tool-output.test.ts index 35e47d19c1b..35dfef6a3c2 100644 --- a/packages/core/test/tool-output.test.ts +++ b/packages/core/test/tool-output.test.ts @@ -4,6 +4,7 @@ import { Effect } from "effect" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { LayerNode } from "@opencode-ai/util/effect/layer-node" import { ToolOutput } from "@opencode-ai/core/tool-output" +import type { Tool } from "@opencode-ai/core/tool" import { FSUtil } from "@opencode-ai/util/fs-util" import { Global } from "@opencode-ai/util/global" import { Identifier } from "@opencode-ai/core/id/id" @@ -36,7 +37,7 @@ describe("ToolOutput", () => { (service, fs) => Effect.gen(function* () { const output = { items: [1, 2, 3] } - const result = yield* service.truncate({ output, content: "one\ntwo\nthree" }) + const result = yield* service.truncate({ output, content: [{ type: "text", text: "one\ntwo\nthree" }] }) expect(result.output).toBe(output) expect(result.metadata).toMatchObject({ truncated: true }) const outputPath = result.metadata?.outputPath @@ -56,7 +57,7 @@ describe("ToolOutput", () => { withStore( (output) => Effect.gen(function* () { - const result = yield* output.truncate({ content: "one\ntwo" }) + const result = yield* output.truncate({ content: [{ type: "text", text: "one\ntwo" }] }) expect(result.content).toEqual([ { type: "text", text: "one" }, { @@ -91,8 +92,9 @@ describe("ToolOutput", () => { it.live("skips results that report a truncation state", () => withStore((output) => Effect.gen(function* () { - const truncated = { content: "one\ntwo", metadata: { truncated: true, source: "tool" } } - const retained = { content: "one\ntwo", metadata: { truncated: false, source: "tool" } } + const content: Tool.NormalizedResult["content"] = [{ type: "text", text: "one\ntwo" }] + const truncated = { content, metadata: { truncated: true, source: "tool" } } + const retained = { content, metadata: { truncated: false, source: "tool" } } expect(yield* output.truncate(truncated)).toBe(truncated) expect(yield* output.truncate(retained)).toBe(retained) }), @@ -112,8 +114,8 @@ describe("ToolOutput", () => { withStore( (output) => Effect.gen(function* () { - expect(yield* output.truncate({ content: "one\ntwo\n" })).toEqual({ - content: "one\ntwo\n", + expect(yield* output.truncate({ content: [{ type: "text", text: "one\ntwo\n" }] })).toEqual({ + content: [{ type: "text", text: "one\ntwo\n" }], metadata: { truncated: false }, }) }), @@ -125,7 +127,7 @@ describe("ToolOutput", () => { withStore( (output) => Effect.gen(function* () { - const result = yield* output.truncate({ content: "one\n" }) + const result = yield* output.truncate({ content: [{ type: "text", text: "one\n" }] }) expect(result.content).toEqual([ { type: "text", text: "one" }, { type: "text", text: expect.stringMatching(/^\.\.\. 1 byte truncated; full content saved to /) }, diff --git a/packages/core/test/tool-registry.test.ts b/packages/core/test/tool-registry.test.ts index ef6397e72b1..f7d7c761db9 100644 --- a/packages/core/test/tool-registry.test.ts +++ b/packages/core/test/tool-registry.test.ts @@ -3,11 +3,13 @@ import { Agent } from "@opencode-ai/core/agent" import type { Permission } from "@opencode-ai/core/permission" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { Image } from "@opencode-ai/core/image" +import { PluginHooks } from "@opencode-ai/core/plugin/hooks" import { Session } from "@opencode-ai/core/session" import { SessionMessage } from "@opencode-ai/core/session/message" import { State } from "@opencode-ai/core/state" import { Tool } from "@opencode-ai/core/tool" import type { Info } from "@opencode-ai/schema/tool" +import { LayerNode } from "@opencode-ai/util/effect/layer-node" import { codeModeListings, executeTool, toolDefinitions } from "./lib/tool" import { Deferred, Effect, Exit, Fiber, Layer, Logger, Schema, SchemaGetter, SchemaIssue, Scope } from "effect" import { z } from "zod" @@ -35,7 +37,9 @@ const imageStore = Layer.mock(Image.Service, { }) }, }) -const registryLayer = AppNodeBuilder.build(Tool.node, [Image.node.replace(imageStore)]) +const registryLayer = AppNodeBuilder.build(LayerNode.group([Tool.node, PluginHooks.node]), [ + Image.node.replace(imageStore), +]) const it = testEffect(registryLayer) const identity = { agent: Agent.ID.make("build"), @@ -843,6 +847,36 @@ describe("Tool", () => { ]) }), ) + ;[ + { name: "string", content: "hooked", text: "hooked" }, + { name: "empty string", content: "", text: "" }, + { name: "missing content", content: undefined, text: '{"text":"hooked"}' }, + { name: "empty content array", content: [], text: '{"text":"hooked"}' }, + ].forEach((input) => { + it.effect(`normalizes ${input.name} after the final tool hook`, () => + Effect.gen(function* () { + const service = yield* Tool.Service + const hooks = yield* PluginHooks.Service + yield* transform(service, { echo: make() }, { codemode: false }) + yield* hooks.register("tool", "execute.after", (event) => + Effect.sync(() => { + if (event.status !== "completed") return + event.result = { + output: { text: "hooked" }, + content: input.content, + metadata: { source: "hook" }, + } + }), + ) + const snapshot = yield* service.snapshot() + expect(yield* snapshot.execute(call("echo"))).toEqual({ + output: { text: "hooked" }, + content: [{ type: "text", text: input.text }], + metadata: { source: "hook" }, + }) + }), + ) + }) it.effect("normalizes image tool output once and drops unresizable images", () => Effect.gen(function* () {