Compare commits

..

2 Commits

Author SHA1 Message Date
usrnk1 b567db3495 feat(desktop): show model variants in notices 2026-08-19 09:52:20 +02:00
usrnk1 06dffe3671 feat(desktop): add model change notices 2026-08-18 22:04:56 +02:00
53 changed files with 586 additions and 1103 deletions
-6
View File
@@ -1,6 +0,0 @@
---
"@opencode-ai/plugin": patch
"@opencode-ai/core": patch
---
Add transport-neutral Session model request hooks and provider-scoped hook registration so eligible OpenAI Responses requests can prefer WebSocket without bypassing HTTP-only middleware.
@@ -905,7 +905,6 @@ const onContentBlockDelta = Effect.fn("AnthropicMessages.onContentBlockDelta")(f
if (delta?.type === "input_json_delta" && event.index !== undefined) {
if (!delta.partial_json) return [state, NO_EVENTS] satisfies StepResult
if (!state.tools[event.index]) return [state, NO_EVENTS] satisfies StepResult
const result = ToolStream.appendExisting(
ADAPTER,
state.tools,
@@ -1010,34 +1010,6 @@ describe("Anthropic Messages route", () => {
}),
)
it.effect("ignores tool input deltas without a matching tool start", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ type: "message_start", message: { usage: { input_tokens: 5 } } },
{ type: "content_block_start", index: 0, content_block: { type: "text", text: "" } },
{ type: "content_block_delta", index: 0, delta: { type: "text_delta", text: "Hello" } },
{
type: "content_block_delta",
index: 1,
delta: { type: "input_json_delta", partial_json: '{"query":"orphaned"}' },
},
{ type: "content_block_stop", index: 0 },
{ type: "message_delta", delta: { stop_reason: "end_turn" }, usage: { output_tokens: 1 } },
{ type: "message_stop" },
),
),
),
)
expect(response.text).toBe("Hello")
expect(response.toolCalls).toEqual([])
expect(response.finishReason).toEqual({ normalized: "stop", raw: "end_turn" })
}),
)
it.effect("settles pending tool calls at message_stop", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
@@ -852,8 +852,26 @@ function provider() {
name: "OpenCode",
models: { "claude-opus-4-6": { id: "claude-opus-4-6", name: "Claude Opus 4.6", limit: { context: 200_000 } } },
},
{
id: "company-gateway",
name: "Company Gateway",
models: {
"fast-nano": {
id: "fast-nano",
api: { id: "openai/gpt-5.4-nano" },
name: "GPT-5.4 nano",
limit: { context: 128_000 },
},
"long-context": {
id: "long-context",
api: { id: "company/long-context" },
name: "Company Gateway Extra Long Context Model for Narrow Timeline Layouts",
limit: { context: 128_000 },
},
},
},
],
connected: ["opencode"],
connected: ["opencode", "company-gateway"],
default: { providerID: "opencode", modelID: "claude-opus-4-6" },
}
}
@@ -128,6 +128,43 @@ test.describe("session timeline projection", () => {
expect(rows).toEqual(["AssistantPart", "TurnDivider", "AssistantPart"])
})
test("renders aliased and long custom model notices", async ({ page }) => {
const shortName = "GPT-5.4 nano"
const longName = "Company Gateway Extra Long Context Model for Narrow Timeline Layouts"
await setupTimeline(page, {
viewport: { width: 420, height: 700 },
sessionMessages: [
{
id: "msg_model_fast_nano",
type: "model-switched",
time: { created: 1700000000000 },
model: { providerID: "company-gateway", id: "fast-nano", variant: "xhigh" },
},
{
id: "msg_model_long_context",
type: "model-switched",
time: { created: 1700000001000 },
model: { providerID: "company-gateway", id: "long-context" },
},
userMessage(),
assistantMessage(),
],
})
const shortNotice = page.locator('[data-slot="session-timeline-notice"]').filter({ hasText: shortName })
const longNotice = page.locator('[data-slot="session-timeline-notice"]').filter({ hasText: longName })
await expect(shortNotice).toBeVisible()
await expect(shortNotice.getByText(`Switched to ${shortName}`, { exact: true })).toBeVisible()
await expect(shortNotice.locator('[data-slot="session-timeline-notice-variant"]')).toHaveText("xhigh")
await expect(page.getByText("fast-nano", { exact: true })).toHaveCount(0)
await expect(shortNotice.locator('[data-component="provider-icon"]')).toBeVisible()
await expect(longNotice).toBeVisible()
await expect(longNotice.locator('[data-component="provider-icon"]')).toBeVisible()
await expect(longNotice.locator('[data-slot="session-timeline-notice-variant"]')).toHaveCount(0)
await expect(longNotice.locator("[title]")).toHaveAttribute("title", `Switched to ${longName}`)
await expect.poll(() => longNotice.evaluate((element) => element.scrollWidth <= element.clientWidth)).toBe(true)
})
test("renders user image, file attachment, file reference, and agent reference", async ({ page }) => {
const text = "Use @explore with @src/a.ts and inspect the attachments"
const parts: PartSeed<"user">[] = [
+1 -1
View File
@@ -473,7 +473,7 @@ function currentModels(value: unknown) {
return [
{
id: model.id,
modelID: model.id,
modelID: record(model.api) && typeof model.api.id === "string" ? model.api.id : model.id,
providerID: provider.id,
name: model.name,
capabilities: { tools: true, input: ["text"], output: ["text"] },
+1
View File
@@ -670,6 +670,7 @@ export const dict = {
"session.timeline.notice.finished": "{{actor}} finished",
"session.timeline.notice.failed": "{{actor}} failed",
"session.timeline.notice.cancelled": "{{actor}} cancelled",
"session.timeline.notice.modelSwitched": "Switched to {{model}}",
"session.error.serverConnection": "Can't connect to this server",
"session.review.filesChanged": "Files Changed {{count}}",
"session.review.change.one": "Change",
@@ -13,9 +13,10 @@ import {
import { createStore } from "solid-js/store"
import { createVirtualizer, defaultRangeExtractor, elementScroll, type VirtualItem } from "@tanstack/solid-virtual"
import { Card } from "@opencode-ai/ui/card"
import { MessageDivider, SessionShellMessage, type UserActions } from "@opencode-ai/session-ui/message-part"
import { SessionShellMessage, type UserActions } from "@opencode-ai/session-ui/message-part"
import { DiffChanges } from "@opencode-ai/ui/diff-changes"
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
@@ -103,6 +104,37 @@ function TimelineThinkingRow(props: { reasoningHeading?: string; showReasoningSu
)
}
function TimelineSeparator(props: { label: string; providerID?: string; variant?: string }) {
return (
<div class="flex h-8 w-full items-center gap-3 text-v2-text-text-faint">
<span class="h-px min-w-0 flex-1 bg-v2-border-border-strong" />
<span class="flex min-w-0 items-center gap-1 text-[13px] font-[440] leading-4 tracking-[-0.04px]">
<Show when={props.providerID}>
{(providerID) => <ProviderIcon id={providerID()} class="text-v2-icon-icon-faint" aria-hidden="true" />}
</Show>
<span class="flex min-w-0 items-center gap-1.5">
<span class="truncate" title={props.label}>
{props.label}
</span>
<Show when={props.variant && props.variant !== "default" ? props.variant : undefined}>
{(variant) => (
<>
<span class="flex size-1.5 shrink-0 items-center justify-center" aria-hidden="true">
<span class="size-[2.25px] rounded-full bg-current" />
</span>
<span data-slot="session-timeline-notice-variant" class="shrink-0 capitalize">
{variant()}
</span>
</>
)}
</Show>
</span>
</span>
<span class="h-px min-w-0 flex-1 bg-v2-border-border-strong" />
</div>
)
}
function WorkspaceMoveAction(props: {
variant: "inline" | "panel"
eligible: boolean
@@ -369,11 +401,7 @@ function MessageTimelineView(
label: language.t("ui.tool.agent.default"),
data: message.previous ? `${message.previous}${message.agent}` : message.agent,
}
if (message.type === "model-switched")
return {
label: language.t("command.category.model"),
data: `${message.model.providerID}/${message.model.id}`,
}
if (message.type === "model-switched") return undefined
if (message.type === "location-switched")
return { label: language.t("ui.patch.action.moved"), data: message.location.directory }
if (message.type === "skill") return { label: language.t("ui.tool.skill"), data: message.name }
@@ -952,20 +980,50 @@ function MessageTimelineView(
}
case "Notice": {
const noticeRow = row as Accessor<TimelineRowByTag<"Notice">>
const model = createMemo(() => {
const message = sessionMessageByID().get(noticeRow().messageID)
if (message?.type !== "model-switched") return undefined
const info = data.location.model
.list({ directory: sessionDirectory() })
?.find((item) => item.providerID === message.model.providerID && item.id === message.model.id)
return {
providerID: message.model.providerID,
variant: message.model.variant,
label: language.t("session.timeline.notice.modelSwitched", { model: info?.name ?? message.model.id }),
}
})
const content = createMemo(() => {
const message = sessionMessageByID().get(noticeRow().messageID)
return message ? noticeContent(message) : undefined
})
return (
<TimelineRowFrame row={noticeRow()}>
<Show when={content()}>
{(content) => (
<Show
when={model()}
fallback={
<Show when={content()}>
{(content) => (
<div
data-slot="session-timeline-notice"
class={`w-full pt-3 pb-1 text-13-regular text-text-weak ${turnPadding()}`}
>
<span class="text-13-medium">{content().label}</span>
<Show when={content().data}>{(data) => <span> · {data()}</span>}</Show>
</div>
)}
</Show>
}
>
{(model) => (
<div
data-slot="session-timeline-notice"
class={`w-full pt-3 pb-1 text-13-regular text-text-weak ${turnPadding()}`}
class={`w-full py-2 ${turnPadding()}`}
>
<span class="text-13-medium">{content().label}</span>
<Show when={content().data}>{(data) => <span> · {data()}</span>}</Show>
<TimelineSeparator
label={model().label}
providerID={model().providerID}
variant={model().variant}
/>
</div>
)}
</Show>
@@ -978,7 +1036,9 @@ function MessageTimelineView(
<TimelineRowFrame row={turnDividerRow()}>
<div data-slot="session-turn-message-container" class={`w-full ${turnPadding()}`}>
<div data-slot="session-turn-compaction">
<MessageDivider label={language.t("ui.message.interrupted")} />
<div class="py-2">
<TimelineSeparator label={language.t("ui.message.interrupted")} />
</div>
</div>
</div>
</TimelineRowFrame>
+1 -1
View File
@@ -42,8 +42,8 @@
"solid-js": "catalog:",
"tree-sitter-bash": "0.25.0",
"tree-sitter-powershell": "0.25.10",
"uqr": "0.1.3",
"web-tree-sitter": "0.25.10",
"uqr": "0.1.3",
"ws": "8.21.0"
},
"devDependencies": {
@@ -1,70 +0,0 @@
export * as ConfigFormatterPlugin from "./formatter.js"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Global } from "@opencode-ai/util/global"
import { Npm } from "@opencode-ai/util/npm"
import { AppProcess } from "@opencode-ai/util/process"
import { Effect, Stream } from "effect"
import { Config } from "../../config.js"
import { Formatter } from "../../formatter.js"
import { make, type Info } from "../../formatter/builtins.js"
import { Location } from "../../location.js"
export const Plugin = define({
id: "opencode.config.formatter",
effect: Effect.fn(function* (ctx) {
const config = yield* Config.Service
const formatter = yield* Formatter.Service
const fs = yield* FSUtil.Service
const global = yield* Global.Service
const location = yield* Location.Service
const npm = yield* Npm.Service
const processes = yield* AppProcess.Service
const loaded = { entries: yield* config.entries() }
const reload = config.entries().pipe(
Effect.tap((entries) => Effect.sync(() => (loaded.entries = entries))),
Effect.andThen(formatter.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* formatter.transform((draft) => {
const configured = Config.latest(loaded.entries, "formatter")
if (!configured) return
const builtIns = make({
directory: location.directory,
worktree: location.project.directory,
fs,
npm,
processes,
bin: global.bin,
})
builtIns.forEach(draft.set)
if (configured === true) return
for (const [name, entry] of Object.entries(configured)) {
if (entry.disabled) {
draft.remove(name)
continue
}
const builtIn = builtIns.find((formatter) => formatter.name === name)
const current: Info = {
name,
extensions: entry.extensions ?? builtIn?.extensions ?? [],
environment: { ...builtIn?.environment, ...entry.environment },
enabled:
builtIn && !entry.command ? builtIn.enabled : Effect.succeed(entry.command ? [...entry.command] : false),
}
draft.set(current)
}
})
}),
})
-40
View File
@@ -1,40 +0,0 @@
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 }),
})
}
})
}),
})
+58 -34
View File
@@ -4,21 +4,15 @@ import { Context, Effect, Layer } from "effect"
import { ChildProcess } from "effect/unstable/process"
import path from "path"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Npm } from "@opencode-ai/util/npm"
import { AppProcess } from "@opencode-ai/util/process"
import { Global } from "@opencode-ai/util/global"
import { Config } from "./config.js"
import { Location } from "./location.js"
import type { Info } from "./formatter/builtins.js"
import { State } from "./state.js"
import { make, type Info } from "./formatter/builtins.js"
type Data = {
formatters: Info[]
}
export type Draft = {
set: (formatter: Info) => void
remove: (name: string) => void
}
export interface Interface extends State.Transformable<Draft> {
export interface Interface {
readonly file: (filepath: string) => Effect.Effect<boolean>
}
@@ -27,36 +21,66 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/v2
const layer = Layer.effect(
Service,
Effect.gen(function* () {
const config = yield* Config.Service
const fs = yield* FSUtil.Service
const location = yield* Location.Service
const npm = yield* Npm.Service
const processes = yield* AppProcess.Service
const commands = new WeakMap<Info, string[] | false>()
const state = State.create<Data, Draft>({
name: "formatter",
initial: () => ({ formatters: [] }),
draft: (draft) => ({
set: (formatter) => {
const index = draft.formatters.findIndex((item) => item.name === formatter.name)
if (index === -1) draft.formatters.push(formatter)
else draft.formatters[index] = formatter
},
remove: (name) => {
draft.formatters = draft.formatters.filter((formatter) => formatter.name !== name)
},
}),
})
const global = yield* Global.Service
const commands = new Map<string, string[] | false>()
let formatters: Info[] = []
const load = yield* Effect.cached(
Effect.gen(function* () {
const configured = Config.latest(yield* config.entries(), "formatter")
if (!configured) {
yield* Effect.logInfo("all formatters are disabled")
return
}
const builtIns = make({
directory: location.directory,
worktree: location.project.directory,
fs,
npm,
processes,
bin: global.bin,
})
formatters = builtIns
if (configured === true) return
for (const [name, entry] of Object.entries(configured)) {
const index = formatters.findIndex((formatter) => formatter.name === name)
if (entry.disabled) {
if (index !== -1) formatters.splice(index, 1)
continue
}
const builtIn = builtIns.find((formatter) => formatter.name === name)
const formatter: Info = {
name,
extensions: entry.extensions ?? builtIn?.extensions ?? [],
environment: { ...builtIn?.environment, ...entry.environment },
enabled:
builtIn && !entry.command ? builtIn.enabled : Effect.succeed(entry.command ? [...entry.command] : false),
}
if (index === -1) formatters.push(formatter)
else formatters[index] = formatter
}
}).pipe(Effect.withSpan("Formatter.load")),
)
const command = Effect.fnUntraced(function* (formatter: Info) {
const cached = commands.get(formatter)
const cached = commands.get(formatter.name)
if (cached !== undefined) return cached
const result = yield* formatter.enabled
if (result !== false) commands.set(formatter, result)
if (result !== false) commands.set(formatter.name, result)
return result
})
const file = Effect.fn("Formatter.file")(function* (filepath: string) {
const matching = state
.get()
.formatters.filter((formatter) => formatter.extensions.includes(path.extname(filepath)))
yield* load
const matching = formatters.filter((formatter) => formatter.extensions.includes(path.extname(filepath)))
for (const formatter of matching) {
const enabled = yield* command(formatter)
@@ -94,12 +118,12 @@ const layer = Layer.effect(
return false
})
return Service.of({ transform: state.transform, reload: state.reload, file })
return Service.of({ file })
}),
)
export const node = makeLocationNode({
service: Service,
layer,
deps: [Location.node, AppProcess.node],
deps: [Config.node, FSUtil.node, Location.node, Npm.node, AppProcess.node, Global.node],
})
+17 -33
View File
@@ -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,18 +32,7 @@ export class SizeError extends Schema.TaggedError<SizeError>()("Image.SizeError"
}
}
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> {
export interface Interface {
readonly normalize: (
resource: string,
content: FileSystem.Content & { readonly encoding: "base64" },
@@ -58,23 +47,7 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/Im
const layer = Layer.effect(
Service,
Effect.gen(function* () {
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 config = yield* Config.Service
const loadAdapter = yield* Effect.cached(
Effect.tryPromise({
try: () => import("./image/photon.js"),
@@ -85,11 +58,22 @@ 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, state.get())
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 Service.of({ transform: state.transform, reload: state.reload, normalize })
return Service.of({ normalize })
}),
)
export const node = makeLocationNode({ service: Service, layer, deps: [] })
export const node = makeLocationNode({ service: Service, layer, deps: [Config.node] })
+21 -43
View File
@@ -4,7 +4,6 @@ import type { AISDKHooks } from "@opencode-ai/plugin/effect/aisdk"
import type { SessionHooks } from "@opencode-ai/plugin/effect/session"
import type { ShellHooks } from "@opencode-ai/plugin/effect/shell"
import type { ToolFailures, ToolHooks } from "@opencode-ai/plugin/effect/tool"
import type { ModelHookOptions } from "@opencode-ai/plugin/effect/registration"
import { Context, Effect, Layer, Scope } from "effect"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { State } from "../state.js"
@@ -27,26 +26,16 @@ interface Failures extends Record<keyof Domains, unknown> {
}
type Callback<Event, Error> = (event: Event) => Effect.Effect<void, Error>
type Entry = { readonly callback: Function; readonly options?: ModelHookOptions }
const eventProviderID = (event: unknown) => {
if (typeof event !== "object" || event === null || !("model" in event)) return undefined
const model = event.model
if (typeof model !== "object" || model === null || !("providerID" in model)) return undefined
return typeof model.providerID === "string" ? model.providerID : undefined
}
export interface Interface {
readonly has: <Domain extends keyof Domains>(
domain: Domain,
name: keyof Domains[Domain] & keyof Failures[Domain],
providerID?: string,
) => Effect.Effect<boolean>
readonly register: <Domain extends keyof Domains, Name extends keyof Domains[Domain] & keyof Failures[Domain]>(
domain: Domain,
name: Name,
callback: Callback<Domains[Domain][Name], Failures[Domain][Name]>,
options?: ModelHookOptions,
) => Effect.Effect<State.Registration, never, Scope.Scope>
readonly trigger: <Domain extends keyof Domains, Name extends keyof Domains[Domain] & keyof Failures[Domain]>(
domain: Domain,
@@ -60,47 +49,36 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/Pl
const layer = Layer.effect(
Service,
Effect.gen(function* () {
const callbacks = new Map<string, Entry[]>()
const callbacks = new Map<string, Function[]>()
const key = (domain: keyof Domains, name: PropertyKey) => `${domain}.${String(name)}`
const register: Interface["register"] = Effect.fn("PluginHooks.register")(
function* (domain, name, callback, options) {
const scope = yield* Scope.Scope
const id = key(domain, name)
let active = true
const entry = { callback, options }
callbacks.set(id, [...(callbacks.get(id) ?? []), entry])
const dispose = Effect.sync(() => {
if (!active) return
active = false
const next = (callbacks.get(id) ?? []).filter((item) => item !== entry)
if (next.length === 0) callbacks.delete(id)
else callbacks.set(id, next)
})
yield* Scope.addFinalizer(scope, dispose)
return { dispose }
},
)
const register: Interface["register"] = Effect.fn("PluginHooks.register")(function* (domain, name, callback) {
const scope = yield* Scope.Scope
const id = key(domain, name)
let active = true
callbacks.set(id, [...(callbacks.get(id) ?? []), callback])
const dispose = Effect.sync(() => {
if (!active) return
active = false
const next = (callbacks.get(id) ?? []).filter((item) => item !== callback)
if (next.length === 0) callbacks.delete(id)
else callbacks.set(id, next)
})
yield* Scope.addFinalizer(scope, dispose)
return { dispose }
})
const trigger: Interface["trigger"] = Effect.fnUntraced(function* (domain, name, event) {
for (const entry of callbacks.get(key(domain, name)) ?? []) {
if (entry.options?.providerID !== undefined && entry.options.providerID !== eventProviderID(event)) continue
const result: Effect.Effect<void, Failures[typeof domain][typeof name]> = Reflect.apply(
entry.callback,
undefined,
[event],
)
for (const callback of callbacks.get(key(domain, name)) ?? []) {
const result: Effect.Effect<void, Failures[typeof domain][typeof name]> = Reflect.apply(callback, undefined, [
event,
])
yield* result
}
return event
})
const has: Interface["has"] = (domain, name, providerID) =>
Effect.sync(() =>
(callbacks.get(key(domain, name)) ?? []).some(
(entry) => entry.options?.providerID === undefined || entry.options.providerID === providerID,
),
)
const has: Interface["has"] = (domain, name) => Effect.sync(() => callbacks.has(key(domain, name)))
return Service.of({ has, register, trigger })
}),
+2 -4
View File
@@ -104,10 +104,9 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: import("../p
}),
},
aisdk: {
hook: (name, callback, options) => {
hook: (name, callback) => {
if (name === "sdk") {
return aisdk.hook.sdk((event) => {
if (options?.providerID !== undefined && options.providerID !== event.model.providerID) return Effect.void
const output = {
model: mutable(event.model),
package: event.package,
@@ -120,7 +119,6 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: import("../p
})
}
return aisdk.hook.language((event) => {
if (options?.providerID !== undefined && options.providerID !== event.model.providerID) return Effect.void
const output = {
model: mutable(event.model),
options: event.options,
@@ -384,7 +382,7 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: import("../p
}),
},
session: {
hook: (name, callback, options) => hooks.register("session", name, callback, options),
hook: (name, callback) => hooks.register("session", name, callback),
create: (input) =>
runtime.session.create({
id: input?.id,
-8
View File
@@ -3,7 +3,6 @@ export * as PluginInternal from "./internal.js"
import type { Plugin } from "@opencode-ai/plugin/effect/plugin"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { httpClient } from "@opencode-ai/util/effect/app-node-platform"
import { AppProcess } from "@opencode-ai/util/process"
import { Context, Effect, Scope } from "effect"
import { HttpClient } from "effect/unstable/http"
import { Agent } from "../agent.js"
@@ -13,8 +12,6 @@ import { Config } from "../config.js"
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"
@@ -80,7 +77,6 @@ import { WellKnownPlugin } from "../wellknown/plugin.js"
const services = Effect.fn("PluginInternal.services")(function* () {
const agent = yield* Agent.Service
const processes = yield* AppProcess.Service
const catalog = yield* Catalog.Service
const command = yield* Command.Service
const config = yield* Config.Service
@@ -119,7 +115,6 @@ const services = Effect.fn("PluginInternal.services")(function* () {
const wellknown = yield* WellKnown.Service
return Context.mergeAll(
Context.make(Agent.Service, agent),
Context.make(AppProcess.Service, processes),
Context.make(Catalog.Service, catalog),
Context.make(Command.Service, command),
Context.make(Config.Service, config),
@@ -165,7 +160,6 @@ export type Requirements = ContextServices<Effect.Success<ReturnType<typeof serv
export const requirements = LayerNode.group([
Agent.node,
AppProcess.node,
Catalog.node,
Command.node,
Config.node,
@@ -238,8 +232,6 @@ const post = [
ConfigReferencePlugin.Plugin,
ConfigAgentPlugin.Plugin,
ConfigCommandPlugin.Plugin,
ConfigFormatterPlugin.Plugin,
ConfigImagePlugin.Plugin,
ConfigSkillPlugin.Plugin,
ConfigProviderPlugin.Plugin,
ConfigWebSearchPlugin.Plugin,
@@ -241,22 +241,19 @@ export const GithubCopilotPlugin = define({
evt.sdk = mod.createOpenaiCompatible(evt.options)
}),
)
yield* ctx.session.hook(
"http.request",
(evt) =>
Effect.gen(function* () {
if (evt.model.providerID !== Provider.ID.githubCopilot) return
if (evt.agent === Agent.ID.make("title"))
evt.request.headers.set("X-Interaction-Type", "conversation-background")
if (evt.agent === Agent.ID.make("compaction"))
evt.request.headers.set("X-Interaction-Type", "conversation-compaction")
const token = evt.request.headers.get("x-api-key")
if (!token) return
const text = yield* Effect.promise(() => evt.request.clone().text())
const body = Option.getOrUndefined(decodeBody(text))
applyHeaders(evt.request.headers, token, ctx.app, requestMetadata(evt.request.url, body), true)
}),
{ providerID: Provider.ID.githubCopilot },
yield* ctx.session.hook("http.request", (evt) =>
Effect.gen(function* () {
if (evt.model.providerID !== Provider.ID.githubCopilot) return
if (evt.agent === Agent.ID.make("title"))
evt.request.headers.set("X-Interaction-Type", "conversation-background")
if (evt.agent === Agent.ID.make("compaction"))
evt.request.headers.set("X-Interaction-Type", "conversation-compaction")
const token = evt.request.headers.get("x-api-key")
if (!token) return
const text = yield* Effect.promise(() => evt.request.clone().text())
const body = Option.getOrUndefined(decodeBody(text))
applyHeaders(evt.request.headers, token, ctx.app, requestMetadata(evt.request.url, body), true)
}),
)
yield* ctx.aisdk.hook(
"language",
+10 -11
View File
@@ -5,6 +5,7 @@ import { App } from "../../app.js"
import { Credential } from "../../credential.js"
import { Bus } from "../../bus.js"
import { Integration } from "../../integration.js"
import { Model } from "../../model.js"
import { OauthCallbackPage } from "../../oauth/page.js"
import { Provider } from "../../provider.js"
import type { PluginInternal } from "../internal.js"
@@ -229,17 +230,15 @@ export const OpenAIPlugin = define({
})
}
})
yield* ctx.session.hook(
"model.request",
(evt) =>
Effect.sync(() => {
if (!chatgpt) return
if (evt.baseURL && URL.canParse(evt.baseURL) && new URL(evt.baseURL).origin === "https://api.openai.com")
evt.baseURL = codexBaseURL
evt.headers.originator = "opencode"
evt.headers["session-id"] = evt.sessionID
}),
{ providerID: Provider.ID.openai },
yield* ctx.session.hook("http.request", (evt) =>
Effect.sync(() => {
if (!chatgpt || evt.model.providerID !== Provider.ID.openai) return
const url = new URL(evt.request.url)
evt.request.headers.set("originator", "opencode")
evt.request.headers.set("session-id", evt.sessionID)
if (url.origin !== "https://api.openai.com") return
evt.request = new Request(`${codexBaseURL}${url.pathname.replace(/^\/v1/, "")}${url.search}`, evt.request)
}),
)
const refresh = () => loading.withPermit(load().pipe(Effect.andThen(ctx.catalog.reload())))
yield* bus.subscribe(Integration.Event.ConnectionUpdated).pipe(
@@ -1,14 +0,0 @@
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") {}
+8 -3
View File
@@ -1,9 +1,8 @@
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, Deferred, Effect, Layer, Schema, Stream } from "effect"
import { Cause, Context, Deferred, Effect, Layer, Schema, Stream } from "effect"
import path from "path"
import { pathToFileURL } from "url"
import { ConfigPluginSource } from "../config/plugin/source.js"
@@ -15,7 +14,6 @@ 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([
@@ -130,6 +128,13 @@ 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* () {
+1 -6
View File
@@ -40,7 +40,6 @@ 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"
@@ -580,11 +579,7 @@ 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 = Effect.gen(function* () {
const plugins = yield* PluginSupervisor.Service
yield* plugins.flush
return yield* Image.Service
}).pipe(Effect.provide(locations.get(session.location)))
const image = 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 },
+15 -18
View File
@@ -12,7 +12,6 @@ import { llmClient } from "../effect/app-node-platform.js"
import { SessionEvent } from "./event.js"
import type { SessionMessage } from "./message.js"
import { SessionModelHeaders } from "./model-headers.js"
import { SessionModelHook } from "./model-hook.js"
import { SessionModelHttp } from "./model-http.js"
import { SessionPromptCacheKey } from "./prompt-cache-key.js"
import { App } from "../app.js"
@@ -271,25 +270,23 @@ const make = (dependencies: Dependencies) => {
})
: Effect.void,
)
const request = yield* SessionModelHook.apply(
dependencies.hooks,
{ sessionID: plan.session.id, agent: Agent.ID.make("compaction"), model: plan.ref },
LLM.request({
model: plan.model,
promptCacheKey: SessionPromptCacheKey.make(plan.session.id),
http: { headers: SessionModelHeaders.make(plan.session, dependencies.app) },
messages: [Message.user(plan.prompt)],
tools: [],
}),
)
yield* dependencies.llm
.stream(request, {
http: SessionModelHttp.middleware(dependencies.hooks, {
sessionID: plan.session.id,
agent: Agent.ID.make("compaction"),
model: plan.ref,
.stream(
LLM.request({
model: plan.model,
promptCacheKey: SessionPromptCacheKey.make(plan.session.id),
http: { headers: SessionModelHeaders.make(plan.session, dependencies.app) },
messages: [Message.user(plan.prompt)],
tools: [],
}),
})
{
http: SessionModelHttp.middleware(dependencies.hooks, {
sessionID: plan.session.id,
agent: Agent.ID.make("compaction"),
model: plan.ref,
}),
},
)
.pipe(
Stream.runForEach((event) => {
if (LLMEvent.is.providerError(event))
+8 -11
View File
@@ -11,7 +11,6 @@ import { SessionContext } from "./context.js"
import { SessionGenerate } from "./generate.js"
import { SessionHistory } from "./history.js"
import { SessionModelHeaders } from "./model-headers.js"
import { SessionModelHook } from "./model-hook.js"
import { SessionModelHttp } from "./model-http.js"
import { SessionPromptCacheKey } from "./prompt-cache-key.js"
import { SessionRunnerModel } from "./runner/model.js"
@@ -72,9 +71,7 @@ export const layer = Layer.effect(
providerID: model.ref.providerID,
modelID: model.ref.id,
})
const request = yield* SessionModelHook.apply(
hooks,
{ sessionID: selection.session.id, agent: selection.agent.id, model: model.ref },
const response = yield* llm.generate(
LLM.request({
model: model.model,
http: { headers: SessionModelHeaders.make(selection.session, app) },
@@ -83,14 +80,14 @@ export const layer = Layer.effect(
messages: contextEvent.messages,
tools: hookedTools,
}),
{
http: SessionModelHttp.middleware(hooks, {
sessionID: selection.session.id,
agent: selection.agent.id,
model: model.ref,
}),
},
)
const response = yield* llm.generate(request, {
http: SessionModelHttp.middleware(hooks, {
sessionID: selection.session.id,
agent: selection.agent.id,
model: model.ref,
}),
})
yield* Effect.logInfo("session generation usage diagnostic", { usage: response.usage })
return response.text
}),
+7 -21
View File
@@ -180,27 +180,13 @@ export const admitCompaction = Effect.fn("SessionInbox.admitCompaction")(functio
bus: Bus.Interface,
input: { readonly id: SessionMessage.ID; readonly sessionID: SessionSchema.ID; readonly delivery: Delivery },
) {
return yield* serialized(
input.sessionID,
Effect.gen(function* () {
const exact = yield* find(db, input.id)
if (exact) {
if (exact.type === "compaction" && exact.sessionID === input.sessionID) return exact
return yield* Effect.die(new LifecycleConflict({ id: input.id }))
}
if (yield* promotedFromMessage(db, input.sessionID, input.id, input.delivery))
return yield* Effect.die(new LifecycleConflict({ id: input.id }))
const pending = (yield* list(db, input.sessionID)).find((item) => item.type === "compaction")
if (pending) return pending
const admitted = yield* admit(db, bus, {
id: input.id,
sessionID: input.sessionID,
item: Item.make({ type: "compaction", payload: {}, delivery: input.delivery }),
})
if (admitted.type === "compaction") return admitted
return yield* Effect.die(new LifecycleConflict({ id: input.id }))
}),
)
const admitted = yield* admit(db, bus, {
id: input.id,
sessionID: input.sessionID,
item: Item.make({ type: "compaction", payload: {}, delivery: input.delivery }),
})
if (admitted.type === "compaction") return admitted
return yield* Effect.die(new LifecycleConflict({ id: input.id }))
})
export const projectAdmitted = Effect.fn("SessionInbox.projectAdmitted")(function* (
-34
View File
@@ -1,34 +0,0 @@
export * as SessionModelHook from "./model-hook.js"
import { HttpOptions, LanguageModel, LLMRequest } from "@opencode-ai/ai"
import type { Agent } from "@opencode-ai/schema/agent"
import type { Model } from "@opencode-ai/schema/model"
import type { Session } from "@opencode-ai/schema/session"
import { Effect } from "effect"
import { PluginHooks } from "../plugin/hooks.js"
export const apply = (
hooks: PluginHooks.Interface,
input: { readonly sessionID: Session.ID; readonly agent: Agent.ID; readonly model: Model.Ref },
request: LLMRequest,
) =>
Effect.gen(function* () {
const currentBaseURL = request.model.route.endpoint.baseURL
const event = yield* hooks.trigger("session", "model.request", {
...input,
baseURL: typeof currentBaseURL === "string" ? currentBaseURL : undefined,
headers: { ...request.http?.headers },
})
const route =
event.baseURL !== undefined && event.baseURL !== currentBaseURL
? request.model.route.with({ endpoint: { baseURL: event.baseURL } })
: request.model.route
return LLMRequest.update(request, {
model: route === request.model.route ? request.model : LanguageModel.update(request.model, { route }),
http: new HttpOptions({
body: request.http?.body,
headers: Object.keys(event.headers).length === 0 ? undefined : event.headers,
query: request.http?.query,
}),
})
})
+14 -20
View File
@@ -14,7 +14,6 @@ import { QuestionTool } from "../tool/plugin/question.js"
import { Tool } from "../tool.js"
import { SessionContext } from "./context.js"
import { SessionModelHeaders } from "./model-headers.js"
import { SessionModelHook } from "./model-hook.js"
import { SessionModelHttp } from "./model-http.js"
import { SessionModelTransport } from "./model-transport.js"
import { SessionPromptCacheKey } from "./prompt-cache-key.js"
@@ -227,25 +226,20 @@ export const layer = Layer.effect(
return [[name, { ...tool, description: definition.description, inputSchema: definition.input }] as const]
}),
)
const request = yield* SessionModelHook.apply(
hooks,
{ sessionID: session.id, agent: agent.id, model: resolved.ref },
LLM.request({
model,
http: {
headers: SessionModelHeaders.make(session, app),
},
// TODO: Persist cache lineage so nested forks reuse the root session's cache key.
promptCacheKey: SessionPromptCacheKey.make(session.fork?.sessionID ?? session.id),
system: context.system,
messages: boundImages(unsupportedParts(context.messages, resolved.capabilities)),
tools: Array.from(hooked, ([name, tool]) => ({ ...tool, name })),
toolChoice: stepLimitReached ? "none" : undefined,
}),
)
const request = LLM.request({
model,
http: {
headers: SessionModelHeaders.make(session, app),
},
// TODO: Persist cache lineage so nested forks reuse the root session's cache key.
promptCacheKey: SessionPromptCacheKey.make(session.fork?.sessionID ?? session.id),
system: context.system,
messages: boundImages(unsupportedParts(context.messages, resolved.capabilities)),
tools: Array.from(hooked, ([name, tool]) => ({ ...tool, name })),
toolChoice: stepLimitReached ? "none" : undefined,
})
const webSocketEligible =
!(yield* hooks.has("session", "http.request", resolved.ref.providerID)) &&
!(yield* hooks.has("session", "http.response", resolved.ref.providerID))
!(yield* hooks.has("session", "http.request")) && !(yield* hooks.has("session", "http.response"))
const http = webSocketEligible
? undefined
: SessionModelHttp.middleware(hooks, {
@@ -258,7 +252,7 @@ export const layer = Layer.effect(
...(webSocket &&
webSocketEligible &&
resolved.ref.providerID === Provider.ID.openai &&
request.model.route.id === "openai-responses"
model.route.id === "openai-responses"
? { webSocket: transport.bind(session.id) }
: {}),
}
+15 -18
View File
@@ -14,7 +14,6 @@ import { PluginHooks } from "../plugin/hooks.js"
import { SessionEvent } from "./event.js"
import { SessionHistory } from "./history.js"
import { SessionModelHeaders } from "./model-headers.js"
import { SessionModelHook } from "./model-hook.js"
import { SessionModelHttp } from "./model-http.js"
import { SessionRunnerModel } from "./runner/model.js"
import { SessionSchema } from "./schema.js"
@@ -81,25 +80,23 @@ const make = (dependencies: Dependencies) => {
})
: Effect.void,
)
const request = yield* SessionModelHook.apply(
dependencies.hooks,
{ sessionID: session.id, agent: agent.id, model: resolved.ref },
LLM.request({
model: resolved.model,
http: { headers: SessionModelHeaders.make(session, dependencies.app) },
system: agent.system,
messages: [Message.user(firstUser.text)],
tools: [],
}),
)
const streamed = yield* dependencies.llm
.stream(request, {
http: SessionModelHttp.middleware(dependencies.hooks, {
sessionID: session.id,
agent: agent.id,
model: resolved.ref,
.stream(
LLM.request({
model: resolved.model,
http: { headers: SessionModelHeaders.make(session, dependencies.app) },
system: agent.system,
messages: [Message.user(firstUser.text)],
tools: [],
}),
})
{
http: SessionModelHttp.middleware(dependencies.hooks, {
sessionID: session.id,
agent: agent.id,
model: resolved.ref,
}),
},
)
.pipe(
Stream.runForEach((event) => {
if (LLMEvent.is.providerError(event)) failed = true
-86
View File
@@ -1,86 +0,0 @@
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"))
})
+118 -193
View File
@@ -1,30 +1,41 @@
import fs from "fs/promises"
import path from "path"
import { describe, expect } from "bun:test"
import { Deferred, Effect, Fiber } from "effect"
import { Effect, Layer, Schema } from "effect"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { Bus } from "@opencode-ai/core/bus"
import { Database } from "@opencode-ai/core/database/database"
import { LocationServiceMap } from "@opencode-ai/core/location-services"
import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor"
import { SdkPlugins } from "@opencode-ai/core/plugin/sdk"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Info } from "@opencode-ai/schema/config"
import { Global } from "@opencode-ai/util/global"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Npm } from "@opencode-ai/util/npm"
import { Document, Info } from "@opencode-ai/schema/config"
import { Config } from "../src/config"
import { Formatter } from "../src/formatter"
import { Location } from "../src/location"
import { tempGlobalLayer } from "./fixture/global"
import { location } from "./fixture/location"
import { tmpdir } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect"
const it = testEffect(
AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SdkPlugins.node, LocationServiceMap.node]), [
[Global.node, tempGlobalLayer],
]),
)
const it = testEffect(Layer.empty)
type ConfigInput = typeof Info.Encoded
function formatterLayer(directory: string, configured?: ConfigInput["formatter"]) {
const entries =
configured === undefined
? []
: [
new Document({
type: "document",
info: Schema.decodeUnknownSync(Info)({ formatter: configured }),
}),
]
return AppNodeBuilder.build(Formatter.node, [
[Config.node, Config.testLayer(entries)],
[
Location.node,
Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(directory) }))),
],
[Npm.node, Layer.mock(Npm.Service, { which: () => Effect.succeed(undefined) })],
])
}
function withTemp<A, E, R>(body: (directory: string) => Effect.Effect<A, E, R>) {
return Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
@@ -33,208 +44,122 @@ function withTemp<A, E, R>(body: (directory: string) => Effect.Effect<A, E, R>)
)
}
function withFormatter<A, E, R>(
configured: ConfigInput["formatter"],
body: (formatter: Formatter.Interface, directory: string) => Effect.Effect<A, E, R>,
) {
return withTemp((directory) =>
Effect.promise(() =>
fs.writeFile(path.join(directory, "opencode.json"), JSON.stringify({ formatter: configured })),
).pipe(
Effect.andThen(
Effect.gen(function* () {
const plugins = yield* PluginSupervisor.Service
yield* plugins.flush
return yield* body(yield* Formatter.Service, directory)
}).pipe(
Effect.scoped,
Effect.provide(
LocationServiceMap.Service.get(Location.Ref.make({ directory: AbsolutePath.make(directory) })),
),
describe("Formatter", () => {
it.live("does not run formatters marked as disabled in config", () =>
withTemp((directory) =>
Effect.gen(function* () {
const file = path.join(directory, "test.disabled")
expect(yield* Formatter.Service.use((formatter) => formatter.file(file))).toBe(false)
}).pipe(
Effect.provide(
formatterLayer(directory, {
disabled: {
disabled: true,
command: [process.execPath, "-e", "process.exit(0)", "$FILE"],
extensions: [".disabled"],
},
}),
),
),
),
)
}
describe("Formatter", () => {
it.live("does not run formatters marked as disabled in config", () =>
withFormatter(
{
disabled: {
disabled: true,
command: [process.execPath, "-e", "process.exit(0)", "$FILE"],
extensions: [".disabled"],
},
},
(formatter, directory) =>
Effect.gen(function* () {
const file = path.join(directory, "test.disabled")
expect(yield* formatter.file(file)).toBe(false)
}),
),
)
it.live("file() returns false when no formatter runs", () =>
withFormatter(false, (formatter, directory) =>
withTemp((directory) =>
Effect.gen(function* () {
const file = path.join(directory, "test.txt")
yield* Effect.promise(() => fs.writeFile(file, "x"))
expect(yield* formatter.file(file)).toBe(false)
}),
expect(yield* Formatter.Service.use((formatter) => formatter.file(file))).toBe(false)
}).pipe(Effect.provide(formatterLayer(directory, false))),
),
)
it.live("loads formatter state per directory", () =>
withFormatter(false, (disabledFormatter, off) =>
withFormatter(
{
isolated: {
command: [process.execPath, "-e", "process.exit(0)", "$FILE"],
extensions: [".isolated"],
},
},
(enabledFormatter, on) =>
Effect.gen(function* () {
const offFile = path.join(off, "test.isolated")
const onFile = path.join(on, "test.isolated")
const disabled = yield* disabledFormatter.file(offFile)
const enabled = yield* enabledFormatter.file(onFile)
expect(disabled).toBe(false)
expect(enabled).toBe(true)
}),
withTemp((off) =>
withTemp((on) =>
Effect.gen(function* () {
const offFile = path.join(off, "test.isolated")
const onFile = path.join(on, "test.isolated")
const disabled = yield* Formatter.Service.use((formatter) => formatter.file(offFile)).pipe(
Effect.provide(formatterLayer(off, false)),
)
const enabled = yield* Formatter.Service.use((formatter) => formatter.file(onFile)).pipe(
Effect.provide(
formatterLayer(on, {
isolated: {
command: [process.execPath, "-e", "process.exit(0)", "$FILE"],
extensions: [".isolated"],
},
}),
),
)
expect(disabled).toBe(false)
expect(enabled).toBe(true)
}),
),
),
)
it.live("stops after the first matching formatter succeeds", () =>
withFormatter(
{
first: {
command: [
process.execPath,
"-e",
"const fs = require('fs'); const file = process.argv.at(-1); fs.appendFileSync(file, 'A')",
"$FILE",
],
extensions: [".seq"],
},
second: {
command: [
process.execPath,
"-e",
"const fs = require('fs'); const file = process.argv.at(-1); fs.appendFileSync(file, 'B')",
"$FILE",
],
extensions: [".seq"],
},
},
(formatter, directory) =>
Effect.gen(function* () {
const file = path.join(directory, "test.seq")
yield* Effect.promise(() => fs.writeFile(file, "x"))
expect(yield* formatter.file(file)).toBe(true)
expect(yield* Effect.promise(() => fs.readFile(file, "utf8"))).toBe("xA")
}),
withTemp((directory) =>
Effect.gen(function* () {
const file = path.join(directory, "test.seq")
yield* Effect.promise(() => fs.writeFile(file, "x"))
expect(yield* Formatter.Service.use((formatter) => formatter.file(file))).toBe(true)
expect(yield* Effect.promise(() => fs.readFile(file, "utf8"))).toBe("xA")
}).pipe(
Effect.provide(
formatterLayer(directory, {
first: {
command: [
process.execPath,
"-e",
"const fs = require('fs'); const file = process.argv.at(-1); fs.appendFileSync(file, 'A')",
"$FILE",
],
extensions: [".seq"],
},
second: {
command: [
process.execPath,
"-e",
"const fs = require('fs'); const file = process.argv.at(-1); fs.appendFileSync(file, 'B')",
"$FILE",
],
extensions: [".seq"],
},
}),
),
),
),
)
it.live("tries the next matching formatter when the first fails", () =>
withFormatter(
{
first: {
command: [process.execPath, "-e", "process.exit(1)", "$FILE"],
extensions: [".fallback"],
},
second: {
command: [
process.execPath,
"-e",
"const fs = require('fs'); const file = process.argv.at(-1); fs.appendFileSync(file, 'B')",
"$FILE",
],
extensions: [".fallback"],
},
},
(formatter, directory) =>
Effect.gen(function* () {
const file = path.join(directory, "test.fallback")
yield* Effect.promise(() => fs.writeFile(file, "x"))
expect(yield* formatter.file(file)).toBe(true)
expect(yield* Effect.promise(() => fs.readFile(file, "utf8"))).toBe("xB")
}),
),
)
it.live("rebuilds formatter state and clears resolved commands", () =>
withFormatter(false, (formatter, directory) =>
withTemp((directory) =>
Effect.gen(function* () {
const command = { suffix: "A" }
yield* formatter.transform((draft) => {
const suffix = command.suffix
draft.set({
name: "reload",
extensions: [".reload"],
enabled: Effect.succeed([
process.execPath,
"-e",
`const fs = require('fs'); const file = process.argv.at(-1); fs.appendFileSync(file, '${suffix}')`,
"$FILE",
]),
})
})
const file = path.join(directory, "test.reload")
const file = path.join(directory, "test.fallback")
yield* Effect.promise(() => fs.writeFile(file, "x"))
expect(yield* formatter.file(file)).toBe(true)
command.suffix = "B"
yield* formatter.reload()
expect(yield* formatter.file(file)).toBe(true)
expect(yield* Effect.promise(() => fs.readFile(file, "utf8"))).toBe("xAB")
}),
),
)
it.live("does not cache a command resolved before reload", () =>
withFormatter(false, (formatter, directory) =>
Effect.gen(function* () {
const resolving = yield* Deferred.make<void>()
const release = yield* Deferred.make<void>()
const command = { suffix: "A" }
yield* formatter.transform((draft) => {
const suffix = command.suffix
const resolved = [
process.execPath,
"-e",
`const fs = require('fs'); const file = process.argv.at(-1); fs.appendFileSync(file, '${suffix}')`,
"$FILE",
]
draft.set({
name: "reload-race",
extensions: [".race"],
enabled:
suffix === "A"
? Deferred.succeed(resolving, undefined).pipe(
Effect.andThen(Deferred.await(release)),
Effect.as(resolved),
)
: Effect.succeed(resolved),
})
})
const file = path.join(directory, "test.race")
yield* Effect.promise(() => fs.writeFile(file, "x"))
const first = yield* formatter.file(file).pipe(Effect.forkChild({ startImmediately: true }))
yield* Deferred.await(resolving)
command.suffix = "B"
yield* formatter.reload()
yield* Deferred.succeed(release, undefined)
expect(yield* Fiber.join(first)).toBe(true)
expect(yield* formatter.file(file)).toBe(true)
expect(yield* Effect.promise(() => fs.readFile(file, "utf8"))).toBe("xAB")
}),
expect(yield* Formatter.Service.use((formatter) => formatter.file(file))).toBe(true)
expect(yield* Effect.promise(() => fs.readFile(file, "utf8"))).toBe("xB")
}).pipe(
Effect.provide(
formatterLayer(directory, {
first: {
command: [process.execPath, "-e", "process.exit(1)", "$FILE"],
extensions: [".fallback"],
},
second: {
command: [
process.execPath,
"-e",
"const fs = require('fs'); const file = process.argv.at(-1); fs.appendFileSync(file, 'B')",
"$FILE",
],
extensions: [".fallback"],
},
}),
),
),
),
)
})
+4 -16
View File
@@ -320,14 +320,10 @@ describe("fromPromise", () => {
define({
id: "promise-session-http",
setup: async (ctx) => {
await ctx.session.hook(
"http.request",
(event) => {
event.request = new Request("https://provider.test/changed", event.request)
event.request.headers.set("x-hook", "promise")
},
{ providerID: "test" },
)
await ctx.session.hook("http.request", (event) => {
event.request = new Request("https://provider.test/changed", event.request)
event.request.headers.set("x-hook", "promise")
})
await ctx.session.hook("http.response", async (event) => {
event.response = new Response(`${await event.response.text()}-response`, {
status: event.response.status,
@@ -346,11 +342,6 @@ describe("fromPromise", () => {
...context,
request: new Request("https://provider.test", { method: "POST", body: "payload" }),
})
const ignored = yield* hooks.trigger("session", "http.request", {
...context,
model: Model.Ref.make({ providerID: Provider.ID.make("other"), id: Model.ID.make("model") }),
request: new Request("https://other.test"),
})
const response = yield* hooks.trigger("session", "http.response", {
...context,
request: request.request,
@@ -358,9 +349,6 @@ describe("fromPromise", () => {
})
expect(request.request.url).toBe("https://provider.test/changed")
expect(ignored.request.url).toBe("https://other.test/")
expect(yield* hooks.has("session", "http.request", Provider.ID.make("test"))).toBe(true)
expect(yield* hooks.has("session", "http.request", Provider.ID.make("other"))).toBe(false)
expect(yield* Effect.promise(() => response.response.text())).toBe("promise-response")
}),
)
@@ -1,25 +1,17 @@
import { Money } from "@opencode-ai/schema/money"
import { Agent } from "@opencode-ai/schema/agent"
import { Session } from "@opencode-ai/schema/session"
import { OpenAIResponses } from "@opencode-ai/ai/protocols/openai-responses"
import { describe, expect } from "bun:test"
import { ConfigProvider, DateTime, Effect } from "effect"
import { Effect } from "effect"
import { Catalog } from "@opencode-ai/core/catalog"
import { Credential } from "@opencode-ai/core/credential"
import { Integration } from "@opencode-ai/core/integration"
import { Location } from "@opencode-ai/core/location"
import { Model } from "@opencode-ai/core/model"
import { Plugin } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host"
import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
import { GithubCopilotPlugin } from "@opencode-ai/core/plugin/provider/github-copilot"
import { OpenAIPlugin } from "@opencode-ai/core/plugin/provider/openai"
import { Project } from "@opencode-ai/core/project"
import { Provider } from "@opencode-ai/core/provider"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { SessionModelRequest } from "@opencode-ai/core/session/model-request"
import { SessionModelTransport } from "@opencode-ai/core/session/model-transport"
import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
import { testEffect } from "../lib/effect"
import { PluginTestLayer } from "./fixture"
@@ -32,33 +24,19 @@ const addPlugin = Effect.fn(function* () {
yield* OpenAIPlugin.effect(host).pipe(Effect.provideService(Integration.Service, integrations))
})
const addGithubCopilotPlugin = Effect.fn(function* () {
const plugin = yield* Plugin.Service
const host = yield* PluginHost.make(plugin)
yield* GithubCopilotPlugin.effect(host)
})
function required<T>(value: T | undefined): T {
if (value === undefined) throw new Error("Expected value")
return value
}
const request = Effect.fn(function* (providerID: Provider.ID, baseURL: string) {
const hooks = yield* PluginHooks.Service
const event = yield* hooks.trigger("session", "model.request", {
const http = Effect.fn(function* (providerID: Provider.ID, url: string) {
const event = yield* (yield* PluginHooks.Service).trigger("session", "http.request", {
sessionID: Session.ID.make("ses_test"),
agent: Agent.ID.make("build"),
model: Model.Ref.make({ providerID, id: Model.ID.make("gpt-5.5") }),
baseURL,
headers: {},
request: new Request(url, { method: "POST", body: "{}" }),
})
return {
baseURL: event.baseURL,
headers: event.headers,
hasHttpHooks:
(yield* hooks.has("session", "http.request", providerID)) ||
(yield* hooks.has("session", "http.response", providerID)),
}
return { url: event.request.url, headers: Object.fromEntries(event.request.headers.entries()) }
})
describe("OpenAIPlugin", () => {
@@ -132,19 +110,18 @@ describe("OpenAIPlugin", () => {
})
yield* addPlugin()
const direct = yield* request(Provider.ID.openai, "https://api.openai.com/v1")
const custom = yield* request(Provider.ID.make("custom-openai"), "https://custom.example/v1")
const proxy = yield* request(Provider.ID.openai, "https://proxy.example/v1?region=us")
const request = yield* http(Provider.ID.openai, "https://api.openai.com/v1/responses")
const custom = yield* http(Provider.ID.make("custom-openai"), "https://custom.example/v1/responses")
const proxy = yield* http(Provider.ID.openai, "https://proxy.example/v1/responses?region=us")
const provider = required(yield* catalog.provider.get(Provider.ID.openai))
expect(provider.package).toBe("@opencode-ai/ai/providers/openai")
expect(provider.settings).toMatchObject({ baseURL: "https://chatgpt.com/backend-api/codex" })
expect(provider.headers).toMatchObject({ originator: "opencode", "chatgpt-account-id": "acct_123" })
expect(direct.baseURL).toBe("https://chatgpt.com/backend-api/codex")
expect(direct.headers).toMatchObject({ originator: "opencode", "session-id": "ses_test" })
expect(direct.hasHttpHooks).toBe(false)
expect(request.url).toBe("https://chatgpt.com/backend-api/codex/responses")
expect(request.headers).toMatchObject({ originator: "opencode", "session-id": "ses_test" })
expect(custom.headers).not.toHaveProperty("originator")
expect(proxy.baseURL).toBe("https://proxy.example/v1?region=us")
expect(proxy.url).toBe("https://proxy.example/v1/responses?region=us")
expect(proxy.headers).toMatchObject({ originator: "opencode", "session-id": "ses_test" })
const eligible = required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.5")))
expect(eligible.package).toBe("@opencode-ai/ai/providers/openai")
@@ -190,77 +167,16 @@ describe("OpenAIPlugin", () => {
})
yield* addPlugin()
const direct = yield* request(Provider.ID.openai, "https://api.openai.com/v1")
const request = yield* http(Provider.ID.openai, "https://api.openai.com/v1/responses")
const provider = required(yield* catalog.provider.get(Provider.ID.openai))
const model = required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.5")))
expect(model.package).toBe("@opencode-ai/ai/providers/openai")
expect(model.enabled).toBe(true)
expect(model.limit).toEqual({ context: 1_050_000, input: 922_000, output: 128_000 })
expect(direct.headers).not.toHaveProperty("originator")
expect(direct.hasHttpHooks).toBe(false)
expect(request.headers).not.toHaveProperty("originator")
expect(provider.headers).not.toHaveProperty("originator")
expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-4.1"))).enabled).toBe(true)
}),
)
it.effect("selects WebSocket with the built-in provider hooks enabled", () =>
Effect.gen(function* () {
const credentials = yield* Credential.Service
yield* credentials.create({
integrationID: Integration.ID.make("openai"),
value: Credential.Key.make({ type: "key", key: "sk-test" }),
})
yield* addPlugin()
yield* addGithubCopilotPlugin()
const executor = { execute: () => Effect.die("unused WebSocket execution") }
const transport = SessionModelTransport.Service.of({
bind: () => executor,
close: () => Effect.void,
closeAll: Effect.void,
})
const sessionID = Session.ID.make("ses_websocket_hooks")
const agentID = Agent.ID.make("build")
const agent = Agent.Info.make(Agent.Info.default(agentID))
const model = SessionRunnerModel.resolved(OpenAIResponses.route.model({ id: "gpt-5.5" }), {
capabilities: { tools: true, input: ["text"], output: ["text"] },
cost: [],
})
const program = Effect.gen(function* () {
const requests = yield* SessionModelRequest.Service
return yield* requests.prepare({
context: {
session: Session.Info.make({
id: sessionID,
projectID: Project.ID.global,
cost: Money.USD.zero,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: DateTime.makeUnsafe(0), updated: DateTime.makeUnsafe(0) },
location: Location.Ref.make({ directory: AbsolutePath.make("/project") }),
}),
agent: { id: agentID, info: agent },
model,
initial: "",
messages: [],
tools: { definitions: [], execute: () => Effect.die("unused tool execution") },
},
step: 1,
})
}).pipe(
Effect.provide(SessionModelRequest.layer),
Effect.provideService(SessionModelTransport.Service, transport),
Effect.provide(
ConfigProvider.layer(
ConfigProvider.fromEnv({ env: { OPENCODE_EXPERIMENTAL_OPENAI_RESPONSES_WEBSOCKET: "true" } }),
),
),
)
const prepared = yield* program
expect(prepared.webSocketEligible).toBe(true)
expect(prepared.options.webSocket).toBe(executor)
expect(prepared.options.http).toBeUndefined()
}),
)
})
+3 -18
View File
@@ -73,7 +73,7 @@ const it = testEffect(
)
describe("Session.compact", () => {
it.effect("durably coalesces manual compaction", () =>
it.effect("durably stacks manual compaction", () =>
Effect.gen(function* () {
requests = []
const session = yield* Session.Service
@@ -102,10 +102,11 @@ describe("Session.compact", () => {
const first = yield* session.compact({ sessionID: created.id })
const second = yield* session.compact({ sessionID: created.id })
expect(second.id).toBe(first.id)
expect(second.id).not.toBe(first.id)
expect(requests).toHaveLength(0)
expect(yield* session.inbox(created.id)).toEqual([
expect.objectContaining({ id: first.id, type: "compaction", delivery: "queue" }),
expect.objectContaining({ id: second.id, type: "compaction", delivery: "queue" }),
])
expect((yield* session.context(created.id)).find((message) => message.id === first.id)).toBeUndefined()
@@ -114,20 +115,4 @@ describe("Session.compact", () => {
expect(steer).toMatchObject({ type: "compaction", delivery: "steer" })
}),
)
it.effect("coalesces concurrent manual compaction", () =>
Effect.gen(function* () {
const session = yield* Session.Service
const created = yield* session.create({ location })
const admitted = yield* Effect.all(
[SessionMessage.ID.create(), SessionMessage.ID.create()].map((id) =>
session.compact({ id, sessionID: created.id }),
),
{ concurrency: "unbounded" },
)
expect(admitted[1]?.id).toBe(admitted[0]?.id)
expect(yield* session.inbox(created.id)).toHaveLength(1)
}),
)
})
@@ -291,25 +291,18 @@ it.effect(
instruction = "Changed context"
const before = yield* durableState(db, sessionID)
const hooks = yield* PluginHooks.Service
let modelRequestHook = false
yield* hooks.register("session", "context", (event) =>
Effect.sync(() => {
event.system = [SystemPart.make("Hooked system"), ...event.system]
if (event.tools.lookup) event.tools.lookup.description = "Hooked lookup"
}),
)
yield* hooks.register("session", "model.request", () =>
Effect.sync(() => {
modelRequestHook = true
}),
)
const generate = yield* SessionGenerate.Service
const result = yield* generate.generate({ sessionID, prompt: "Summarize privately" })
expect(result).toBe("Transient answer")
expect(requests).toHaveLength(1)
expect(modelRequestHook).toBe(true)
expect(hasHttpMiddleware).toBe(true)
expect(requests[0]?.model).toBe(model)
expect(requests[0]?.system[0]?.text).toBe("Hooked system")
+5 -19
View File
@@ -27,7 +27,6 @@ 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[] = []
@@ -60,25 +59,12 @@ const locations = Layer.effect(
LocationServiceMap.Service,
LayerMap.make(
() =>
// Attachment admission only needs image normalization and plugin readiness.
// Attachment admission only needs the location-scoped Image service.
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion
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>,
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>,
),
)
const it = testEffect(
-30
View File
@@ -1020,36 +1020,6 @@ describe("SessionRunnerLLM", () => {
}),
)
it.effect("keeps WebSocket eligibility after model request hooks", () =>
Effect.gen(function* () {
yield* setup
const hooks = yield* PluginHooks.Service
yield* hooks.register("session", "model.request", (event) =>
Effect.sync(() => {
event.headers["x-model-request-hook"] = "active"
}),
)
yield* hooks.register("session", "http.request", () => Effect.die("Other-provider HTTP hook should not apply"), {
providerID: Provider.ID.githubCopilot,
})
const context = yield* SessionContext.Service
const modelRequests = yield* SessionModelRequest.Service
const selected = yield* context.select(sessionID)
const database = yield* Database.Service
const bus = yield* Bus.Service
yield* InstructionState.prepare(database.db, bus, selected.instructions, sessionID)
const prepared = yield* modelRequests.prepare({
context: yield* context.load(selected),
step: 1,
})
expect(prepared.request.http?.headers?.["x-model-request-hook"]).toBe("active")
expect(prepared.webSocketEligible).toBe(true)
expect(prepared.options.http).toBeUndefined()
}),
)
it.effect("forces HTTP and triggers active request and response hooks once", () =>
Effect.gen(function* () {
yield* setup
+40 -13
View File
@@ -1,7 +1,9 @@
import { beforeEach, describe, expect } from "bun:test"
import path from "path"
import { Effect, Exit, Layer } from "effect"
import { Effect, Exit, Layer, Stream } 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"
@@ -88,7 +90,7 @@ const permission = permissionLayer({
),
})
const config = Config.testLayer()
const imageLayer = AppNodeBuilder.build(Image.node)
const imageLayer = AppNodeBuilder.build(Image.node, [[Config.node, config]])
const testFileSystem = Layer.effect(
FSUtil.Service,
FSUtil.Service.use((fs) =>
@@ -128,9 +130,10 @@ const mutation = Layer.succeed(
},
}),
)
const unavailableImage = Layer.mock(Image.Service, {
normalize: () => Effect.fail(new Image.ResizerUnavailableError()),
})
const unavailableImage = Layer.succeed(
Image.Service,
Image.Service.of({ normalize: () => Effect.fail(new Image.ResizerUnavailableError()) }),
)
const readLayer = (imageLayer: Layer.Layer<Image.Service>) =>
Layer.mergeAll(
AppNodeBuilder.build(LayerNode.group([Tool.node, readToolNode]), [
@@ -143,9 +146,8 @@ const readLayer = (imageLayer: Layer.Layer<Image.Service>) =>
[Location.node, locationLayer],
[Global.node, Global.layerWith({ data: Global.Path.data })],
]),
// Merge by reference so Config.Test and Image.Service resolve to the memoized instances.
// Merge by reference so Config.Test resolves to the memoized instance.
config,
imageLayer,
)
const it = testEffect(readLayer(imageLayer))
const itWithoutResizer = testEffect(readLayer(unavailableImage))
@@ -382,8 +384,17 @@ describe("ReadTool", () => {
encoding: "base64",
mime: "image/png",
}
const image = yield* Image.Service
yield* image.transform((draft) => draft.configure({ autoResize: false, maxWidth: 4 }))
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 registry = yield* Tool.Service
expect(
@@ -416,8 +427,15 @@ describe("ReadTool", () => {
encoding: "base64",
mime: "image/png",
}
const image = yield* Image.Service
yield* image.transform((draft) => draft.configure({ maxWidth: 4 }))
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 registry = yield* Tool.Service
const result = yield* executeTool(registry, {
sessionID,
@@ -448,8 +466,17 @@ describe("ReadTool", () => {
encoding: "base64",
mime: "image/png",
}
const image = yield* Image.Service
yield* image.transform((draft) => draft.configure({ maxBase64Bytes: 1 }))
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 registry = yield* Tool.Service
expect(
+2 -2
View File
@@ -1,6 +1,6 @@
import type { LanguageModelV3 } from "@ai-sdk/provider"
import type { Model } from "@opencode-ai/schema/model"
import type { ModelHooks } from "./registration.js"
import type { Hooks } from "./registration.js"
export interface AISDKHooks {
sdk: {
@@ -18,5 +18,5 @@ export interface AISDKHooks {
}
export interface AISDKDomain {
readonly hook: ModelHooks<AISDKHooks>
readonly hook: Hooks<AISDKHooks>
}
+11 -14
View File
@@ -1,19 +1,16 @@
import type { CommandApi } from "@opencode-ai/client/effect/api"
import type { Session } from "@opencode-ai/schema/session"
import type { Effect, Scope } from "effect"
import type { Registration } from "./registration.js"
import type { CommandInfo } from "@opencode-ai/client"
import type { Effect } from "effect"
import type { Transform } from "./registration.js"
export interface CommandInvocation {
readonly sessionID: Session.ID
readonly arguments: string
export interface CommandDraft {
list(): readonly CommandInfo[]
get(name: string): CommandInfo | undefined
update(name: string, update: (command: CommandInfo) => void): void
remove(name: string): void
}
export interface CommandDefinition {
readonly name: string
readonly description?: string
readonly run: (input: CommandInvocation) => Effect.Effect<void>
}
export interface CommandDomain extends Pick<CommandApi<unknown>, "list"> {
readonly register: (definition: CommandDefinition) => Effect.Effect<Registration, never, Scope.Scope>
export interface CommandDomain extends CommandApi<unknown> {
readonly transform: Transform<CommandDraft>
readonly reload: () => Effect.Effect<void>
}
@@ -4,11 +4,6 @@ export interface Registration {
readonly dispose: Effect.Effect<void>
}
export interface ModelHookOptions {
/** Limits the hook to one provider. Unscoped hooks apply to every provider. */
readonly providerID?: string
}
export type Hooks<Spec, Failures extends Record<keyof Spec, unknown> = Record<keyof Spec, never>> = <
Name extends keyof Spec,
>(
@@ -16,12 +11,4 @@ export type Hooks<Spec, Failures extends Record<keyof Spec, unknown> = Record<ke
callback: (input: Spec[Name]) => Effect.Effect<void, Failures[Name]>,
) => Effect.Effect<Registration, never, Scope.Scope>
export type ModelHooks<Spec, Failures extends Record<keyof Spec, unknown> = Record<keyof Spec, never>> = <
Name extends keyof Spec,
>(
name: Name,
callback: (input: Spec[Name]) => Effect.Effect<void, Failures[Name]>,
options?: ModelHookOptions,
) => Effect.Effect<Registration, never, Scope.Scope>
export type Transform<Input> = (callback: (input: Input) => void) => Effect.Effect<Registration, never, Scope.Scope>
+2 -11
View File
@@ -4,7 +4,7 @@ import type { Agent } from "@opencode-ai/schema/agent"
import type { Model } from "@opencode-ai/schema/model"
import type { Session } from "@opencode-ai/schema/session"
import type { JsonSchema } from "effect"
import type { ModelHooks } from "./registration.js"
import type { Hooks } from "./registration.js"
export interface SessionContext {
readonly sessionID: Session.ID
@@ -15,14 +15,6 @@ export interface SessionContext {
tools: Record<string, { description: string; input: JsonSchema.JsonSchema }>
}
export interface SessionModelRequest {
readonly sessionID: Session.ID
readonly agent: Agent.ID
readonly model: Model.Ref
baseURL?: string
headers: Record<string, string>
}
export interface SessionHttpRequest {
readonly sessionID: Session.ID
readonly agent: Agent.ID
@@ -40,7 +32,6 @@ export interface SessionHttpResponse {
export interface SessionHooks {
readonly context: SessionContext
readonly "model.request": SessionModelRequest
readonly "http.request": SessionHttpRequest
readonly "http.response": SessionHttpResponse
}
@@ -49,5 +40,5 @@ export type SessionDomain = Pick<
SessionApi<unknown>,
"create" | "get" | "prompt" | "generate" | "command" | "synthetic" | "interrupt" | "rename" | "wait"
> & {
readonly hook: ModelHooks<SessionHooks>
readonly hook: Hooks<SessionHooks>
}
+4 -8
View File
@@ -130,10 +130,8 @@ export function fromPromise(plugin: Plugin) {
reload: () => run(host.agent.reload()),
},
aisdk: {
hook: (name, callback, options) =>
register(
host.aisdk.hook(name, (event) => Effect.promise(() => Promise.resolve(callback(event))), options),
),
hook: (name, callback) =>
register(host.aisdk.hook(name, (event) => Effect.promise(() => Promise.resolve(callback(event))))),
},
catalog: {
provider: {
@@ -297,10 +295,8 @@ export function fromPromise(plugin: Plugin) {
),
},
session: {
hook: (name, callback, options) =>
register(
host.session.hook(name, (event) => Effect.promise(() => Promise.resolve(callback(event))), options),
),
hook: (name, callback) =>
register(host.session.hook(name, (event) => Effect.promise(() => Promise.resolve(callback(event))))),
create: adaptApiMethod(SessionEndpoints["session.create"], host.session.create),
get: adaptApiMethod(SessionEndpoints["session.get"], host.session.get),
prompt: adaptApiMethod(SessionEndpoints["session.prompt"], host.session.prompt),
+2 -2
View File
@@ -1,6 +1,6 @@
import type { LanguageModelV3 } from "@ai-sdk/provider"
import type { Model } from "@opencode-ai/schema/model"
import type { ModelHooks } from "./registration.js"
import type { Hooks } from "./registration.js"
export interface AISDKHooks {
sdk: {
@@ -18,5 +18,5 @@ export interface AISDKHooks {
}
export interface AISDKDomain {
readonly hook: ModelHooks<AISDKHooks>
readonly hook: Hooks<AISDKHooks>
}
+10 -13
View File
@@ -1,18 +1,15 @@
import type { CommandApi } from "@opencode-ai/client/promise/api"
import type { Session } from "@opencode-ai/schema/session"
import type { Registration } from "./registration.js"
import type { CommandInfo } from "@opencode-ai/client"
import type { Transform } from "./registration.js"
export interface CommandInvocation {
readonly sessionID: Session.ID
readonly arguments: string
export interface CommandDraft {
list(): readonly CommandInfo[]
get(name: string): CommandInfo | undefined
update(name: string, update: (command: CommandInfo) => void): void
remove(name: string): void
}
export interface CommandDefinition {
readonly name: string
readonly description?: string
readonly run: (input: CommandInvocation) => Promise<void>
}
export interface CommandDomain extends Pick<CommandApi, "list"> {
readonly register: (definition: CommandDefinition) => Promise<Registration>
export interface CommandDomain extends CommandApi {
readonly transform: Transform<CommandDraft>
readonly reload: () => Promise<void>
}
@@ -2,20 +2,9 @@ export interface Registration {
readonly dispose: () => Promise<void>
}
export interface ModelHookOptions {
/** Limits the hook to one provider. Unscoped hooks apply to every provider. */
readonly providerID?: string
}
export type Hooks<Spec> = <Name extends keyof Spec>(
name: Name,
callback: (input: Spec[Name]) => Promise<void> | void,
) => Promise<Registration>
export type ModelHooks<Spec> = <Name extends keyof Spec>(
name: Name,
callback: (input: Spec[Name]) => Promise<void> | void,
options?: ModelHookOptions,
) => Promise<Registration>
export type Transform<Input> = (callback: (input: Input) => void) => Promise<Registration>
+2 -11
View File
@@ -4,7 +4,7 @@ import type { Agent } from "@opencode-ai/schema/agent"
import type { Model } from "@opencode-ai/schema/model"
import type { Session } from "@opencode-ai/schema/session"
import type { JsonSchema } from "effect"
import type { ModelHooks } from "./registration.js"
import type { Hooks } from "./registration.js"
export interface SessionContext {
readonly sessionID: Session.ID
@@ -15,14 +15,6 @@ export interface SessionContext {
tools: Record<string, { description: string; input: JsonSchema.JsonSchema }>
}
export interface SessionModelRequest {
readonly sessionID: Session.ID
readonly agent: Agent.ID
readonly model: Model.Ref
baseURL?: string
headers: Record<string, string>
}
export interface SessionHttpRequest {
readonly sessionID: Session.ID
readonly agent: Agent.ID
@@ -40,7 +32,6 @@ export interface SessionHttpResponse {
export interface SessionHooks {
readonly context: SessionContext
readonly "model.request": SessionModelRequest
readonly "http.request": SessionHttpRequest
readonly "http.response": SessionHttpResponse
}
@@ -49,5 +40,5 @@ export type SessionDomain = Pick<
SessionApi,
"create" | "get" | "prompt" | "generate" | "command" | "synthetic" | "interrupt" | "rename" | "wait"
> & {
readonly hook: ModelHooks<SessionHooks>
readonly hook: Hooks<SessionHooks>
}
@@ -1,7 +1,6 @@
import { createMemo, For, Show, createEffect, onMount, onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
import { TextAttributes, ScrollBoxRenderable } from "@opentui/core"
import type { SessionInfo } from "@opencode-ai/client"
import { useRoute, useRouteData } from "../../../context/route"
import { useData } from "../../../context/data"
import { useClient } from "../../../context/client"
@@ -10,7 +9,6 @@ import { Locale } from "../../../util/locale"
import { Keymap } from "../../../context/keymap"
import { useComposerTab } from "./index"
import { withTimestampedFallback } from "@opencode-ai/util/session-title-fallback"
import { sessionFamily } from "../../../util/session"
interface SubagentEntry {
sessionID: string
@@ -18,7 +16,6 @@ interface SubagentEntry {
title: string
status: string
current: boolean
prefix: string
}
export function SubagentsTab(props: { sessionID: string }) {
@@ -37,24 +34,47 @@ export function SubagentsTab(props: { sessionID: string }) {
const current = session()
if (!current) return []
const result = sessionFamily<SessionInfo>(data.session.list(), current.id).map(
({ session, prefix }): SubagentEntry => {
const title = withTimestampedFallback(session)
const result: SubagentEntry[] = []
if (current.parentID) {
const siblings = data.session.list().filter((s) => s.parentID === current.parentID)
for (const sibling of siblings) {
const title = withTimestampedFallback(sibling)
const agentMatch = title.match(/@(\w+) subagent/)
return {
sessionID: session.id,
agent: session.agent
? Locale.titlecase(session.agent)
: agentMatch
? Locale.titlecase(agentMatch[1])
: "Subagent",
title: agentMatch ? title.replace(agentMatch[0], "").trim() || title : title,
status: data.session.status(session.id),
current: session.id === route.sessionID,
prefix,
}
},
)
const agent = sibling.agent
? Locale.titlecase(sibling.agent)
: agentMatch
? Locale.titlecase(agentMatch[1])
: "Subagent"
const name = agentMatch ? title.replace(agentMatch[0], "").trim() || title : title
result.push({
sessionID: sibling.id,
agent,
title: name,
status: data.session.status(sibling.id),
current: sibling.id === route.sessionID,
})
}
} else {
const children = data.session.list().filter((s) => s.parentID === props.sessionID)
for (const child of children) {
const title = withTimestampedFallback(child)
const agentMatch = title.match(/@(\w+) subagent/)
const agent = child.agent
? Locale.titlecase(child.agent)
: agentMatch
? Locale.titlecase(agentMatch[1])
: "Subagent"
const name = agentMatch ? title.replace(agentMatch[0], "").trim() || title : title
result.push({
sessionID: child.id,
agent,
title: name,
status: data.session.status(child.id),
current: child.id === route.sessionID,
})
}
}
return result.filter((entry) => (store.active ? entry.status === "running" : entry.status !== "running"))
})
@@ -244,7 +264,6 @@ export function SubagentsTab(props: { sessionID: string }) {
attributes={active() ? TextAttributes.BOLD : undefined}
wrapMode="none"
>
{entry.prefix}
{entry.agent}: {entry.title}
</text>
</box>
-41
View File
@@ -1,47 +1,6 @@
import type { ModelInfo, SessionMessageAssistant, SessionMessageInfo } from "@opencode-ai/client"
import { Locale } from "./locale"
type SessionNode = {
id: string
parentID?: string | null
}
export function sessionFamily<T extends SessionNode>(sessions: readonly T[], sessionID: string) {
const byID = new Map(sessions.map((session) => [session.id, session]))
const current = byID.get(sessionID)
if (!current) return []
const children = new Map<string, T[]>()
sessions.forEach((session) => {
if (!session.parentID) return
const group = children.get(session.parentID)
if (group) group.push(session)
else children.set(session.parentID, [session])
})
function root(session: T): T {
const parent = session.parentID ? byID.get(session.parentID) : undefined
return parent ? root(parent) : session
}
function walk(parentID: string, ancestors: boolean[]): Array<{ session: T; prefix: string }> {
const group = children.get(parentID) ?? []
return group.flatMap((session, index) => {
const last = index === group.length - 1
const prefix =
ancestors.length === 0
? ""
: ancestors
.slice(1)
.map((ancestor) => (ancestor ? " " : "│ "))
.join("") + (last ? "└─ " : "├─ ")
return [{ session, prefix }, ...walk(session.id, [...ancestors, last])]
})
}
return walk(root(current).id, [])
}
export function lastAssistantWithUsage(messages: ReadonlyArray<SessionMessageInfo>, boundary?: string) {
const boundaryIndex = boundary ? messages.findIndex((message) => message.id === boundary) : -1
if (boundary && boundaryIndex === -1) return undefined
+1 -22
View File
@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test"
import type { SessionMessageInfo } from "@opencode-ai/client"
import { lastAssistantWithUsage, sessionFamily } from "../../src/util/session"
import { lastAssistantWithUsage } from "../../src/util/session"
const assistant = (id: string, input: number): SessionMessageInfo => ({
id,
@@ -13,27 +13,6 @@ const assistant = (id: string, input: number): SessionMessageInfo => ({
})
describe("util.session", () => {
test("flattens nested subagents from any session in the family", () => {
const sessions = [
{ id: "root" },
{ id: "child-a", parentID: "root" },
{ id: "grandchild-a", parentID: "child-a" },
{ id: "great-grandchild-a", parentID: "grandchild-a" },
{ id: "grandchild-a2", parentID: "child-a" },
{ id: "child-b", parentID: "root" },
{ id: "grandchild-b", parentID: "child-b" },
]
expect(sessionFamily(sessions, "great-grandchild-a")).toEqual([
{ session: sessions[1], prefix: "" },
{ session: sessions[2], prefix: "├─ " },
{ session: sessions[3], prefix: "│ └─ " },
{ session: sessions[4], prefix: "└─ " },
{ session: sessions[5], prefix: "" },
{ session: sessions[6], prefix: "└─ " },
])
})
test("tracks usage across undo and redo boundaries", () => {
const messages = [assistant("msg_z", 10), assistant("msg_a", 30)]
+1 -1
View File
@@ -9,7 +9,7 @@ export type ProviderIconProps = JSX.SVGElementTags["svg"] & {
export const ProviderIcon: Component<ProviderIconProps> = (props) => {
const [local, rest] = splitProps(props, ["id", "class", "classList"])
const resolved = createMemo(() => (iconNames.includes(local.id as IconName) ? local.id : "session.synthetic"))
const resolved = createMemo(() => (iconNames.includes(local.id as IconName) ? local.id : "synthetic"))
return (
<svg
data-component="provider-icon"
@@ -252,6 +252,7 @@
--color-v2-text-text-code-accent: var(--v2-text-text-code-accent);
--color-v2-icon-icon-base: var(--v2-icon-icon-base);
--color-v2-icon-icon-muted: var(--v2-icon-icon-muted);
--color-v2-icon-icon-faint: var(--v2-icon-icon-faint);
--color-v2-icon-icon-inverse: var(--v2-icon-icon-inverse);
--color-v2-icon-icon-contrast: var(--v2-icon-icon-contrast);
--color-v2-icon-icon-accent: var(--v2-icon-icon-accent);
+2
View File
@@ -176,6 +176,7 @@
"v2-text-text-code-accent": "var(--v2-blue-900)",
"v2-icon-icon-base": "var(--v2-grey-800)",
"v2-icon-icon-muted": "var(--v2-grey-600)",
"v2-icon-icon-faint": "var(--v2-grey-500)",
"v2-icon-icon-inverse": "var(--v2-grey-50)",
"v2-icon-icon-contrast": "var(--v2-grey-100)",
"v2-icon-icon-accent": "var(--v2-blue-600)",
@@ -411,6 +412,7 @@
"v2-text-text-code-accent": "var(--v2-blue-400)",
"v2-icon-icon-base": "var(--v2-grey-400)",
"v2-icon-icon-muted": "var(--v2-grey-600)",
"v2-icon-icon-faint": "var(--v2-grey-700)",
"v2-icon-icon-inverse": "var(--v2-grey-1100)",
"v2-icon-icon-contrast": "var(--v2-grey-100)",
"v2-icon-icon-accent": "var(--v2-blue-400)",
+1
View File
@@ -52,6 +52,7 @@ export function mapV2Foreground(
"v2-text-text-faint": shift(body, { l: isDark ? -0.2 : 0.21, c: isDark ? 0.78 : 0.72 }),
"v2-icon-icon-base": greyRef(pickGrey(primitives, bgBase, 7, isDark ? 400 : 800)),
"v2-icon-icon-muted": greyRef(pickGrey(primitives, bgBase, 3, 600)),
"v2-icon-icon-faint": greyRef(pickGrey(primitives, bgBase, 2, isDark ? 700 : 500)),
"v2-icon-icon-inverse": greyRef(pickGrey(primitives, bgInverse, 7, inverseTarget)),
"v2-icon-icon-contrast": greyRef(pickGrey(primitives, bgContrast, 7, 100)),
"v2-icon-icon-accent": isDark ? "var(--v2-blue-400)" : "var(--v2-blue-600)",
+4
View File
@@ -27,6 +27,7 @@
/* ── Icon ── */
--v2-icon-icon-base: var(--v2-grey-800);
--v2-icon-icon-muted: var(--v2-grey-600);
--v2-icon-icon-faint: var(--v2-grey-500);
--v2-icon-icon-inverse: var(--v2-grey-50);
--v2-icon-icon-contrast: var(--v2-grey-100);
--v2-icon-icon-accent: var(--v2-blue-600);
@@ -158,6 +159,7 @@
--v2-icon-icon-base: var(--v2-grey-400);
--v2-icon-icon-muted: var(--v2-grey-600);
--v2-icon-icon-faint: var(--v2-grey-700);
--v2-icon-icon-inverse: var(--v2-grey-1000);
--v2-icon-icon-contrast: var(--v2-grey-200);
--v2-icon-icon-accent: var(--v2-blue-400);
@@ -271,6 +273,7 @@
--v2-icon-icon-base: var(--v2-grey-800);
--v2-icon-icon-muted: var(--v2-grey-600);
--v2-icon-icon-faint: var(--v2-grey-500);
--v2-icon-icon-inverse: var(--v2-grey-50);
--v2-icon-icon-contrast: var(--v2-grey-100);
--v2-icon-icon-accent: var(--v2-blue-600);
@@ -393,6 +396,7 @@
--v2-icon-icon-base: var(--v2-grey-400);
--v2-icon-icon-muted: var(--v2-grey-600);
--v2-icon-icon-faint: var(--v2-grey-700);
--v2-icon-icon-inverse: var(--v2-grey-1100);
--v2-icon-icon-contrast: var(--v2-grey-100);
--v2-icon-icon-accent: var(--v2-blue-400);