From b4f769f695cd33429269ad829caa20ba172ec97a Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Fri, 7 Aug 2026 13:02:13 -0400 Subject: [PATCH] fix(core): normalize tool images once (#41097) --- packages/core/src/tool.ts | 3 +- .../test/session-runner-tool-registry.test.ts | 47 +++++++++++++++++-- 2 files changed, 44 insertions(+), 6 deletions(-) diff --git a/packages/core/src/tool.ts b/packages/core/src/tool.ts index e5e54f53d4..6719ddf980 100644 --- a/packages/core/src/tool.ts +++ b/packages/core/src/tool.ts @@ -118,13 +118,12 @@ const layer = Layer.effect( yield* hooks.trigger("tool", "execute.after", afterEvent) return yield* afterEvent.error } - const content = yield* normalizeImages(execution.value.content) const afterEvent: PluginHooks.Domains["tool"]["execute.after"] = { ...base, status: "completed", result: { ...(execution.value.output === undefined ? {} : { output: execution.value.output }), - content: content.length > 0 ? content : execution.value.content, + content: execution.value.content, ...(execution.value.metadata === undefined ? {} : { metadata: execution.value.metadata }), }, } diff --git a/packages/core/test/session-runner-tool-registry.test.ts b/packages/core/test/session-runner-tool-registry.test.ts index 0dc5244a18..7db79669b6 100644 --- a/packages/core/test/session-runner-tool-registry.test.ts +++ b/packages/core/test/session-runner-tool-registry.test.ts @@ -3,10 +3,12 @@ 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 { Tool } from "@opencode-ai/core/tool" import type { Info } from "@opencode-ai/schema/tool" +import { LayerNode } from "@opencode-ai/util/effect/layer-node" 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" @@ -26,10 +28,14 @@ const imageStore = Layer.mock(Image.Service, { maxBytes: 5, }), ) - return Effect.succeed({ ...content, content: "bm9ybWFsaXplZA==", mime: "image/jpeg" }) + return Effect.succeed({ + ...content, + content: Buffer.from(`${Buffer.from(content.content, "base64").toString()} normalized`).toString("base64"), + mime: "image/jpeg", + }) }, }) -const registryLayer = AppNodeBuilder.build(Tool.node, [[Image.node, imageStore]]) +const registryLayer = AppNodeBuilder.build(LayerNode.group([Tool.node, PluginHooks.node]), [[Image.node, imageStore]]) const it = testEffect(registryLayer) const identity = { agent: Agent.ID.make("build"), @@ -344,7 +350,7 @@ describe("Tool", () => { }), ) - it.effect("normalizes image tool output at execution and drops unresizable images", () => + it.effect("normalizes image tool output once and drops unresizable images", () => Effect.gen(function* () { const service = yield* Tool.Service yield* transform(service, @@ -376,7 +382,12 @@ describe("Tool", () => { 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: "file", + uri: "data:image/jpeg;base64,aW1hZ2Ugbm9ybWFsaXplZA==", + mime: "image/jpeg", + name: "frame.png", + }, { type: "text", text: "snapshot" }, { type: "text", text: "[1 image omitted: could not be decoded.]" }, { type: "text", text: "[1 image omitted: could not be resized below the image size limit.]" }, @@ -384,6 +395,34 @@ describe("Tool", () => { }), ) + it.effect("normalizes image content added by an after hook", () => + Effect.gen(function* () { + const service = yield* Tool.Service + const hooks = yield* PluginHooks.Service + yield* transform(service, { hooked: constant("original") }, { codemode: false }) + yield* hooks.register("tool", "execute.after", (event) => + Effect.sync(() => { + if (event.status !== "completed") return + event.result = { + ...event.result, + content: [ + { type: "file", uri: "data:image/png;base64,aW1hZ2U=", mime: "image/png", name: "hook.png" }, + ], + } + }), + ) + + expect((yield* executeTool(service, call("hooked"))).content).toEqual([ + { + type: "file", + uri: "data:image/jpeg;base64,aW1hZ2Ugbm9ybWFsaXplZA==", + mime: "image/jpeg", + name: "hook.png", + }, + ]) + }), + ) + it.effect("publishes progress metadata unchanged", () => Effect.gen(function* () { const service = yield* Tool.Service