diff --git a/packages/core/src/config/plugin/image.ts b/packages/core/src/config/plugin/image.ts new file mode 100644 index 0000000000..4ba9361560 --- /dev/null +++ b/packages/core/src/config/plugin/image.ts @@ -0,0 +1,40 @@ +export * as ConfigImagePlugin from "./image.js" + +import { define } from "@opencode-ai/plugin/effect/plugin" +import { Effect, Stream } from "effect" +import { Config } from "../../config.js" +import { Image } from "../../image.js" + +export const Plugin = define({ + id: "opencode.config.image", + effect: Effect.fn(function* (ctx) { + const config = yield* Config.Service + const image = yield* Image.Service + const loaded = { entries: yield* config.entries() } + const reload = config.entries().pipe( + Effect.tap((entries) => Effect.sync(() => (loaded.entries = entries))), + Effect.andThen(image.reload()), + ) + yield* ctx.event.subscribe().pipe( + Stream.filter((event) => event.type === "config.updated"), + Stream.runForEach(() => reload), + Effect.forkScoped({ startImmediately: true }), + ) + // Refetch after subscribing so a config update between the first read and + // the live subscription cannot leave the transform on a stale snapshot. + loaded.entries = yield* config.entries() + yield* image.transform((draft) => { + for (const entry of loaded.entries) { + if (entry.type !== "document") continue + const configured = entry.info.media?.image + if (!configured) continue + draft.configure({ + ...(configured.auto_resize === undefined ? {} : { autoResize: configured.auto_resize }), + ...(configured.max_width === undefined ? {} : { maxWidth: configured.max_width }), + ...(configured.max_height === undefined ? {} : { maxHeight: configured.max_height }), + ...(configured.max_base64_bytes === undefined ? {} : { maxBase64Bytes: configured.max_base64_bytes }), + }) + } + }) + }), +}) diff --git a/packages/core/src/image.ts b/packages/core/src/image.ts index 2325dcb417..b62c28d50b 100644 --- a/packages/core/src/image.ts +++ b/packages/core/src/image.ts @@ -2,8 +2,8 @@ export * as Image from "./image.js" import { makeLocationNode } from "@opencode-ai/util/effect/app-node" import { Context, Effect, Layer, Schema } from "effect" -import { Config } from "./config.js" import { FileSystem } from "./filesystem.js" +import { State } from "./state.js" export class ResizerUnavailableError extends Schema.TaggedError()( "Image.ResizerUnavailableError", @@ -32,7 +32,18 @@ export class SizeError extends Schema.TaggedError()("Image.SizeError" } } -export interface Interface { +export type Limits = { + autoResize: boolean + maxWidth: number + maxHeight: number + maxBase64Bytes: number +} + +export type Draft = { + configure: (limits: Partial) => void +} + +export interface Interface extends State.Transformable { readonly normalize: ( resource: string, content: FileSystem.Content & { readonly encoding: "base64" }, @@ -47,7 +58,23 @@ export class Service extends Context.Service()("@opencode/Im const layer = Layer.effect( Service, Effect.gen(function* () { - const config = yield* Config.Service + const state = State.create({ + name: "image", + initial: () => ({ + autoResize: true, + maxWidth: 2_000, + maxHeight: 2_000, + maxBase64Bytes: 5 * 1024 * 1024, + }), + draft: (draft) => ({ + configure: (limits) => { + if (limits.autoResize !== undefined) draft.autoResize = limits.autoResize + if (limits.maxWidth !== undefined) draft.maxWidth = limits.maxWidth + if (limits.maxHeight !== undefined) draft.maxHeight = limits.maxHeight + if (limits.maxBase64Bytes !== undefined) draft.maxBase64Bytes = limits.maxBase64Bytes + }, + }), + }) const loadAdapter = yield* Effect.cached( Effect.tryPromise({ try: () => import("./image/photon.js"), @@ -58,22 +85,11 @@ const layer = Layer.effect( resource: string, content: FileSystem.Content & { readonly encoding: "base64" }, ) { - const image = Object.assign( - {}, - ...(yield* config.entries()).flatMap((entry) => - entry.type === "document" && entry.info.media?.image ? [entry.info.media.image] : [], - ), - ) const normalize = yield* loadAdapter - return yield* normalize(resource, content, { - autoResize: image.auto_resize ?? true, - maxWidth: image.max_width ?? 2_000, - maxHeight: image.max_height ?? 2_000, - maxBase64Bytes: image.max_base64_bytes ?? 5 * 1024 * 1024, - }) + return yield* normalize(resource, content, state.get()) }) - return Service.of({ normalize }) + return Service.of({ transform: state.transform, reload: state.reload, normalize }) }), ) -export const node = makeLocationNode({ service: Service, layer, deps: [Config.node] }) +export const node = makeLocationNode({ service: Service, layer, deps: [] }) diff --git a/packages/core/src/plugin/internal.ts b/packages/core/src/plugin/internal.ts index 0736d1f862..98d0c59f1b 100644 --- a/packages/core/src/plugin/internal.ts +++ b/packages/core/src/plugin/internal.ts @@ -14,6 +14,7 @@ import { Credential } from "../credential.js" import { ConfigAgentPlugin } from "../config/plugin/agent.js" import { ConfigCommandPlugin } from "../config/plugin/command.js" import { ConfigFormatterPlugin } from "../config/plugin/formatter.js" +import { ConfigImagePlugin } from "../config/plugin/image.js" import { ConfigInstructionPlugin } from "../config/plugin/instruction.js" import { ConfigMCPPlugin } from "../config/plugin/mcp.js" import { ConfigProviderPlugin } from "../config/plugin/provider.js" @@ -238,6 +239,7 @@ const post = [ ConfigAgentPlugin.Plugin, ConfigCommandPlugin.Plugin, ConfigFormatterPlugin.Plugin, + ConfigImagePlugin.Plugin, ConfigSkillPlugin.Plugin, ConfigProviderPlugin.Plugin, ConfigWebSearchPlugin.Plugin, diff --git a/packages/core/src/plugin/supervisor-service.ts b/packages/core/src/plugin/supervisor-service.ts new file mode 100644 index 0000000000..5770346b6d --- /dev/null +++ b/packages/core/src/plugin/supervisor-service.ts @@ -0,0 +1,14 @@ +export * as PluginSupervisor from "./supervisor-service.js" + +import { Context, Effect } from "effect" + +/** + * Dependency-only supervisor seam. Keep this module free of implementation + * imports: the supervisor reaches PluginRuntime, which depends on Session. + */ +export interface Interface { + /** Wait for the initial plugin generation and startup updates to settle. */ + readonly flush: Effect.Effect +} + +export class Service extends Context.Service()("@opencode/PluginSupervisor") {} diff --git a/packages/core/src/plugin/supervisor.ts b/packages/core/src/plugin/supervisor.ts index d8e5c4c192..03f649fae6 100644 --- a/packages/core/src/plugin/supervisor.ts +++ b/packages/core/src/plugin/supervisor.ts @@ -1,8 +1,9 @@ export * as PluginSupervisor from "./supervisor.js" +export { Service, type Interface } from "./supervisor-service.js" import type { Plugin as PluginDefinition } from "@opencode-ai/plugin/effect/plugin" import { Event } from "@opencode-ai/schema/config" -import { Cause, Context, Deferred, Effect, Layer, Schema, Stream } from "effect" +import { Cause, Deferred, Effect, Layer, Schema, Stream } from "effect" import path from "path" import { pathToFileURL } from "url" import { ConfigPluginSource } from "../config/plugin/source.js" @@ -14,6 +15,7 @@ import { PluginPromise } from "../plugin/promise.js" import { PluginInternal } from "./internal.js" import { SdkPlugins } from "./sdk.js" import { importModule } from "@opencode-ai/util/runtime-import" +import { Service } from "./supervisor-service.js" const PluginModule = Schema.Struct({ default: Schema.Union([ @@ -128,13 +130,6 @@ const load = Effect.fn("PluginSupervisor.load")(function* ( } satisfies Plugin.Versioned }) -export interface Interface { - /** Wait for the initial plugin generation and startup updates to settle. */ - readonly flush: Effect.Effect -} - -export class Service extends Context.Service()("@opencode/PluginSupervisor") {} - export const layer = Layer.effect( Service, Effect.gen(function* () { diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts index 4cd2634961..a7df388250 100644 --- a/packages/core/src/session.ts +++ b/packages/core/src/session.ts @@ -40,6 +40,7 @@ import { SessionRevert } from "./session/revert.js" import { Session } from "@opencode-ai/schema/session" import { FSUtil } from "@opencode-ai/util/fs-util" import { Image } from "./image.js" +import { PluginSupervisor } from "./plugin/supervisor-service.js" import { Mime } from "./mime.js" import type { EventLog } from "@opencode-ai/schema/event-log" import { Event } from "@opencode-ai/schema/event" @@ -579,7 +580,11 @@ const layer = Layer.effect( if (session.revert) yield* SessionRevert.commit(session).pipe(Effect.provideService(Bus.Service, bus)) // Resolved lazily so prompt admission only boots location services when an // image attachment actually needs the resizer. - const image = Image.Service.pipe(Effect.provide(locations.get(session.location))) + const image = Effect.gen(function* () { + const plugins = yield* PluginSupervisor.Service + yield* plugins.flush + return yield* Image.Service + }).pipe(Effect.provide(locations.get(session.location))) const skills = Skill.Service.pipe(Effect.provide(locations.get(session.location))) const prompt = yield* resolvePrompt( { text: input.text, files: input.files, agents: input.agents, skills: input.skills }, diff --git a/packages/core/test/config/image.test.ts b/packages/core/test/config/image.test.ts new file mode 100644 index 0000000000..dde9e962dc --- /dev/null +++ b/packages/core/test/config/image.test.ts @@ -0,0 +1,86 @@ +import { describe, expect } from "bun:test" +import { Bus } from "@opencode-ai/core/bus" +import { Config } from "@opencode-ai/core/config" +import { ConfigImagePlugin } from "@opencode-ai/core/config/plugin/image" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { Image } from "@opencode-ai/core/image" +import { Plugin } from "@opencode-ai/core/plugin" +import { PluginHost } from "@opencode-ai/core/plugin/host" +import { Document, Event, Info, type Entry } from "@opencode-ai/schema/config" +import { Effect, Layer, Schema, Stream } from "effect" +import { testEffect } from "../lib/effect" +import { PluginTestLayer } from "../plugin/fixture" + +const it = testEffect(Layer.merge(PluginTestLayer, AppNodeBuilder.build(Image.node))) +const decode = Schema.decodeUnknownSync(Info) +const content = { + uri: "file:///pixel.png", + content: "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=", + encoding: "base64" as const, + mime: "image/png", +} + +describe("ConfigImagePlugin.Plugin", () => { + it.live("merges image limits and reloads changed config", () => + Effect.gen(function* () { + const image = yield* Image.Service + const bus = yield* Bus.Service + const config = yield* Config.Test + const plugins = yield* Plugin.Service + yield* ConfigImagePlugin.Plugin.effect(yield* PluginHost.make(plugins)) + + expect(yield* limits(image)).toEqual({ maxWidth: 1_200, maxHeight: 900, maxBytes: 1 }) + + yield* config.setEntries([document({ auto_resize: false, max_width: 700, max_base64_bytes: 1 })]) + yield* bus.publish(Event.Updated, {}) + yield* waitUntil( + limits(image).pipe( + Effect.map((current) => current.maxWidth === 700 && current.maxHeight === 2_000 && current.maxBytes === 1), + ), + ) + }).pipe( + Effect.provide( + Config.testLayer([ + document({ auto_resize: false, max_width: 1_200 }), + document({ max_height: 900, max_base64_bytes: 1 }), + ]), + ), + ), + ) + + it.live("refetches config after subscribing to updates", () => + Effect.gen(function* () { + const image = yield* Image.Service + const plugins = yield* Plugin.Service + let reads = 0 + const config = Config.Service.of({ + entries: () => Effect.sync(() => [document({ max_width: reads++ === 0 ? 1_200 : 700, max_base64_bytes: 1 })]), + update: () => Effect.die(new Error("Config update is unavailable")), + changes: () => Stream.empty, + }) + yield* ConfigImagePlugin.Plugin.effect(yield* PluginHost.make(plugins)).pipe( + Effect.provideService(Config.Service, config), + ) + + expect(yield* limits(image)).toEqual({ maxWidth: 700, maxHeight: 2_000, maxBytes: 1 }) + }), + ) +}) + +function document(image: NonNullable["image"]): Entry { + return new Document({ type: "document", info: decode({ media: { image } }) }) +} + +const limits = Effect.fnUntraced(function* (image: Image.Interface) { + const error = yield* image.normalize("pixel.png", content).pipe(Effect.flip, Effect.orDie) + if (error._tag !== "Image.SizeError") return yield* Effect.die(error) + return { maxWidth: error.maxWidth, maxHeight: error.maxHeight, maxBytes: error.maxBytes } +}) + +const waitUntil = Effect.fnUntraced(function* (condition: Effect.Effect) { + for (let attempt = 0; attempt < 200; attempt++) { + if (yield* condition) return + yield* Effect.sleep("10 millis") + } + yield* Effect.die(new Error("Timed out waiting for image config reload")) +}) diff --git a/packages/core/test/session-prompt.test.ts b/packages/core/test/session-prompt.test.ts index d8471cedab..8f4548eea7 100644 --- a/packages/core/test/session-prompt.test.ts +++ b/packages/core/test/session-prompt.test.ts @@ -27,6 +27,7 @@ import { SessionStore } from "@opencode-ai/core/session/store" import { LocationServiceMap } from "@opencode-ai/core/location-service-map" import type { LocationServices } from "@opencode-ai/core/location-services" import { Image } from "@opencode-ai/core/image" +import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor" import { testEffect } from "./lib/effect" const executionCalls: Session.ID[] = [] @@ -59,12 +60,25 @@ const locations = Layer.effect( LocationServiceMap.Service, LayerMap.make( () => - // Attachment admission only needs the location-scoped Image service. + // Attachment admission only needs image normalization and plugin readiness. // oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion - Layer.mock(Image.Service, { - normalize: (_resource, content) => - Effect.succeed(content.content.length > 5 * 1024 * 1024 ? { ...content, content: "AA==" } : content), - }) as unknown as Layer.Layer, + Layer.unwrap( + Effect.sync(() => { + let ready = false + return Layer.mergeAll( + Layer.mock(Image.Service, { + normalize: (_resource, content) => + ready + ? Effect.succeed(content.content.length > 5 * 1024 * 1024 ? { ...content, content: "AA==" } : content) + : Effect.die(new Error("Image service used before plugins were ready")), + }), + Layer.succeed( + PluginSupervisor.Service, + PluginSupervisor.Service.of({ flush: Effect.sync(() => (ready = true)) }), + ), + ) + }), + ) as unknown as Layer.Layer, ), ) const it = testEffect( diff --git a/packages/core/test/tool-read.test.ts b/packages/core/test/tool-read.test.ts index f00c1711e4..107b62ea31 100644 --- a/packages/core/test/tool-read.test.ts +++ b/packages/core/test/tool-read.test.ts @@ -1,9 +1,7 @@ import { beforeEach, describe, expect } from "bun:test" import path from "path" -import { Effect, Exit, Layer, Stream } from "effect" +import { Effect, Exit, Layer } from "effect" import { Config } from "@opencode-ai/core/config" -import { Document, Info } from "@opencode-ai/schema/config" -import { ConfigMedia } from "@opencode-ai/schema/config/media" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { LayerNode } from "@opencode-ai/util/effect/layer-node" import { FileSystem } from "@opencode-ai/core/filesystem" @@ -90,7 +88,7 @@ const permission = permissionLayer({ ), }) const config = Config.testLayer() -const imageLayer = AppNodeBuilder.build(Image.node, [[Config.node, config]]) +const imageLayer = AppNodeBuilder.build(Image.node) const testFileSystem = Layer.effect( FSUtil.Service, FSUtil.Service.use((fs) => @@ -130,10 +128,9 @@ const mutation = Layer.succeed( }, }), ) -const unavailableImage = Layer.succeed( - Image.Service, - Image.Service.of({ normalize: () => Effect.fail(new Image.ResizerUnavailableError()) }), -) +const unavailableImage = Layer.mock(Image.Service, { + normalize: () => Effect.fail(new Image.ResizerUnavailableError()), +}) const readLayer = (imageLayer: Layer.Layer) => Layer.mergeAll( AppNodeBuilder.build(LayerNode.group([Tool.node, readToolNode]), [ @@ -146,8 +143,9 @@ const readLayer = (imageLayer: Layer.Layer) => [Location.node, locationLayer], [Global.node, Global.layerWith({ data: Global.Path.data })], ]), - // Merge by reference so Config.Test resolves to the memoized instance. + // Merge by reference so Config.Test and Image.Service resolve to the memoized instances. config, + imageLayer, ) const it = testEffect(readLayer(imageLayer)) const itWithoutResizer = testEffect(readLayer(unavailableImage)) @@ -384,17 +382,8 @@ describe("ReadTool", () => { encoding: "base64", mime: "image/png", } - const configTest = yield* Config.Test - yield* configTest.setEntries([ - new Document({ - type: "document", - info: new Info({ - media: new ConfigMedia.Info({ - image: new ConfigMedia.Image({ auto_resize: false, max_width: 4 }), - }), - }), - }), - ]) + const image = yield* Image.Service + yield* image.transform((draft) => draft.configure({ autoResize: false, maxWidth: 4 })) const registry = yield* Tool.Service expect( @@ -427,15 +416,8 @@ describe("ReadTool", () => { encoding: "base64", mime: "image/png", } - const configTest = yield* Config.Test - yield* configTest.setEntries([ - new Document({ - type: "document", - info: new Info({ - media: new ConfigMedia.Info({ image: new ConfigMedia.Image({ max_width: 4 }) }), - }), - }), - ]) + const image = yield* Image.Service + yield* image.transform((draft) => draft.configure({ maxWidth: 4 })) const registry = yield* Tool.Service const result = yield* executeTool(registry, { sessionID, @@ -466,17 +448,8 @@ describe("ReadTool", () => { encoding: "base64", mime: "image/png", } - const configTest = yield* Config.Test - yield* configTest.setEntries([ - new Document({ - type: "document", - info: new Info({ - media: new ConfigMedia.Info({ - image: new ConfigMedia.Image({ max_base64_bytes: 1 }), - }), - }), - }), - ]) + const image = yield* Image.Service + yield* image.transform((draft) => draft.configure({ maxBase64Bytes: 1 })) const registry = yield* Tool.Service expect(