mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-22 00:33:27 +00:00
refactor(core): move image config into state (#43090)
This commit is contained in:
parent
5ff6bb87cf
commit
4b9d89e943
9 changed files with 216 additions and 71 deletions
40
packages/core/src/config/plugin/image.ts
Normal file
40
packages/core/src/config/plugin/image.ts
Normal file
|
|
@ -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 }),
|
||||
})
|
||||
}
|
||||
})
|
||||
}),
|
||||
})
|
||||
|
|
@ -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<ResizerUnavailableError>()(
|
||||
"Image.ResizerUnavailableError",
|
||||
|
|
@ -32,7 +32,18 @@ export class SizeError extends Schema.TaggedError<SizeError>()("Image.SizeError"
|
|||
}
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
export type Limits = {
|
||||
autoResize: boolean
|
||||
maxWidth: number
|
||||
maxHeight: number
|
||||
maxBase64Bytes: number
|
||||
}
|
||||
|
||||
export type Draft = {
|
||||
configure: (limits: Partial<Limits>) => void
|
||||
}
|
||||
|
||||
export interface Interface extends State.Transformable<Draft> {
|
||||
readonly normalize: (
|
||||
resource: string,
|
||||
content: FileSystem.Content & { readonly encoding: "base64" },
|
||||
|
|
@ -47,7 +58,23 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/Im
|
|||
const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const config = yield* Config.Service
|
||||
const state = State.create<Limits, Draft>({
|
||||
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: [] })
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
14
packages/core/src/plugin/supervisor-service.ts
Normal file
14
packages/core/src/plugin/supervisor-service.ts
Normal file
|
|
@ -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<void>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/PluginSupervisor") {}
|
||||
|
|
@ -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<void>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/PluginSupervisor") {}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
86
packages/core/test/config/image.test.ts
Normal file
86
packages/core/test/config/image.test.ts
Normal file
|
|
@ -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<typeof Info.Encoded.media>["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<boolean>) {
|
||||
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"))
|
||||
})
|
||||
|
|
@ -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<LocationServices>,
|
||||
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<LocationServices>,
|
||||
),
|
||||
)
|
||||
const it = testEffect(
|
||||
|
|
|
|||
|
|
@ -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<Image.Service>) =>
|
||||
Layer.mergeAll(
|
||||
AppNodeBuilder.build(LayerNode.group([Tool.node, readToolNode]), [
|
||||
|
|
@ -146,8 +143,9 @@ const readLayer = (imageLayer: Layer.Layer<Image.Service>) =>
|
|||
[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(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue