Compare commits

..

1 Commits

Author SHA1 Message Date
Shoubhit Dash 177370dae0 fix(tui): paste into custom form answers 2026-08-18 13:13:51 +00:00
151 changed files with 1539 additions and 2555 deletions
-5
View File
@@ -1,5 +0,0 @@
---
"@opencode-ai/core": patch
---
Simplify interrupt continuation: the steer-scoped resume decision now lives in SessionExecution as a post-cleanup inbox check, and the run coordinator drops its continuation state machine. Wakes arriving during cancellation cleanup now restart a normal full drain, and interrupting an idle session with continue now resumes pending steering input. Recovery-applied moves now end with the same full wake as inbox-admitted moves, retrying any stranded inbox work at the new location.
-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.
-1
View File
@@ -82,7 +82,6 @@ jobs:
build-cli: build-cli:
needs: version needs: version
runs-on: blacksmith-4vcpu-ubuntu-2404 runs-on: blacksmith-4vcpu-ubuntu-2404
timeout-minutes: 30
if: github.repository == 'anomalyco/opencode' if: github.repository == 'anomalyco/opencode'
steps: steps:
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0 - uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
@@ -905,7 +905,6 @@ const onContentBlockDelta = Effect.fn("AnthropicMessages.onContentBlockDelta")(f
if (delta?.type === "input_json_delta" && event.index !== undefined) { if (delta?.type === "input_json_delta" && event.index !== undefined) {
if (!delta.partial_json) return [state, NO_EVENTS] satisfies StepResult 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( const result = ToolStream.appendExisting(
ADAPTER, ADAPTER,
state.tools, 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", () => it.effect("settles pending tool calls at message_stop", () =>
Effect.gen(function* () { Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe( const response = yield* LLMClient.generate(request).pipe(
@@ -7,7 +7,6 @@ import { useMcpToggle } from "@/context/mcp"
import { useWorkspaceLocation } from "@/context/location" import { useWorkspaceLocation } from "@/context/location"
import { useServerSDK } from "@/context/server-sdk" import { useServerSDK } from "@/context/server-sdk"
import { useData } from "@/context/server" import { useData } from "@/context/server"
import { pluginLabel } from "@/utils/plugin"
import { ExternalLink } from "./external-link" import { ExternalLink } from "./external-link"
type SkillItem = { type SkillItem = {
@@ -102,10 +101,10 @@ export const ProjectSettingsExtensions: Component = () => {
() => (serverSDK.connection.status() === "connected" ? directorySDK().directory : undefined), () => (serverSDK.connection.status() === "connected" ? directorySDK().directory : undefined),
(directory) => serverSDK.api.plugin.list({ location: { directory } }).then((result) => result.data), (directory) => serverSDK.api.plugin.list({ location: { directory } }).then((result) => result.data),
) )
const globalPlugins = createMemo(() => (globalPluginList.latest ?? []).map(pluginLabel)) const globalPlugins = createMemo(() => (globalPluginList.latest ?? []).map((item) => item.id))
const projectPlugins = createMemo(() => { const projectPlugins = createMemo(() => {
const shared = new Set(globalPlugins()) const shared = new Set(globalPlugins())
return (projectPluginList.latest ?? []).map(pluginLabel).filter((name) => !shared.has(name)) return (projectPluginList.latest ?? []).map((item) => item.id).filter((name) => !shared.has(name))
}) })
const serverSkills = createMemo(() => data.location.skill.list() ?? []) const serverSkills = createMemo(() => data.location.skill.list() ?? [])
@@ -6,7 +6,6 @@ import { useLanguage } from "@/context/language"
import { useData } from "@/context/server" import { useData } from "@/context/server"
import { useServerSDK } from "@/context/server-sdk" import { useServerSDK } from "@/context/server-sdk"
import { useMcpToggle } from "@/context/mcp" import { useMcpToggle } from "@/context/mcp"
import { pluginLabel } from "@/utils/plugin"
import { ExternalLink } from "../external-link" import { ExternalLink } from "../external-link"
import { InlineServerSelect } from "./parts/server-select" import { InlineServerSelect } from "./parts/server-select"
import "./settings-v2.css" import "./settings-v2.css"
@@ -45,9 +44,7 @@ export const SettingsExtensionsV2: Component = () => {
() => serverSdk.connection.status() === "connected", () => serverSdk.connection.status() === "connected",
() => serverSdk.api.plugin.list().then((result) => result.data), () => serverSdk.api.plugin.list().then((result) => result.data),
) )
const plugins = createMemo<PluginRowItem[]>(() => const plugins = createMemo<PluginRowItem[]>(() => (pluginList.latest ?? []).map((item) => ({ name: item.id })))
(pluginList.latest ?? []).map((item) => ({ name: pluginLabel(item) })),
)
createEffect(() => { createEffect(() => {
if (serverSdk.connection.status() !== "connected") return if (serverSdk.connection.status() !== "connected") return
@@ -6,7 +6,6 @@ import { useMcpToggle } from "@/context/mcp"
import { useWorkspaceLocation } from "@/context/location" import { useWorkspaceLocation } from "@/context/location"
import { useData } from "@/context/server" import { useData } from "@/context/server"
import { useServerSDK } from "@/context/server-sdk" import { useServerSDK } from "@/context/server-sdk"
import { pluginLabel } from "@/utils/plugin"
const pluginEmptyMessage = (value: string, file: string): JSXElement => { const pluginEmptyMessage = (value: string, file: string): JSXElement => {
const parts = value.split(file) const parts = value.split(file)
@@ -39,7 +38,7 @@ export function StatusPopoverBody(props: { shown: boolean }) {
() => (props.shown ? sdk().directory : undefined), () => (props.shown ? sdk().directory : undefined),
(directory) => serverSDK.api.plugin.list({ location: { directory } }).then((result) => result.data), (directory) => serverSDK.api.plugin.list({ location: { directory } }).then((result) => result.data),
) )
const plugins = createMemo(() => (pluginList.latest ?? []).map(pluginLabel)) const plugins = createMemo(() => (pluginList.latest ?? []).map((item) => item.id))
const pluginCount = createMemo(() => plugins().length) const pluginCount = createMemo(() => plugins().length)
const pluginEmpty = createMemo(() => pluginEmptyMessage(language.t("dialog.plugins.empty"), "opencode.json")) const pluginEmpty = createMemo(() => pluginEmptyMessage(language.t("dialog.plugins.empty"), "opencode.json"))
-8
View File
@@ -1,8 +0,0 @@
import type { PluginInfo } from "@opencode-ai/client"
export function pluginLabel(plugin: PluginInfo) {
if (plugin.id) return plugin.id
if (plugin.source.type === "package") return plugin.source.package
if (plugin.source.type === "local") return plugin.source.path
return plugin.source.type
}
+1 -1
View File
@@ -42,8 +42,8 @@
"solid-js": "catalog:", "solid-js": "catalog:",
"tree-sitter-bash": "0.25.0", "tree-sitter-bash": "0.25.0",
"tree-sitter-powershell": "0.25.10", "tree-sitter-powershell": "0.25.10",
"uqr": "0.1.3",
"web-tree-sitter": "0.25.10", "web-tree-sitter": "0.25.10",
"uqr": "0.1.3",
"ws": "8.21.0" "ws": "8.21.0"
}, },
"devDependencies": { "devDependencies": {
+1 -1
View File
@@ -109,7 +109,7 @@ for (const item of targets) {
external: ["node-gyp"], external: ["node-gyp"],
format: "esm", format: "esm",
minify: true, minify: true,
sourcemap: Script.channel === "dev" || Script.channel === "local" ? "inline" : "none", sourcemap: "inline",
splitting: true, splitting: true,
compile: { compile: {
autoloadBunfig: false, autoloadBunfig: false,
+12 -7
View File
@@ -1,9 +1,8 @@
#!/usr/bin/env bun #!/usr/bin/env bun
import { NodeFileSystem } from "@effect/platform-node"
import { Service } from "@opencode-ai/client/effect/service" import { Service } from "@opencode-ai/client/effect/service"
import { ServiceStatus } from "@opencode-ai/protocol/groups/health" import { ServiceStatus } from "@opencode-ai/protocol/groups/health"
import { Effect, Schema } from "effect" import { Schema } from "effect"
import fs from "node:fs/promises" import fs from "node:fs/promises"
import os from "node:os" import os from "node:os"
import path from "node:path" import path from "node:path"
@@ -64,22 +63,28 @@ try {
}) })
if (unauthorizedOpenApi.status !== 401) if (unauthorizedOpenApi.status !== 401)
throw new Error("Compiled service exposed application routes without authentication") throw new Error("Compiled service exposed application routes without authentication")
const stopRoute = await fetch(new URL("/api/service/stop", info.url), { const unauthorizedStop = await fetch(new URL("/api/service/stop", info.url), {
method: "POST", method: "POST",
headers: { ...headers, "content-type": "application/json" }, headers: { "content-type": "application/json" },
body: JSON.stringify({ instanceID: info.id }), body: JSON.stringify({ instanceID: info.id }),
signal: AbortSignal.timeout(5_000), signal: AbortSignal.timeout(5_000),
}) })
if (stopRoute.status !== 404) throw new Error("Compiled service exposed the removed HTTP stop route") if (unauthorizedStop.status !== 401) throw new Error("Compiled service accepted unauthenticated stop")
const winner = processes.find((process) => process.pid === info.pid) const winner = processes.find((process) => process.pid === info.pid)
const loser = processes.find((process) => process.pid !== info.pid) const loser = processes.find((process) => process.pid !== info.pid)
if (!winner || !loser) throw new Error("Compiled contenders did not elect one registered owner") if (!winner || !loser) throw new Error("Compiled contenders did not elect one registered owner")
if (!(await exitsWithin(loser, 10_000))) throw new Error("Losing compiled contender did not exit") if (!(await exitsWithin(loser, 10_000))) throw new Error("Losing compiled contender did not exit")
await Effect.runPromise( const stopped = await Schema.decodeUnknownPromise(ServiceStatus.StopResponse)(
Service.stop({ file: registration }).pipe(Effect.provide(NodeFileSystem.layer)), await fetch(new URL("/api/service/stop", info.url), {
method: "POST",
headers: { ...headers, "content-type": "application/json" },
body: JSON.stringify({ instanceID: info.id }),
signal: AbortSignal.timeout(5_000),
}).then((response) => response.json()),
) )
if (!stopped.accepted) throw new Error("Compiled service rejected exact-instance stop")
if (!(await exitsWithin(winner, 10_000))) throw new Error("Compiled service did not stop") if (!(await exitsWithin(winner, 10_000))) throw new Error("Compiled service did not stop")
for (let attempt = 0; attempt < 200 && (await Bun.file(registration).exists()); attempt++) await Bun.sleep(25) for (let attempt = 0; attempt < 200 && (await Bun.file(registration).exists()); attempt++) await Bun.sleep(25)
if (await Bun.file(registration).exists()) throw new Error("Compiled service registration was not removed") if (await Bun.file(registration).exists()) throw new Error("Compiled service registration was not removed")
+3 -24
View File
@@ -275,36 +275,15 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME
Spec.make("stop", { description: "Stop the background server" }), Spec.make("stop", { description: "Stop the background server" }),
Spec.make("get", { Spec.make("get", {
description: "Get service configuration", description: "Get service configuration",
params: { params: { key: Argument.string("key").pipe(Argument.optional) },
key: Argument.string("key").pipe(Argument.withDescription("Service setting or env"), Argument.optional),
name: Argument.string("name").pipe(
Argument.withDescription("Environment variable name"),
Argument.optional,
),
},
}), }),
Spec.make("set", { Spec.make("set", {
description: "Set service configuration", description: "Set service configuration",
params: { params: { key: Argument.string("key"), value: Argument.string("value") },
key: Argument.string("key").pipe(Argument.withDescription("Service setting or env")),
value: Argument.string("value").pipe(
Argument.withDescription("Setting value or environment variable name"),
),
nestedValue: Argument.string("env-value").pipe(
Argument.withDescription("Environment variable value"),
Argument.optional,
),
},
}), }),
Spec.make("unset", { Spec.make("unset", {
description: "Unset service configuration", description: "Unset service configuration",
params: { params: { key: Argument.string("key") },
key: Argument.string("key").pipe(Argument.withDescription("Service setting or env")),
name: Argument.string("name").pipe(
Argument.withDescription("Environment variable name"),
Argument.optional,
),
},
}), }),
], ],
}), }),
+1 -11
View File
@@ -4,7 +4,7 @@ import { run } from "@opencode-ai/tui"
import { Commands } from "../commands" import { Commands } from "../commands"
import { Runtime } from "../../framework/runtime" import { Runtime } from "../../framework/runtime"
import { Config } from "../../config" import { Config } from "../../config"
import { Context, Effect, FileSystem, Option, Queue } from "effect" import { Context, Effect, FileSystem, Option } from "effect"
import { ServerConnection } from "../../services/server-connection" import { ServerConnection } from "../../services/server-connection"
import { Updater } from "../../services/updater" import { Updater } from "../../services/updater"
import { UpdatePreflight } from "../../services/update-preflight" import { UpdatePreflight } from "../../services/update-preflight"
@@ -19,21 +19,11 @@ export default Runtime.handler(Commands, (input) =>
if (requestedDirectory !== undefined) process.chdir(requestedDirectory) if (requestedDirectory !== undefined) process.chdir(requestedDirectory)
const preflight = UpdatePreflight.make() const preflight = UpdatePreflight.make()
yield* Effect.addFinalizer(() => Effect.promise(() => preflight.close())) yield* Effect.addFinalizer(() => Effect.promise(() => preflight.close()))
const serviceStarts = yield* Queue.unbounded<{
readonly reason: "missing" | "version-mismatch"
readonly previousVersion?: string
}>()
yield* Queue.take(serviceStarts).pipe(
Effect.flatMap((event) => Effect.logInfo("background service starting", event)),
Effect.forever,
Effect.forkScoped,
)
const server = yield* ServerConnection.resolve({ const server = yield* ServerConnection.resolve({
server: requestedServer, server: requestedServer,
standalone: input.standalone, standalone: input.standalone,
mismatch: "replace", mismatch: "replace",
onStart: (reason, previousVersion) => { onStart: (reason, previousVersion) => {
Queue.offerUnsafe(serviceStarts, { reason, previousVersion })
if (reason === "version-mismatch" && preflight.begin(previousVersion)) return if (reason === "version-mismatch" && preflight.begin(previousVersion)) return
process.stderr.write( process.stderr.write(
reason === "version-mismatch" reason === "version-mismatch"
@@ -1,6 +1,6 @@
import { EOL } from "node:os" import { EOL } from "node:os"
import { Effect } from "effect" import { Effect } from "effect"
import { OpenCode, type PluginInfo } from "@opencode-ai/client" import { OpenCode } from "@opencode-ai/client"
import { Service } from "@opencode-ai/client/effect/service" import { Service } from "@opencode-ai/client/effect/service"
import { Commands } from "../../commands" import { Commands } from "../../commands"
import { Runtime } from "../../../framework/runtime" import { Runtime } from "../../../framework/runtime"
@@ -14,18 +14,11 @@ export default Runtime.handler(
const endpoint = found ?? (yield* Service.ensure(options)) const endpoint = found ?? (yield* Service.ensure(options))
const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) }) const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
const response = yield* Effect.promise(() => client.plugin.list({ location: { directory: process.cwd() } })) const response = yield* Effect.promise(() => client.plugin.list({ location: { directory: process.cwd() } }))
const plugins = response.data.toSorted((a, b) => name(a).localeCompare(name(b))) const plugins = response.data.toSorted((a, b) => a.id.localeCompare(b.id))
if (plugins.length === 0) { if (plugins.length === 0) {
process.stdout.write("No plugins loaded" + EOL) process.stdout.write("No plugins loaded" + EOL)
return return
} }
process.stdout.write(plugins.map(name).join(EOL) + EOL) process.stdout.write(plugins.map((plugin) => plugin.id).join(EOL) + EOL)
}), }),
) )
function name(plugin: PluginInfo) {
if (plugin.id) return plugin.id
if (plugin.source.type === "package") return plugin.source.package
if (plugin.source.type === "local") return plugin.source.path
return plugin.source.type
}
@@ -7,8 +7,6 @@ import { ServiceConfig } from "../../../services/service-config"
export default Runtime.handler( export default Runtime.handler(
Commands.commands.service.commands.get, Commands.commands.service.commands.get,
Effect.fn("cli.service.get")(function* (input) { Effect.fn("cli.service.get")(function* (input) {
process.stdout.write( process.stdout.write((yield* ServiceConfig.get(Option.getOrUndefined(input.key))) + EOL)
(yield* ServiceConfig.get(Option.getOrUndefined(input.key), Option.getOrUndefined(input.name))) + EOL,
)
}), }),
) )
@@ -1,4 +1,4 @@
import { Effect, Option } from "effect" import { Effect } from "effect"
import { Commands } from "../../commands" import { Commands } from "../../commands"
import { Runtime } from "../../../framework/runtime" import { Runtime } from "../../../framework/runtime"
import { ServiceConfig } from "../../../services/service-config" import { ServiceConfig } from "../../../services/service-config"
@@ -6,6 +6,6 @@ import { ServiceConfig } from "../../../services/service-config"
export default Runtime.handler( export default Runtime.handler(
Commands.commands.service.commands.set, Commands.commands.service.commands.set,
Effect.fn("cli.service.set")(function* (input) { Effect.fn("cli.service.set")(function* (input) {
yield* ServiceConfig.set(input.key, input.value, Option.getOrUndefined(input.nestedValue)) yield* ServiceConfig.set(input.key, input.value)
}), }),
) )
@@ -1,4 +1,4 @@
import { Effect, Option } from "effect" import { Effect } from "effect"
import { Commands } from "../../commands" import { Commands } from "../../commands"
import { Runtime } from "../../../framework/runtime" import { Runtime } from "../../../framework/runtime"
import { ServiceConfig } from "../../../services/service-config" import { ServiceConfig } from "../../../services/service-config"
@@ -6,6 +6,6 @@ import { ServiceConfig } from "../../../services/service-config"
export default Runtime.handler( export default Runtime.handler(
Commands.commands.service.commands.unset, Commands.commands.service.commands.unset,
Effect.fn("cli.service.unset")(function* (input) { Effect.fn("cli.service.unset")(function* (input) {
yield* ServiceConfig.unset(input.key, Option.getOrUndefined(input.name)) yield* ServiceConfig.unset(input.key)
}), }),
) )
-21
View File
@@ -59,21 +59,6 @@ const Handlers = Runtime.handlers(Commands, {
Effect.gen(function* () { Effect.gen(function* () {
yield* Heap.listen yield* Heap.listen
const runFork = Effect.runForkWith(yield* Effect.context<never>())
const uncaughtException = (cause: Error, origin: "uncaughtException" | "unhandledRejection") => {
runFork(Effect.logError("uncaught exception", { cause, origin }))
}
const unhandledRejection = (cause: unknown) => {
runFork(Effect.logError("unhandled rejection", { cause }))
}
process.on("uncaughtException", uncaughtException)
process.on("unhandledRejection", unhandledRejection)
yield* Effect.addFinalizer(() =>
Effect.sync(() => {
process.off("uncaughtException", uncaughtException)
process.off("unhandledRejection", unhandledRejection)
}),
)
yield* Effect.logInfo("cli starting", { yield* Effect.logInfo("cli starting", {
version: OPENCODE_VERSION, version: OPENCODE_VERSION,
channel: OPENCODE_CHANNEL, channel: OPENCODE_CHANNEL,
@@ -82,12 +67,6 @@ Effect.gen(function* () {
}) })
return yield* Runtime.run(Commands, Handlers, { version: OPENCODE_VERSION }) return yield* Runtime.run(Commands, Handlers, { version: OPENCODE_VERSION })
}).pipe( }).pipe(
Effect.catchCause((cause) =>
Effect.logError("cli process failed", {
cause,
args: process.argv.slice(2),
}).pipe(Effect.andThen(Effect.failCause(cause))),
),
Effect.annotateLogs({ role: "cli" }), Effect.annotateLogs({ role: "cli" }),
Effect.provide(Config.layer), Effect.provide(Config.layer),
Effect.provide(Updater.layer), Effect.provide(Updater.layer),
+7 -24
View File
@@ -117,6 +117,7 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
serviceOptions === undefined serviceOptions === undefined
? undefined ? undefined
: { : {
instanceID,
onListen: (address, shutdown) => onListen: (address, shutdown) =>
Effect.gen(function* () { Effect.gen(function* () {
if (!config.password) yield* ServiceConfig.password(password) if (!config.password) yield* ServiceConfig.password(password)
@@ -179,36 +180,18 @@ const register = Effect.fnUntraced(function* (
password, password,
} }
const encoded = yield* encodeInfo(info) const encoded = yield* encodeInfo(info)
const current = fs.readFileString(file).pipe(Effect.flatMap(decodeInfo)) const current = fs.readFileString(file).pipe(
const owns = (found: Info) => Effect.flatMap(decodeInfo),
found.id === info.id && Effect.orElseSucceed(() => undefined),
)
const owns = (found: Info | undefined) =>
found?.id === info.id &&
found.version === info.version && found.version === info.version &&
found.url === info.url && found.url === info.url &&
found.pid === info.pid && found.pid === info.pid &&
found.password === info.password found.password === info.password
yield* fs.writeFileString(temp, encoded, { mode: 0o600 }).pipe(Effect.andThen(fs.rename(temp, file))) yield* fs.writeFileString(temp, encoded, { mode: 0o600 }).pipe(Effect.andThen(fs.rename(temp, file)))
yield* current.pipe( yield* current.pipe(
Effect.catchCause((cause) =>
Effect.logWarning("managed service registration check failed; shutting down", {
cause,
serviceID: id,
servicePID: process.pid,
registration: file,
}).pipe(Effect.andThen(Effect.failCause(cause))),
),
Effect.tap((found) =>
owns(found)
? Effect.void
: Effect.logWarning("managed service registration replaced; shutting down", {
serviceID: id,
servicePID: process.pid,
registration: file,
observedServiceID: found.id,
observedServicePID: found.pid,
observedVersion: found.version,
observedURL: found.url,
}),
),
Effect.filterOrFail(owns), Effect.filterOrFail(owns),
Effect.repeat(Schedule.spaced("5 seconds")), Effect.repeat(Schedule.spaced("5 seconds")),
Effect.ignore, Effect.ignore,
+8 -37
View File
@@ -15,11 +15,10 @@ export const Info = Schema.Struct({
hostname: Schema.optional(Schema.String), hostname: Schema.optional(Schema.String),
port: Schema.optional(Schema.Int.check(Schema.isGreaterThanOrEqualTo(1), Schema.isLessThanOrEqualTo(65_535))), port: Schema.optional(Schema.Int.check(Schema.isGreaterThanOrEqualTo(1), Schema.isLessThanOrEqualTo(65_535))),
password: Schema.optional(Schema.String), password: Schema.optional(Schema.String),
env: Schema.optional(Schema.Record(Schema.String, Schema.String)),
}) })
export type Info = typeof Info.Type export type Info = typeof Info.Type
const keys = ["hostname", "port", "password", "env"] as const const keys = ["hostname", "port", "password"] as const
type Key = (typeof keys)[number] type Key = (typeof keys)[number]
const decodeInfo = Schema.decodeUnknownEffect(Schema.fromJsonString(Info)) const decodeInfo = Schema.decodeUnknownEffect(Schema.fromJsonString(Info))
@@ -77,7 +76,7 @@ export const migrateConfig = Effect.fnUntraced(function* (legacy: string, file:
}) })
function configKey(key: string): Key { function configKey(key: string): Key {
if (key === "hostname" || key === "port" || key === "password" || key === "env") return key if (key === "hostname" || key === "port" || key === "password") return key
throw new Error(`Unknown service config key: ${key}`) throw new Error(`Unknown service config key: ${key}`)
} }
@@ -105,7 +104,6 @@ export const options = Effect.fnUntraced(function* (input: { readonly checkVersi
return { return {
file, file,
version: input.checkVersion ? OPENCODE_VERSION : undefined, version: input.checkVersion ? OPENCODE_VERSION : undefined,
env: (yield* read()).env,
command: [ command: [
...selfCommand(), ...selfCommand(),
"serve", "serve",
@@ -143,14 +141,12 @@ export const password = Effect.fn("cli.service-config.password")(function* (valu
return next return next
}) })
export const get = Effect.fn("cli.service-config.get")(function* (key?: string, name?: string) { export const get = Effect.fn("cli.service-config.get")(function* (key?: string) {
if (key === undefined) { if (key === undefined) {
const { password: _password, ...safe } = yield* read() const { password: _password, ...safe } = yield* read()
return JSON.stringify(safe, null, 2) return JSON.stringify(safe, null, 2)
} }
const selected = configKey(key) switch (configKey(key)) {
if (selected !== "env" && name !== undefined) throw new Error(`Usage: opencode service get ${selected}`)
switch (selected) {
case "hostname": { case "hostname": {
return (yield* read()).hostname ?? "" return (yield* read()).hostname ?? ""
} }
@@ -161,19 +157,12 @@ export const get = Effect.fn("cli.service-config.get")(function* (key?: string,
case "password": { case "password": {
return yield* password() return yield* password()
} }
case "env": {
const env = (yield* read()).env ?? {}
return name === undefined ? JSON.stringify(env, null, 2) : (env[name] ?? "")
}
} }
throw new Error(`Unknown service config key: ${key}`) throw new Error(`Unknown service config key: ${key}`)
}) })
export const set = Effect.fn("cli.service-config.set")(function* (key: string, value: string, nestedValue?: string) { export const set = Effect.fn("cli.service-config.set")(function* (key: string, value: string) {
const selected = configKey(key) switch (configKey(key)) {
if (selected !== "env" && nestedValue !== undefined)
throw new Error(`Usage: opencode service set ${selected} <value>`)
switch (selected) {
case "hostname": { case "hostname": {
yield* Service.stop(yield* options()) yield* Service.stop(yield* options())
yield* write({ ...(yield* read()), hostname: value }) yield* write({ ...(yield* read()), hostname: value })
@@ -191,20 +180,11 @@ export const set = Effect.fn("cli.service-config.set")(function* (key: string, v
yield* password(value) yield* password(value)
return return
} }
case "env": {
if (nestedValue === undefined) throw new Error("Usage: opencode service set env <key> <value>")
yield* Service.stop(yield* options())
const existing = yield* read()
yield* write({ ...existing, env: { ...existing.env, [value]: nestedValue } })
return
}
} }
}) })
export const unset = Effect.fn("cli.service-config.unset")(function* (key: string, name?: string) { export const unset = Effect.fn("cli.service-config.unset")(function* (key: string) {
const selected = configKey(key) switch (configKey(key)) {
if (selected !== "env" && name !== undefined) throw new Error(`Usage: opencode service unset ${selected}`)
switch (selected) {
case "hostname": { case "hostname": {
yield* Service.stop(yield* options()) yield* Service.stop(yield* options())
const { hostname: _hostname, ...next } = yield* read() const { hostname: _hostname, ...next } = yield* read()
@@ -223,15 +203,6 @@ export const unset = Effect.fn("cli.service-config.unset")(function* (key: strin
yield* write(next) yield* write(next)
return return
} }
case "env": {
if (name === undefined) throw new Error("Usage: opencode service unset env <key>")
yield* Service.stop(yield* options())
const existing = yield* read()
const { [name]: _removed, ...env } = existing.env ?? {}
const { env: _existingEnv, ...rest } = existing
yield* write(Object.keys(env).length === 0 ? rest : { ...rest, env })
return
}
} }
}) })
-38
View File
@@ -60,44 +60,6 @@ test("local channel stores service config with the local service filename", asyn
} }
}) })
test("service config manages environment variables", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-env-"))
const layer = Global.layerWith({ config: path.join(root, "config"), state: path.join(root, "state") })
try {
await Effect.runPromise(
ServiceConfig.set("env", "OPENCODE_SERVICE_ENV_TEST", "configured").pipe(
Effect.provide(layer),
Effect.provide(NodeFileSystem.layer),
),
)
expect(
await Effect.runPromise(
ServiceConfig.get("env", "OPENCODE_SERVICE_ENV_TEST").pipe(
Effect.provide(layer),
Effect.provide(NodeFileSystem.layer),
),
),
).toBe("configured")
expect(
(
await Effect.runPromise(
ServiceConfig.options().pipe(Effect.provide(layer), Effect.provide(NodeFileSystem.layer)),
)
).env,
).toEqual({ OPENCODE_SERVICE_ENV_TEST: "configured" })
await Effect.runPromise(
ServiceConfig.unset("env", "OPENCODE_SERVICE_ENV_TEST").pipe(
Effect.provide(layer),
Effect.provide(NodeFileSystem.layer),
),
)
expect(await Bun.file(path.join(root, "config", "service-local.json")).json()).toEqual({})
} finally {
await fs.rm(root, { recursive: true, force: true })
}
})
test("service filenames share release channels and identify preview channels", () => { test("service filenames share release channels and identify preview channels", () => {
expect(ServiceConfig.filename("latest")).toBe("service.json") expect(ServiceConfig.filename("latest")).toBe("service.json")
expect(ServiceConfig.filename("dev")).toBe("service.json") expect(ServiceConfig.filename("dev")).toBe("service.json")
+5
View File
@@ -41,8 +41,13 @@ import type { Config } from "@opencode-ai/schema/config"
export type Endpoint0_0Output = { readonly healthy: true; readonly version: string; readonly pid: number } export type Endpoint0_0Output = { readonly healthy: true; readonly version: string; readonly pid: number }
export type HealthGetOperation<E = never> = () => Effect.Effect<Endpoint0_0Output, E> export type HealthGetOperation<E = never> = () => Effect.Effect<Endpoint0_0Output, E>
export type Endpoint0_1Input = { readonly instanceID: string }
export type Endpoint0_1Output = { readonly accepted: boolean }
export type HealthStopOperation<E = never> = (input: Endpoint0_1Input) => Effect.Effect<Endpoint0_1Output, E>
export interface HealthApi<E = never> { export interface HealthApi<E = never> {
readonly get: HealthGetOperation<E> readonly get: HealthGetOperation<E>
readonly stop: HealthStopOperation<E>
} }
export type Endpoint1_0Output = { readonly urls: ReadonlyArray<string> } export type Endpoint1_0Output = { readonly urls: ReadonlyArray<string> }
@@ -6,6 +6,8 @@ import { HttpApiClient } from "effect/unstable/httpapi"
import { ClientApi } from "../../contract" import { ClientApi } from "../../contract"
import type { import type {
Endpoint0_0Output, Endpoint0_0Output,
Endpoint0_1Input,
Endpoint0_1Output,
Endpoint1_0Output, Endpoint1_0Output,
Endpoint2_0Input, Endpoint2_0Input,
Endpoint2_0Output, Endpoint2_0Output,
@@ -246,7 +248,12 @@ const preserveStream =
const Endpoint0_0 = (raw: RawClient["server.health"]) => () => const Endpoint0_0 = (raw: RawClient["server.health"]) => () =>
preserveEffect<Endpoint0_0Output>()(raw["health.get"]({}).pipe(Effect.mapError(mapClientError))) preserveEffect<Endpoint0_0Output>()(raw["health.get"]({}).pipe(Effect.mapError(mapClientError)))
const adaptGroup0 = (raw: RawClient["server.health"]) => ({ get: Endpoint0_0(raw) }) const Endpoint0_1 = (raw: RawClient["server.health"]) => (input: Endpoint0_1Input) =>
preserveEffect<Endpoint0_1Output>()(
raw["health.stop"]({ payload: { instanceID: input["instanceID"] } }).pipe(Effect.mapError(mapClientError)),
)
const adaptGroup0 = (raw: RawClient["server.health"]) => ({ get: Endpoint0_0(raw), stop: Endpoint0_1(raw) })
const Endpoint1_0 = (raw: RawClient["server.server"]) => () => const Endpoint1_0 = (raw: RawClient["server.server"]) => () =>
preserveEffect<Endpoint1_0Output>()(raw["server.get"]({}).pipe(Effect.mapError(mapClientError))) preserveEffect<Endpoint1_0Output>()(raw["server.get"]({}).pipe(Effect.mapError(mapClientError)))
+58 -14
View File
@@ -71,7 +71,7 @@ export const ensure = Effect.fn("service.ensure")(function* (options: EnsureOpti
if (command === undefined) return yield* Effect.fail(new Error("Missing service command")) if (command === undefined) return yield* Effect.fail(new Error("Missing service command"))
return yield* Effect.try({ return yield* Effect.try({
try: () => { try: () => {
return spawnServiceContender(command, args, options.env) return spawnServiceContender(command, args)
}, },
catch: (cause) => new Error("Failed to start server", { cause }), catch: (cause) => new Error("Failed to start server", { cause }),
}) })
@@ -87,7 +87,7 @@ export const ensure = Effect.fn("service.ensure")(function* (options: EnsureOpti
} }
if (timeouts.count >= 3) { if (timeouts.count >= 3) {
yield* announce("missing") yield* announce("missing")
yield* terminate(info, options, timing) yield* evict(info, options, timing)
timeouts = undefined timeouts = undefined
lastSpawn = Date.now() - spawnDelay lastSpawn = Date.now() - spawnDelay
} }
@@ -100,7 +100,7 @@ export const ensure = Effect.fn("service.ensure")(function* (options: EnsureOpti
return yield* Effect.fail(new Error("Background service failed to start")) return yield* Effect.fail(new Error("Background service failed to start"))
if (compatible) return Option.none<LocalService>() if (compatible) return Option.none<LocalService>()
yield* announce("version-mismatch", service.version) yield* announce("version-mismatch", service.version)
yield* terminate(service.info, options, timing).pipe(Effect.ignore) yield* kill(service, options, timing).pipe(Effect.ignore)
lastSpawn = 0 lastSpawn = 0
return Option.none<LocalService>() return Option.none<LocalService>()
} else if (lastSpawn === 0 && info !== undefined) lastSpawn = Date.now() } else if (lastSpawn === 0 && info !== undefined) lastSpawn = Date.now()
@@ -133,8 +133,8 @@ export const ensure = Effect.fn("service.ensure")(function* (options: EnsureOpti
/** Stop the registered local service. */ /** Stop the registered local service. */
export const stop = Effect.fn("service.stop")(function* (options: StopOptions = {}) { export const stop = Effect.fn("service.stop")(function* (options: StopOptions = {}) {
const info = yield* read(options.file) const existing = yield* find(options)
if (info !== undefined) yield* terminate(info, options, defaultEnsureTiming) if (existing !== undefined) yield* kill(existing, options, defaultEnsureTiming)
}) })
function fallback() { function fallback() {
@@ -243,6 +243,12 @@ const registered = Effect.fnUntraced(function* (file?: string, allowLegacy = fal
return { info, ...(yield* probeResult(info, allowLegacy, timeout)) } return { info, ...(yield* probeResult(info, allowLegacy, timeout)) }
}) })
// Health-checked lookup without the version gate: lifecycle operations must be
// able to see (and replace or stop) a server from a different version.
const find = Effect.fnUntraced(function* (options: { readonly file?: string }) {
return (yield* registered(options.file, true)).service
})
// 50ms cadence bounded at ~5s, shared by stop escalation and each ensure // 50ms cadence bounded at ~5s, shared by stop escalation and each ensure
// discovery window. // discovery window.
const poll = (timing: EnsureTiming) => const poll = (timing: EnsureTiming) =>
@@ -263,21 +269,59 @@ function same(left: Info, right: Info) {
return left.id === right.id && left.version === right.version && left.url === right.url && left.pid === right.pid return left.id === right.id && left.version === right.version && left.url === right.url && left.pid === right.pid
} }
const terminate = Effect.fnUntraced(function* (info: Info, options: { readonly file?: string }, timing: EnsureTiming) { const evict = Effect.fnUntraced(function* (info: Info, options: { readonly file?: string }, timing: EnsureTiming) {
const current = yield* read(options.file) const current = yield* read(options.file)
if (current === undefined || !same(current, info)) return if (current === undefined || !same(current, info)) return
yield* signal(info.pid, "SIGTERM") yield* signal(info.pid, "SIGTERM")
const done = yield* stopped(info.pid).pipe(Effect.retry(poll(timing)), Effect.option) const done = yield* stopped(info.pid).pipe(Effect.retry(poll(timing)), Effect.option)
if (Option.isNone(done)) { if (Option.isSome(done)) return
const latest = yield* read(options.file)
if (latest === undefined || !same(latest, info)) return
yield* signal(info.pid, "SIGKILL")
yield* stopped(info.pid).pipe(Effect.retry(poll(timing)))
}
const latest = yield* read(options.file) const latest = yield* read(options.file)
if (latest === undefined || !same(latest, info)) return if (latest === undefined || !same(latest, info)) return
const fs = yield* FileSystem.FileSystem yield* signal(info.pid, "SIGKILL")
yield* fs.remove(options.file ?? fallback()).pipe(Effect.ignore) yield* stopped(info.pid).pipe(Effect.retry(poll(timing)))
})
const kill = Effect.fnUntraced(function* (
service: LocalService,
options: { readonly file?: string },
timing: EnsureTiming,
) {
const requested = yield* requestStop(service, timing.requestTimeout)
if (requested === "rejected") return
if (requested === "unsupported") {
// A stale registration may point at a reused PID. Authenticate again
// immediately before the legacy signal fallback.
const current = yield* find(options)
if (current === undefined || !same(current.info, service.info)) return
yield* signal(service.info.pid, "SIGTERM")
}
const done = yield* stopped(service.info.pid).pipe(Effect.retry(poll(timing)), Effect.option)
if (Option.isSome(done)) return
const latest = yield* find(options)
if (latest === undefined || !same(latest.info, service.info)) return
yield* signal(service.info.pid, "SIGKILL")
yield* stopped(service.info.pid).pipe(Effect.retry(poll(timing)))
})
const decodeStopResponse = Schema.decodeUnknownOption(ServiceStatus.StopResponse)
const requestStop = Effect.fnUntraced(function* (service: LocalService, timeout = defaultEnsureTiming.requestTimeout) {
if (service.info.id === undefined || service.legacy) return "unsupported" as const
const response = yield* Effect.tryPromise(() =>
fetch(new URL("/api/service/stop", service.info.url), {
method: "POST",
headers: { ...headers(service.endpoint), "content-type": "application/json" },
body: JSON.stringify({ instanceID: service.info.id }),
signal: AbortSignal.timeout(timeout),
}),
).pipe(Effect.option, Effect.map(Option.getOrUndefined))
if (response === undefined || response.status === 404 || response.status === 405) return "unsupported" as const
const body = yield* Effect.tryPromise(() => response.json()).pipe(Effect.option, Effect.map(Option.getOrUndefined))
const decoded = decodeStopResponse(body)
if (!response.ok || Option.isNone(decoded) || !decoded.value.accepted) return "rejected" as const
return "accepted" as const
}) })
/** Effect-based local service lifecycle operations. */ /** Effect-based local service lifecycle operations. */
@@ -1,5 +1,7 @@
import type { import type {
HealthGetOutput, HealthGetOutput,
HealthStopInput,
HealthStopOutput,
ServerGetOutput, ServerGetOutput,
LocationGetInput, LocationGetInput,
LocationGetOutput, LocationGetOutput,
@@ -365,6 +367,18 @@ export function make(options: ClientOptions) {
{ method: "GET", path: `/api/health`, successStatus: 200, declaredStatuses: [401, 400], empty: false }, { method: "GET", path: `/api/health`, successStatus: 200, declaredStatuses: [401, 400], empty: false },
requestOptions, requestOptions,
), ),
stop: (input: HealthStopInput, requestOptions?: RequestOptions) =>
request<HealthStopOutput>(
{
method: "POST",
path: `/api/service/stop`,
body: { instanceID: input["instanceID"] },
successStatus: 200,
declaredStatuses: [401, 400],
empty: false,
},
requestOptions,
),
}, },
server: { server: {
get: (requestOptions?: RequestOptions) => get: (requestOptions?: RequestOptions) =>
@@ -2,6 +2,8 @@ export type JsonValue = null | boolean | number | string | Array<JsonValue> | {
export type ServiceHealth = { healthy: true; version: string; pid: number } export type ServiceHealth = { healthy: true; version: string; pid: number }
export type ServiceStopResponse = { accepted: boolean }
export type ModelRef = { id: string; providerID: string; variant?: string } export type ModelRef = { id: string; providerID: string; variant?: string }
export type ProviderSettings = { [x: string]: any } export type ProviderSettings = { [x: string]: any }
@@ -10,11 +12,7 @@ export type AgentColor = string
export type PermissionEffect = "allow" | "deny" | "ask" export type PermissionEffect = "allow" | "deny" | "ask"
export type PluginSource = export type PluginInfo = { id: string }
| { type: "builtin" }
| { type: "package"; package: string }
| { type: "local"; path: string }
| { type: "sdk" }
export type SessionForkBoundary = { type: "before"; messageID: string } | { type: "through"; messageID: string } export type SessionForkBoundary = { type: "before"; messageID: string } | { type: "through"; messageID: string }
@@ -200,10 +198,6 @@ export type ProviderRequest = {
export type PermissionRule = { action: string; resource: string; effect: PermissionEffect } export type PermissionRule = { action: string; resource: string; effect: PermissionEffect }
export type PluginInfo =
| { id: string; source: PluginSource; status: "active"; tui: boolean }
| { id?: string; source: PluginSource; status: "failed"; error: string; tui: boolean }
export type TokenUsageInfo = { export type TokenUsageInfo = {
input: number input: number
output: number output: number
@@ -2279,6 +2273,10 @@ export const isWorktreeError = (value: unknown): value is WorktreeError =>
export type HealthGetOutput = ServiceHealth export type HealthGetOutput = ServiceHealth
export type HealthStopInput = { readonly instanceID: { readonly instanceID: string }["instanceID"] }
export type HealthStopOutput = ServiceStopResponse
export type ServerGetOutput = { urls: Array<string> } export type ServerGetOutput = { urls: Array<string> }
export type LocationGetInput = { export type LocationGetInput = {
+47 -15
View File
@@ -1,4 +1,4 @@
import { readFile, rm } from "node:fs/promises" import { readFile } from "node:fs/promises"
import { homedir } from "node:os" import { homedir } from "node:os"
import { join } from "node:path" import { join } from "node:path"
import type { DiscoverOptions, Endpoint, Info, EnsureOptions, StopOptions } from "../service.js" import type { DiscoverOptions, Endpoint, Info, EnsureOptions, StopOptions } from "../service.js"
@@ -10,7 +10,7 @@ import {
} from "../service-contender.js" } from "../service-contender.js"
import { defaultEnsureTiming, ensureTiming, type EnsureTiming } from "../service-timing.js" import { defaultEnsureTiming, ensureTiming, type EnsureTiming } from "../service-timing.js"
import { matchesVersion } from "../service-version.js" import { matchesVersion } from "../service-version.js"
import type { ServiceHealth } from "./generated/types.js" import type { ServiceHealth, ServiceStopResponse } from "./generated/types.js"
export * from "../service.js" export * from "../service.js"
@@ -51,7 +51,7 @@ export async function ensure(options: EnsureOptions = {}): Promise<Endpoint> {
const [command, ...args] = options.command ?? ["opencode", "serve", "--service"] const [command, ...args] = options.command ?? ["opencode", "serve", "--service"]
if (command === undefined) throw new Error("Missing service command") if (command === undefined) throw new Error("Missing service command")
try { try {
return spawnServiceContender(command, args, options.env) return spawnServiceContender(command, args)
} catch (cause) { } catch (cause) {
throw new Error("Failed to start server", { cause }) throw new Error("Failed to start server", { cause })
} }
@@ -68,7 +68,7 @@ export async function ensure(options: EnsureOptions = {}): Promise<Endpoint> {
} }
if (timeouts.count >= 3) { if (timeouts.count >= 3) {
announce("missing") announce("missing")
await terminate(registration.info, options, timing) await evict(registration.info, options, timing)
timeouts = undefined timeouts = undefined
lastSpawn = Date.now() - spawnDelay lastSpawn = Date.now() - spawnDelay
} }
@@ -82,7 +82,7 @@ export async function ensure(options: EnsureOptions = {}): Promise<Endpoint> {
if (compatible && service.state === "failed") throw new Error("Background service failed to start") if (compatible && service.state === "failed") throw new Error("Background service failed to start")
if (!compatible) { if (!compatible) {
announce("version-mismatch", service.version) announce("version-mismatch", service.version)
await terminate(service.info, options, timing).catch(() => undefined) await kill(service, options, timing).catch(() => undefined)
lastSpawn = 0 lastSpawn = 0
} }
} else { } else {
@@ -110,8 +110,8 @@ export async function ensure(options: EnsureOptions = {}): Promise<Endpoint> {
/** Stop the registered local service. */ /** Stop the registered local service. */
export async function stop(options: StopOptions = {}) { export async function stop(options: StopOptions = {}) {
const info = await read(options.file) const existing = await find(options)
if (info !== undefined) await terminate(info, options, defaultEnsureTiming) if (existing !== undefined) await kill(existing, options, defaultEnsureTiming)
} }
function fallback() { function fallback() {
@@ -199,6 +199,10 @@ async function registered(file?: string, allowLegacy = false, timeout?: number)
return { info, ...(await probeResult(info, allowLegacy, timeout)) } return { info, ...(await probeResult(info, allowLegacy, timeout)) }
} }
async function find(options: { readonly file?: string }) {
return (await registered(options.file, true)).service
}
function signal(pid: number, name: NodeJS.Signals) { function signal(pid: number, name: NodeJS.Signals) {
try { try {
process.kill(pid, name) process.kill(pid, name)
@@ -226,19 +230,47 @@ function same(left: Info, right: Info) {
return left.id === right.id && left.version === right.version && left.url === right.url && left.pid === right.pid return left.id === right.id && left.version === right.version && left.url === right.url && left.pid === right.pid
} }
async function terminate(info: Info, options: { readonly file?: string }, timing: EnsureTiming) { async function evict(info: Info, options: { readonly file?: string }, timing: EnsureTiming) {
const current = await read(options.file) const current = await read(options.file)
if (current === undefined || !same(current, info)) return if (current === undefined || !same(current, info)) return
signal(info.pid, "SIGTERM") signal(info.pid, "SIGTERM")
if (!(await waitUntilStopped(info.pid, timing))) { if (await waitUntilStopped(info.pid, timing)) return
const latest = await read(options.file)
if (latest === undefined || !same(latest, info)) return
signal(info.pid, "SIGKILL")
if (!(await waitUntilStopped(info.pid, timing))) throw new Error(`Server process ${info.pid} is still running`)
}
const latest = await read(options.file) const latest = await read(options.file)
if (latest === undefined || !same(latest, info)) return if (latest === undefined || !same(latest, info)) return
await rm(options.file ?? fallback(), { force: true }) signal(info.pid, "SIGKILL")
if (!(await waitUntilStopped(info.pid, timing))) throw new Error(`Server process ${info.pid} is still running`)
}
async function kill(service: LocalService, options: { readonly file?: string }, timing: EnsureTiming) {
const requested = await requestStop(service, timing.requestTimeout)
if (requested === "rejected") return
if (requested === "unsupported") {
const current = await find(options)
if (current === undefined || !same(current.info, service.info)) return
signal(service.info.pid, "SIGTERM")
}
if (await waitUntilStopped(service.info.pid, timing)) return
const latest = await find(options)
if (latest === undefined || !same(latest.info, service.info)) return
signal(service.info.pid, "SIGKILL")
if (!(await waitUntilStopped(service.info.pid, timing)))
throw new Error(`Server process ${service.info.pid} is still running`)
}
async function requestStop(service: LocalService, timeout = defaultEnsureTiming.requestTimeout) {
if (service.info.id === undefined || service.legacy) return "unsupported" as const
const response = await fetch(new URL("/api/service/stop", service.info.url), {
method: "POST",
headers: { ...headers(service.endpoint), "content-type": "application/json" },
body: JSON.stringify({ instanceID: service.info.id }),
signal: AbortSignal.timeout(timeout),
}).catch(() => undefined)
if (response === undefined || response.status === 404 || response.status === 405) return "unsupported" as const
const body = (await response.json().catch(() => undefined)) as ServiceStopResponse | undefined
if (!response.ok || body?.accepted !== true) return "rejected" as const
return "accepted" as const
} }
function delay(milliseconds: number) { function delay(milliseconds: number) {
+2 -10
View File
@@ -10,16 +10,8 @@ export type ServiceContender = {
const stderrLimit = 8 * 1024 const stderrLimit = 8 * 1024
export function spawnServiceContender( export function spawnServiceContender(command: string, args: ReadonlyArray<string>): ServiceContender {
command: string, const child = spawn(command, args, { detached: true, stdio: ["ignore", "ignore", "pipe"] })
args: ReadonlyArray<string>,
env?: Readonly<Record<string, string>>,
): ServiceContender {
const child = spawn(command, args, {
detached: true,
stdio: ["ignore", "ignore", "pipe"],
env: { ...process.env, ...env },
})
let error: Error | undefined let error: Error | undefined
let closed = false let closed = false
let stderr = Buffer.alloc(0) let stderr = Buffer.alloc(0)
-2
View File
@@ -28,8 +28,6 @@ export type EnsureReason = "missing" | "version-mismatch"
export type EnsureOptions = DiscoverOptions & { export type EnsureOptions = DiscoverOptions & {
/** Service command and arguments. Defaults to `opencode serve --service`. */ /** Service command and arguments. Defaults to `opencode serve --service`. */
readonly command?: ReadonlyArray<string> readonly command?: ReadonlyArray<string>
/** Environment variables added to the inherited service process environment. */
readonly env?: Readonly<Record<string, string>>
/** Called once before spawning a new service process. */ /** Called once before spawning a new service process. */
readonly onStart?: (reason: EnsureReason, previousVersion?: string) => void readonly onStart?: (reason: EnsureReason, previousVersion?: string) => void
} }
+16 -8
View File
@@ -11,8 +11,6 @@ if (mode === "record-start") {
await writeFile(registration + ".started", "") await writeFile(registration + ".started", "")
process.exit(1) process.exit(1)
} }
if (mode === "environment")
await writeFile(registration + ".environment", process.env.OPENCODE_SERVICE_ENV_TEST ?? "")
if (mode === "signal") process.kill(process.pid, process.platform === "win32" ? "SIGTERM" : "SIGKILL") if (mode === "signal") process.kill(process.pid, process.platform === "win32" ? "SIGTERM" : "SIGKILL")
if (mode === "delayed" || mode === "delayed-failed" || mode === "coordinated" || mode === "coordinated-failed-loser") { if (mode === "delayed" || mode === "delayed-failed" || mode === "coordinated" || mode === "coordinated-failed-loser") {
@@ -30,7 +28,7 @@ if (mode === "delayed" || mode === "delayed-failed" || mode === "coordinated" ||
let requests = 0 let requests = 0
let version = "test" let version = "test"
if (mode === "old") version = "old" if (mode === "old" || mode === "reject-stop") version = "old"
if (mode === "incompatible") version = "1.9.0" if (mode === "incompatible") version = "1.9.0"
if (mode === "compatible" || mode === "delayed-compatible") version = "2.1.0-next.1" if (mode === "compatible" || mode === "delayed-compatible") version = "2.1.0-next.1"
const id = crypto.randomUUID() const id = crypto.randomUUID()
@@ -38,6 +36,17 @@ const server = Bun.serve({
port: 0, port: 0,
async fetch(request) { async fetch(request) {
const pathname = new URL(request.url).pathname const pathname = new URL(request.url).pathname
if (pathname === "/api/service/stop" && mode === "reject-stop") {
await appendFile(registration + ".stop-attempts", process.pid + "\n")
return Response.json({ accepted: false })
}
if (pathname === "/api/service/stop" && mode === "graceful") {
const body = await request.json()
if (typeof body !== "object" || body === null || body.instanceID !== id) return Response.json({ accepted: false })
await writeFile(registration + ".stop", JSON.stringify(body))
setTimeout(shutdown, 25)
return Response.json({ accepted: true })
}
if (pathname !== "/api/health") return new Response(null, { status: 404 }) if (pathname !== "/api/health") return new Response(null, { status: 404 })
requests += 1 requests += 1
if (mode === "starting") await writeFile(registration + ".health-request", "") if (mode === "starting") await writeFile(registration + ".health-request", "")
@@ -54,7 +63,7 @@ const server = Bun.serve({
if (mode === "starting" && !(await Bun.file(registration + ".release").exists())) if (mode === "starting" && !(await Bun.file(registration + ".release").exists()))
return Response.json({ healthy: true, version, pid: process.pid }, { status: 503 }) return Response.json({ healthy: true, version, pid: process.pid }, { status: 503 })
if (mode === "failed-owner") return Response.json({ healthy: true, version, pid: process.pid }, { status: 500 }) if (mode === "failed-owner") return Response.json({ healthy: true, version, pid: process.pid }, { status: 500 })
if (mode === "starting" || mode === "graceful") if (mode === "starting" || mode === "graceful" || mode === "reject-stop")
return Response.json({ healthy: true, version, pid: process.pid }) return Response.json({ healthy: true, version, pid: process.pid })
return Response.json({ healthy: true, version, pid: process.pid }) return Response.json({ healthy: true, version, pid: process.pid })
}, },
@@ -72,10 +81,9 @@ await writeFile(
) )
await rename(registration + ".tmp", registration) await rename(registration + ".tmp", registration)
async function shutdown(signal?: NodeJS.Signals) { function shutdown() {
if (signal !== undefined) await writeFile(registration + ".signal", signal)
server.stop(true) server.stop(true)
process.exit() process.exit()
} }
process.on("SIGTERM", () => void shutdown("SIGTERM")) process.on("SIGTERM", shutdown)
process.on("SIGINT", () => void shutdown("SIGINT")) process.on("SIGINT", shutdown)
+3 -23
View File
@@ -59,26 +59,6 @@ test("ensures a missing service with native promises", async () => {
} }
}) })
test("adds configured environment variables with native promises", async () => {
const directory = await temp()
const registration = join(directory, "service.json")
const endpoint = await ensure({
file: registration,
version: "test",
command: [process.execPath, fixture, registration, "environment"],
env: { OPENCODE_SERVICE_ENV_TEST: "configured" },
})
const info = await Bun.file(registration).json()
try {
expect(endpoint.url).toBe(info.url)
expect(await Bun.file(registration + ".environment").text()).toBe("configured")
} finally {
process.kill(info.pid, "SIGTERM")
await waitForExit(info.pid)
}
})
test("waits for a live contender when another native contender fails", async () => { test("waits for a live contender when another native contender fails", async () => {
const directory = await temp() const directory = await temp()
const registration = join(directory, "service.json") const registration = join(directory, "service.json")
@@ -146,13 +126,13 @@ test("evicts an unresponsive registered service before starting its replacement"
await waitForExit(replacement.pid) await waitForExit(replacement.pid)
}) })
test("signals the registered service process", async () => { test("requests graceful stop of the exact service instance", async () => {
const registration = await setup("graceful") const registration = await setup("graceful")
const info = await Bun.file(registration).json()
await Service.stop({ file: registration }) await Service.stop({ file: registration })
expect(await Bun.file(registration + ".signal").text()).toBe("SIGTERM") expect(await Bun.file(registration + ".stop").json()).toEqual({ instanceID: info.id })
expect(await Bun.file(registration).exists()).toBe(false)
}) })
async function setup(mode: string) { async function setup(mode: string) {
+16
View File
@@ -191,6 +191,22 @@ test("integration connections optionally submit a form answer", async () => {
expect(await requests[3].json()).toEqual({ methodID: "device" }) expect(await requests[3].json()).toEqual({ methodID: "device" })
}) })
test("health.stop sends exact replacement identity", async () => {
let request: Request | undefined
const client = OpenCode.make({
baseUrl: "http://localhost:3000",
fetch: async (input, init) => {
request = input instanceof Request ? input : new Request(input, init)
return Response.json({ accepted: true })
},
})
expect(await client.health.stop({ instanceID: "instance" })).toEqual({ accepted: true })
expect(request?.method).toBe("POST")
expect(request?.url).toBe("http://localhost:3000/api/service/stop")
expect(await request?.json()).toEqual({ instanceID: "instance" })
})
test("MCP resource catalog uses the public HTTP contract", async () => { test("MCP resource catalog uses the public HTTP contract", async () => {
let request: Request | undefined let request: Request | undefined
const client = OpenCode.make({ const client = OpenCode.make({
+29 -36
View File
@@ -68,28 +68,6 @@ test("reuses a compatible registered service", async () => {
expect(existing.exitCode).toBe(null) expect(existing.exitCode).toBe(null)
}) })
test("adds configured environment variables when starting a service", async () => {
const directory = await temp()
const registration = join(directory, "service.json")
const endpoint = await run(
ensure({
file: registration,
version: "test",
command: [process.execPath, fixture, registration, "environment"],
env: { OPENCODE_SERVICE_ENV_TEST: "configured" },
}),
)
const info = await Bun.file(registration).json()
try {
expect(endpoint.url).toBe(info.url)
expect(await Bun.file(registration + ".environment").text()).toBe("configured")
} finally {
process.kill(info.pid, "SIGTERM")
await waitForExit(info.pid)
}
})
test("replaces an incompatible registered service", async () => { test("replaces an incompatible registered service", async () => {
const directory = await temp() const directory = await temp()
const registration = join(directory, "service.json") const registration = join(directory, "service.json")
@@ -165,36 +143,40 @@ test("evicts an unresponsive registered service before starting its replacement"
await waitForExit(replacement.pid) await waitForExit(replacement.pid)
}) })
test("signals an unresponsive registered service process", async () => { test("requests graceful stop of the exact service instance", async () => {
const directory = await temp() const directory = await temp()
const registration = join(directory, "service.json") const registration = join(directory, "service.json")
const process = spawn(registration, "hanging") const process = spawn(registration, "graceful")
await waitForFile(registration) await waitForFile(registration)
const info = await Bun.file(registration).json()
await run(Service.stop({ file: registration })) await run(Service.stop({ file: registration }))
await process.exited await process.exited
expect(await Bun.file(registration + ".signal").text()).toBe("SIGTERM") expect(await Bun.file(registration + ".stop").json()).toEqual({ instanceID: info.id })
expect(await Bun.file(registration).exists()).toBe(false)
}) })
test("signals an incompatible service before starting its replacement", async () => { test("does not spawn contenders while an incompatible service rejects replacement", async () => {
const directory = await temp() const directory = await temp()
const registration = join(directory, "service.json") const registration = join(directory, "service.json")
const existing = spawn(registration, "old") const contender = join(directory, "contender.json")
const existing = spawn(registration, "reject-stop")
await waitForFile(registration) await waitForFile(registration)
const endpoint = await run( const controller = new AbortController()
const starting = Effect.runPromise(
ensure({ ensure({
file: registration, file: registration,
version: "test", version: "test",
command: [process.execPath, fixture, registration, "delayed", "10"], command: [process.execPath, fixture, contender, "record-start"],
}), }).pipe(Effect.provide(NodeFileSystem.layer)),
{ signal: controller.signal },
) )
const replacement = await Bun.file(registration).json()
expect(await existing.exited).toBe(0) await waitForLines(registration + ".stop-attempts", 2)
expect(endpoint.url).toBe(replacement.url) controller.abort()
process.kill(replacement.pid, "SIGTERM") await starting.catch(() => undefined)
await waitForExit(replacement.pid)
expect(await Bun.file(contender + ".started").exists()).toBe(false)
expect(existing.exitCode).toBe(null)
}) })
test("a legacy health response is still replaced", async () => { test("a legacy health response is still replaced", async () => {
@@ -362,6 +344,17 @@ async function waitForFile(file: string) {
throw new Error(`Timed out waiting for ${file}`) throw new Error(`Timed out waiting for ${file}`)
} }
async function waitForLines(file: string, count: number) {
for (let attempt = 0; attempt < 600; attempt++) {
const text = await Bun.file(file)
.text()
.catch(() => "")
if (text.trim().split("\n").length >= count) return
await Bun.sleep(5)
}
throw new Error(`Timed out waiting for ${count} lines in ${file}`)
}
async function health(url: string) { async function health(url: string) {
return fetch(new URL("/api/health", url), { signal: AbortSignal.timeout(1_000) }).then((response) => response.json()) return fetch(new URL("/api/health", url), { signal: AbortSignal.timeout(1_000) }).then((response) => response.json())
} }
+1 -1
View File
@@ -109,7 +109,7 @@ const layer = Layer.effect(
get: Effect.fn("Agent.get")(function* (id) { get: Effect.fn("Agent.get")(function* (id) {
return state.get().agents.get(id) return state.get().agents.get(id)
}), }),
resolve: Effect.fnUntraced(function* (id) { resolve: Effect.fn("Agent.resolve")(function* (id) {
if (id !== undefined) return state.get().agents.get(ID.make(id)) if (id !== undefined) return state.get().agents.get(ID.make(id))
return selectedDefault() return selectedDefault()
}), }),
+1 -1
View File
@@ -426,7 +426,7 @@ export const layer = (options?: Options) =>
) )
return Service.of({ return Service.of({
entries: Effect.fnUntraced(function* () { entries: Effect.fn("Config.entries")(function* () {
return configs return configs
}), }),
update, update,
+1 -1
View File
@@ -149,7 +149,7 @@ export function normalize(input: unknown): Result {
"agents", "agents",
migratedAgents, migratedAgents,
nativeAgents, nativeAgents,
migratedSmallModel !== undefined || isRecord(input.agent) || isRecord(input.mode) || isRecord(input.agents), isRecord(input.agent) || isRecord(input.mode) || isRecord(input.agents),
diagnostics, diagnostics,
) )
@@ -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 }),
})
}
})
}),
})
@@ -120,13 +120,9 @@ export class EffectSQLiteSession<TRelations extends AnyRelations> extends SQLite
private execute(query: Query, params: unknown[], method: SQLiteExecuteMethod | "values") { private execute(query: Query, params: unknown[], method: SQLiteExecuteMethod | "values") {
const statement = this.client.unsafe(query.sql, params) const statement = this.client.unsafe(query.sql, params)
if (method === "values") return statement.values.pipe(Effect.withTracerEnabled(false)) if (method === "values") return statement.values
if (method === "get") if (method === "get") return statement.withoutTransform.pipe(Effect.map((rows) => rows[0]))
return statement.withoutTransform.pipe( return statement.withoutTransform
Effect.map((rows) => rows[0]),
Effect.withTracerEnabled(false),
)
return statement.withoutTransform.pipe(Effect.withTracerEnabled(false))
} }
private isInTransaction() { private isInTransaction() {
+58 -34
View File
@@ -4,21 +4,15 @@ import { Context, Effect, Layer } from "effect"
import { ChildProcess } from "effect/unstable/process" import { ChildProcess } from "effect/unstable/process"
import path from "path" import path from "path"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node" 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 { AppProcess } from "@opencode-ai/util/process"
import { Global } from "@opencode-ai/util/global"
import { Config } from "./config.js"
import { Location } from "./location.js" import { Location } from "./location.js"
import type { Info } from "./formatter/builtins.js" import { make, type Info } from "./formatter/builtins.js"
import { State } from "./state.js"
type Data = { export interface Interface {
formatters: Info[]
}
export type Draft = {
set: (formatter: Info) => void
remove: (name: string) => void
}
export interface Interface extends State.Transformable<Draft> {
readonly file: (filepath: string) => Effect.Effect<boolean> 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( const layer = Layer.effect(
Service, Service,
Effect.gen(function* () { Effect.gen(function* () {
const config = yield* Config.Service
const fs = yield* FSUtil.Service
const location = yield* Location.Service const location = yield* Location.Service
const npm = yield* Npm.Service
const processes = yield* AppProcess.Service const processes = yield* AppProcess.Service
const commands = new WeakMap<Info, string[] | false>() const global = yield* Global.Service
const state = State.create<Data, Draft>({ const commands = new Map<string, string[] | false>()
name: "formatter", let formatters: Info[] = []
initial: () => ({ formatters: [] }),
draft: (draft) => ({ const load = yield* Effect.cached(
set: (formatter) => { Effect.gen(function* () {
const index = draft.formatters.findIndex((item) => item.name === formatter.name) const configured = Config.latest(yield* config.entries(), "formatter")
if (index === -1) draft.formatters.push(formatter) if (!configured) {
else draft.formatters[index] = formatter yield* Effect.logInfo("all formatters are disabled")
}, return
remove: (name) => { }
draft.formatters = draft.formatters.filter((formatter) => formatter.name !== name)
}, 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 command = Effect.fnUntraced(function* (formatter: Info) {
const cached = commands.get(formatter) const cached = commands.get(formatter.name)
if (cached !== undefined) return cached if (cached !== undefined) return cached
const result = yield* formatter.enabled const result = yield* formatter.enabled
if (result !== false) commands.set(formatter, result) if (result !== false) commands.set(formatter.name, result)
return result return result
}) })
const file = Effect.fn("Formatter.file")(function* (filepath: string) { const file = Effect.fn("Formatter.file")(function* (filepath: string) {
const matching = state yield* load
.get() const matching = formatters.filter((formatter) => formatter.extensions.includes(path.extname(filepath)))
.formatters.filter((formatter) => formatter.extensions.includes(path.extname(filepath)))
for (const formatter of matching) { for (const formatter of matching) {
const enabled = yield* command(formatter) const enabled = yield* command(formatter)
@@ -94,12 +118,12 @@ const layer = Layer.effect(
return false return false
}) })
return Service.of({ transform: state.transform, reload: state.reload, file }) return Service.of({ file })
}), }),
) )
export const node = makeLocationNode({ export const node = makeLocationNode({
service: Service, service: Service,
layer, 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 { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { Context, Effect, Layer, Schema } from "effect" import { Context, Effect, Layer, Schema } from "effect"
import { Config } from "./config.js"
import { FileSystem } from "./filesystem.js" import { FileSystem } from "./filesystem.js"
import { State } from "./state.js"
export class ResizerUnavailableError extends Schema.TaggedError<ResizerUnavailableError>()( export class ResizerUnavailableError extends Schema.TaggedError<ResizerUnavailableError>()(
"Image.ResizerUnavailableError", "Image.ResizerUnavailableError",
@@ -32,18 +32,7 @@ export class SizeError extends Schema.TaggedError<SizeError>()("Image.SizeError"
} }
} }
export type Limits = { export interface Interface {
autoResize: boolean
maxWidth: number
maxHeight: number
maxBase64Bytes: number
}
export type Draft = {
configure: (limits: Partial<Limits>) => void
}
export interface Interface extends State.Transformable<Draft> {
readonly normalize: ( readonly normalize: (
resource: string, resource: string,
content: FileSystem.Content & { readonly encoding: "base64" }, content: FileSystem.Content & { readonly encoding: "base64" },
@@ -58,23 +47,7 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/Im
const layer = Layer.effect( const layer = Layer.effect(
Service, Service,
Effect.gen(function* () { Effect.gen(function* () {
const state = State.create<Limits, Draft>({ const config = yield* Config.Service
name: "image",
initial: () => ({
autoResize: true,
maxWidth: 2_000,
maxHeight: 2_000,
maxBase64Bytes: 5 * 1024 * 1024,
}),
draft: (draft) => ({
configure: (limits) => {
if (limits.autoResize !== undefined) draft.autoResize = limits.autoResize
if (limits.maxWidth !== undefined) draft.maxWidth = limits.maxWidth
if (limits.maxHeight !== undefined) draft.maxHeight = limits.maxHeight
if (limits.maxBase64Bytes !== undefined) draft.maxBase64Bytes = limits.maxBase64Bytes
},
}),
})
const loadAdapter = yield* Effect.cached( const loadAdapter = yield* Effect.cached(
Effect.tryPromise({ Effect.tryPromise({
try: () => import("./image/photon.js"), try: () => import("./image/photon.js"),
@@ -85,11 +58,22 @@ const layer = Layer.effect(
resource: string, resource: string,
content: FileSystem.Content & { readonly encoding: "base64" }, 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 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] })
+5 -5
View File
@@ -138,7 +138,7 @@ export const make = Effect.gen(function* () {
scope: yield* Scope.Scope, scope: yield* Scope.Scope,
} }
const settle = Effect.fnUntraced(function* (id: string, token: object, exit: Exit.Exit<string, unknown>) { const settle = Effect.fn("Job.settle")(function* (id: string, token: object, exit: Exit.Exit<string, unknown>) {
const completed_at = yield* Clock.currentTimeMillis const completed_at = yield* Clock.currentTimeMillis
const result = yield* SynchronizedRef.modify(state.jobs, (jobs): readonly [FinishResult, Map<string, Active>] => { const result = yield* SynchronizedRef.modify(state.jobs, (jobs): readonly [FinishResult, Map<string, Active>] => {
const job = jobs.get(id) const job = jobs.get(id)
@@ -170,7 +170,7 @@ export const make = Effect.gen(function* () {
return result.info return result.info
}) })
const fork = Effect.fnUntraced(function* ( const fork = Effect.fn("Job.fork")(function* (
scope: Scope.Scope, scope: Scope.Scope,
id: string, id: string,
token: object, token: object,
@@ -192,7 +192,7 @@ export const make = Effect.gen(function* () {
return snapshot(job) return snapshot(job)
}) })
const start: Interface["start"] = Effect.fnUntraced(function* (input) { const start: Interface["start"] = Effect.fn("Job.start")(function* (input) {
return yield* Effect.uninterruptibleMask((restore) => return yield* Effect.uninterruptibleMask((restore) =>
Effect.gen(function* () { Effect.gen(function* () {
const id = input.id ?? Identifier.ascending("job") const id = input.id ?? Identifier.ascending("job")
@@ -247,7 +247,7 @@ export const make = Effect.gen(function* () {
return { info: snapshot(job), timedOut: true } return { info: snapshot(job), timedOut: true }
}) })
const removeBlock = Effect.fnUntraced(function* (input: BlockInput) { const removeBlock = Effect.fn("Job.removeBlock")(function* (input: BlockInput) {
yield* SynchronizedRef.update(state.jobs, (jobs) => { yield* SynchronizedRef.update(state.jobs, (jobs) => {
const job = jobs.get(input.id) const job = jobs.get(input.id)
if (!job || job.info.status !== "running" || job.isBackgrounded) return jobs if (!job || job.info.status !== "running" || job.isBackgrounded) return jobs
@@ -258,7 +258,7 @@ export const make = Effect.gen(function* () {
}) })
}) })
const block: Interface["block"] = Effect.fnUntraced(function* (input) { const block: Interface["block"] = Effect.fn("Job.block")(function* (input) {
const result = yield* SynchronizedRef.modify(state.jobs, (jobs): readonly [BlockStart, Map<string, Active>] => { const result = yield* SynchronizedRef.modify(state.jobs, (jobs): readonly [BlockStart, Map<string, Active>] => {
const job = jobs.get(input.id) const job = jobs.get(input.id)
if (!job) return [{ type: "missing" }, jobs] if (!job) return [{ type: "missing" }, jobs]
+1 -1
View File
@@ -65,7 +65,7 @@ const layer = Layer.effect(
const fs = yield* FSUtil.Service const fs = yield* FSUtil.Service
const location = yield* Location.Service const location = yield* Location.Service
const resolve = Effect.fnUntraced(function* (input: ResolveInput) { const resolve = Effect.fn("LocationMutation.resolve")(function* (input: ResolveInput) {
const absolute = path.resolve(location.directory, input.path) const absolute = path.resolve(location.directory, input.path)
if (FSUtil.contains(location.directory, absolute)) { if (FSUtil.contains(location.directory, absolute)) {
return { return {
+2 -2
View File
@@ -152,14 +152,14 @@ const layer = Layer.effect(
) )
}) })
const configured = Effect.fnUntraced(function* (sessionID: SessionSchema.ID, agentID?: Agent.ID) { const configured = Effect.fn("Permission.configured")(function* (sessionID: SessionSchema.ID, agentID?: Agent.ID) {
const session = yield* sessions.get(sessionID) const session = yield* sessions.get(sessionID)
if (!session) return yield* new SessionErrors.NotFoundError({ sessionID }) if (!session) return yield* new SessionErrors.NotFoundError({ sessionID })
const agent = yield* agents.resolve(agentID ?? session.agent) const agent = yield* agents.resolve(agentID ?? session.agent)
return agent?.permissions ?? missingAgentPermissions return agent?.permissions ?? missingAgentPermissions
}) })
const allowsAll = Effect.fnUntraced(function* (input: { const allowsAll = Effect.fn("Permission.allowsAll")(function* (input: {
readonly sessionID: SessionSchema.ID readonly sessionID: SessionSchema.ID
readonly action: string readonly action: string
readonly agent?: Agent.ID readonly agent?: Agent.ID
+1 -1
View File
@@ -39,7 +39,7 @@ const layer = Layer.effect(
Effect.gen(function* () { Effect.gen(function* () {
const { db } = yield* Database.Service const { db } = yield* Database.Service
const list = Effect.fnUntraced(function* (input?: ListInput) { const list = Effect.fn("PermissionSaved.list")(function* (input?: ListInput) {
const rows = yield* db const rows = yield* db
.select() .select()
.from(PermissionTable) .from(PermissionTable)
+13 -47
View File
@@ -1,10 +1,10 @@
export * as Plugin from "./plugin.js" export * as Plugin from "./plugin.js"
export { Event, ID, Info, Source } from "@opencode-ai/schema/plugin" export { Event, ID, Info } from "@opencode-ai/schema/plugin"
import { Plugin } from "@opencode-ai/schema/plugin" import { Plugin } from "@opencode-ai/schema/plugin"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node" import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { App } from "./app.js" import { App } from "./app.js"
import { Cause, Context, Effect, Exit, Layer, Logger, References, Scope, Semaphore } from "effect" import { Context, Effect, Exit, Layer, Logger, References, Scope, Semaphore } from "effect"
import { Agent } from "./agent.js" import { Agent } from "./agent.js"
import { AISDK } from "./aisdk.js" import { AISDK } from "./aisdk.js"
import { Catalog } from "./catalog.js" import { Catalog } from "./catalog.js"
@@ -23,17 +23,11 @@ import { Tool } from "./tool.js"
import { PluginHooks } from "./plugin/hooks.js" import { PluginHooks } from "./plugin/hooks.js"
export interface Interface { export interface Interface {
readonly activate: ( readonly activate: (plugins: readonly Versioned[]) => Effect.Effect<void>
plugins: readonly Versioned[],
failures?: readonly Extract<Plugin.Info, { readonly status: "failed" }>[],
) => Effect.Effect<void>
readonly list: () => Effect.Effect<Plugin.Info[]> readonly list: () => Effect.Effect<Plugin.Info[]>
} }
export type Versioned = import("@opencode-ai/plugin/effect/plugin").Plugin & { export type Versioned = import("@opencode-ai/plugin/effect/plugin").Plugin & { readonly version: string }
readonly version: string
readonly source?: Plugin.Source
}
export class Service extends Context.Service<Service, Interface>()("@opencode/Plugin") {} export class Service extends Context.Service<Service, Interface>()("@opencode/Plugin") {}
@@ -44,7 +38,6 @@ const layer = Layer.effect(
const scope = yield* Scope.make() const scope = yield* Scope.make()
const active = new Map<Plugin.ID, { readonly plugin: Versioned; readonly scope: Scope.Closeable }>() const active = new Map<Plugin.ID, { readonly plugin: Versioned; readonly scope: Scope.Closeable }>()
const lock = Semaphore.makeUnsafe(1) const lock = Semaphore.makeUnsafe(1)
let inventory: Plugin.Info[] = []
let host: Parameters<import("@opencode-ai/plugin/effect/plugin").Plugin["effect"]>[0] let host: Parameters<import("@opencode-ai/plugin/effect/plugin").Plugin["effect"]>[0]
const load = Effect.fnUntraced(function* (plugin: Versioned) { const load = Effect.fnUntraced(function* (plugin: Versioned) {
@@ -63,18 +56,15 @@ const layer = Layer.effect(
Effect.onExit((exit) => (Exit.isFailure(exit) ? Scope.close(child, exit) : Effect.void)), Effect.onExit((exit) => (Exit.isFailure(exit) ? Scope.close(child, exit) : Effect.void)),
Effect.exit, Effect.exit,
) )
if (Exit.isSuccess(loaded)) return { scope: child } as const if (Exit.isSuccess(loaded)) return child
yield* Effect.logWarning("failed to load plugin", { yield* Effect.logWarning("failed to load plugin", {
"plugin.id": plugin.id, "plugin.id": plugin.id,
cause: loaded.cause, cause: loaded.cause,
}) })
return { error: Cause.pretty(loaded.cause) } as const return undefined
}) })
const activate = Effect.fn("Plugin.activate")(function* ( const activate = Effect.fn("Plugin.activate")(function* (plugins: readonly Versioned[]) {
plugins: readonly Versioned[],
failures: readonly Extract<Plugin.Info, { readonly status: "failed" }>[] = [],
) {
const definitions = plugins.map((plugin) => ({ ...plugin, id: Plugin.ID.make(plugin.id) })) const definitions = plugins.map((plugin) => ({ ...plugin, id: Plugin.ID.make(plugin.id) }))
const ids = new Set<Plugin.ID>() const ids = new Set<Plugin.ID>()
for (const definition of definitions) { for (const definition of definitions) {
@@ -95,40 +85,26 @@ const layer = Layer.effect(
const candidate = next[index] const candidate = next[index]
return definition.id === candidate?.id && definition.version === candidate.version return definition.id === candidate?.id && definition.version === candidate.version
}) })
) { )
const nextInventory = [...Array.from(active.values(), (entry) => activeInfo(entry.plugin)), ...failures]
if (JSON.stringify(inventory) === JSON.stringify(nextInventory)) return
inventory = nextInventory
yield* bus.publish(Plugin.Event.Updated, {})
return return
}
yield* State.batch( yield* State.batch(
Effect.gen(function* () { Effect.gen(function* () {
const nextInventory: Plugin.Info[] = []
for (const definition of definitions) { for (const definition of definitions) {
const previous = active.get(definition.id) const previous = active.get(definition.id)
active.delete(definition.id) active.delete(definition.id)
if (previous) yield* Scope.close(previous.scope, Exit.void).pipe(Effect.ignore) if (previous) yield* Scope.close(previous.scope, Exit.void).pipe(Effect.ignore)
const loaded = yield* load(definition) const loaded = yield* load(definition)
if (loaded.scope !== undefined) { if (loaded) {
active.set(definition.id, { plugin: definition, scope: loaded.scope }) active.set(definition.id, { plugin: definition, scope: loaded })
nextInventory.push(activeInfo(definition))
continue continue
} }
nextInventory.push({
id: definition.id,
source: definition.source ?? { type: "builtin" },
status: "failed",
error: loaded.error,
tui: definition.tui ?? false,
})
if (!previous) continue if (!previous) continue
const restored = yield* load(previous.plugin) const restored = yield* load(previous.plugin)
if (restored.scope !== undefined) { if (restored) {
active.set(definition.id, { plugin: previous.plugin, scope: restored.scope }) active.set(definition.id, { plugin: previous.plugin, scope: restored })
continue continue
} }
yield* Effect.logError("failed to restore plugin; deactivating", { yield* Effect.logError("failed to restore plugin; deactivating", {
@@ -143,7 +119,6 @@ const layer = Layer.effect(
yield* Effect.forEach(removed, ([, entry]) => Scope.close(entry.scope, Exit.void).pipe(Effect.ignore), { yield* Effect.forEach(removed, ([, entry]) => Scope.close(entry.scope, Exit.void).pipe(Effect.ignore), {
discard: true, discard: true,
}) })
inventory = [...nextInventory, ...failures]
}), }),
) )
yield* bus.publish(Plugin.Event.Updated, {}) yield* bus.publish(Plugin.Event.Updated, {})
@@ -161,7 +136,7 @@ const layer = Layer.effect(
const service = Service.of({ const service = Service.of({
activate, activate,
list: Effect.fn("Plugin.list")(function* () { list: Effect.fn("Plugin.list")(function* () {
return inventory return Array.from(active.keys()).map((id) => ({ id }))
}), }),
}) })
host = yield* PluginHost.make(service) host = yield* PluginHost.make(service)
@@ -169,15 +144,6 @@ const layer = Layer.effect(
}), }),
) )
function activeInfo(plugin: Versioned): Plugin.Info {
return {
id: Plugin.ID.make(plugin.id),
source: plugin.source ?? { type: "builtin" },
status: "active",
tui: plugin.tui ?? false,
}
}
export const node = makeLocationNode({ export const node = makeLocationNode({
service: Service, service: Service,
layer, layer,
+22 -44
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 { SessionHooks } from "@opencode-ai/plugin/effect/session"
import type { ShellHooks } from "@opencode-ai/plugin/effect/shell" import type { ShellHooks } from "@opencode-ai/plugin/effect/shell"
import type { ToolFailures, ToolHooks } from "@opencode-ai/plugin/effect/tool" 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 { Context, Effect, Layer, Scope } from "effect"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node" import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { State } from "../state.js" 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 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 { export interface Interface {
readonly has: <Domain extends keyof Domains>( readonly has: <Domain extends keyof Domains>(
domain: Domain, domain: Domain,
name: keyof Domains[Domain] & keyof Failures[Domain], name: keyof Domains[Domain] & keyof Failures[Domain],
providerID?: string,
) => Effect.Effect<boolean> ) => Effect.Effect<boolean>
readonly register: <Domain extends keyof Domains, Name extends keyof Domains[Domain] & keyof Failures[Domain]>( readonly register: <Domain extends keyof Domains, Name extends keyof Domains[Domain] & keyof Failures[Domain]>(
domain: Domain, domain: Domain,
name: Name, name: Name,
callback: Callback<Domains[Domain][Name], Failures[Domain][Name]>, callback: Callback<Domains[Domain][Name], Failures[Domain][Name]>,
options?: ModelHookOptions,
) => Effect.Effect<State.Registration, never, Scope.Scope> ) => Effect.Effect<State.Registration, never, Scope.Scope>
readonly trigger: <Domain extends keyof Domains, Name extends keyof Domains[Domain] & keyof Failures[Domain]>( readonly trigger: <Domain extends keyof Domains, Name extends keyof Domains[Domain] & keyof Failures[Domain]>(
domain: Domain, domain: Domain,
@@ -60,47 +49,36 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/Pl
const layer = Layer.effect( const layer = Layer.effect(
Service, Service,
Effect.gen(function* () { 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 key = (domain: keyof Domains, name: PropertyKey) => `${domain}.${String(name)}`
const register: Interface["register"] = Effect.fn("PluginHooks.register")( const register: Interface["register"] = Effect.fn("PluginHooks.register")(function* (domain, name, callback) {
function* (domain, name, callback, options) { const scope = yield* Scope.Scope
const scope = yield* Scope.Scope const id = key(domain, name)
const id = key(domain, name) let active = true
let active = true callbacks.set(id, [...(callbacks.get(id) ?? []), callback])
const entry = { callback, options } const dispose = Effect.sync(() => {
callbacks.set(id, [...(callbacks.get(id) ?? []), entry]) if (!active) return
const dispose = Effect.sync(() => { active = false
if (!active) return const next = (callbacks.get(id) ?? []).filter((item) => item !== callback)
active = false if (next.length === 0) callbacks.delete(id)
const next = (callbacks.get(id) ?? []).filter((item) => item !== entry) else callbacks.set(id, next)
if (next.length === 0) callbacks.delete(id) })
else callbacks.set(id, next) yield* Scope.addFinalizer(scope, dispose)
}) return { dispose }
yield* Scope.addFinalizer(scope, dispose) })
return { dispose }
},
)
const trigger: Interface["trigger"] = Effect.fnUntraced(function* (domain, name, event) { const trigger: Interface["trigger"] = Effect.fn("PluginHooks.trigger")(function* (domain, name, event) {
for (const entry of callbacks.get(key(domain, name)) ?? []) { for (const callback 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(callback, undefined, [
const result: Effect.Effect<void, Failures[typeof domain][typeof name]> = Reflect.apply( event,
entry.callback, ])
undefined,
[event],
)
yield* result yield* result
} }
return event return event
}) })
const has: Interface["has"] = (domain, name, providerID) => const has: Interface["has"] = (domain, name) => Effect.sync(() => callbacks.has(key(domain, name)))
Effect.sync(() =>
(callbacks.get(key(domain, name)) ?? []).some(
(entry) => entry.options?.providerID === undefined || entry.options.providerID === providerID,
),
)
return Service.of({ has, register, trigger }) 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: { aisdk: {
hook: (name, callback, options) => { hook: (name, callback) => {
if (name === "sdk") { if (name === "sdk") {
return aisdk.hook.sdk((event) => { return aisdk.hook.sdk((event) => {
if (options?.providerID !== undefined && options.providerID !== event.model.providerID) return Effect.void
const output = { const output = {
model: mutable(event.model), model: mutable(event.model),
package: event.package, package: event.package,
@@ -120,7 +119,6 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: import("../p
}) })
} }
return aisdk.hook.language((event) => { return aisdk.hook.language((event) => {
if (options?.providerID !== undefined && options.providerID !== event.model.providerID) return Effect.void
const output = { const output = {
model: mutable(event.model), model: mutable(event.model),
options: event.options, options: event.options,
@@ -384,7 +382,7 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: import("../p
}), }),
}, },
session: { session: {
hook: (name, callback, options) => hooks.register("session", name, callback, options), hook: (name, callback) => hooks.register("session", name, callback),
create: (input) => create: (input) =>
runtime.session.create({ runtime.session.create({
id: input?.id, 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 type { Plugin } from "@opencode-ai/plugin/effect/plugin"
import { LayerNode } from "@opencode-ai/util/effect/layer-node" import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { httpClient } from "@opencode-ai/util/effect/app-node-platform" import { httpClient } from "@opencode-ai/util/effect/app-node-platform"
import { AppProcess } from "@opencode-ai/util/process"
import { Context, Effect, Scope } from "effect" import { Context, Effect, Scope } from "effect"
import { HttpClient } from "effect/unstable/http" import { HttpClient } from "effect/unstable/http"
import { Agent } from "../agent.js" import { Agent } from "../agent.js"
@@ -13,8 +12,6 @@ import { Config } from "../config.js"
import { Credential } from "../credential.js" import { Credential } from "../credential.js"
import { ConfigAgentPlugin } from "../config/plugin/agent.js" import { ConfigAgentPlugin } from "../config/plugin/agent.js"
import { ConfigCommandPlugin } from "../config/plugin/command.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 { ConfigInstructionPlugin } from "../config/plugin/instruction.js"
import { ConfigMCPPlugin } from "../config/plugin/mcp.js" import { ConfigMCPPlugin } from "../config/plugin/mcp.js"
import { ConfigProviderPlugin } from "../config/plugin/provider.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 services = Effect.fn("PluginInternal.services")(function* () {
const agent = yield* Agent.Service const agent = yield* Agent.Service
const processes = yield* AppProcess.Service
const catalog = yield* Catalog.Service const catalog = yield* Catalog.Service
const command = yield* Command.Service const command = yield* Command.Service
const config = yield* Config.Service const config = yield* Config.Service
@@ -119,7 +115,6 @@ const services = Effect.fn("PluginInternal.services")(function* () {
const wellknown = yield* WellKnown.Service const wellknown = yield* WellKnown.Service
return Context.mergeAll( return Context.mergeAll(
Context.make(Agent.Service, agent), Context.make(Agent.Service, agent),
Context.make(AppProcess.Service, processes),
Context.make(Catalog.Service, catalog), Context.make(Catalog.Service, catalog),
Context.make(Command.Service, command), Context.make(Command.Service, command),
Context.make(Config.Service, config), Context.make(Config.Service, config),
@@ -165,7 +160,6 @@ export type Requirements = ContextServices<Effect.Success<ReturnType<typeof serv
export const requirements = LayerNode.group([ export const requirements = LayerNode.group([
Agent.node, Agent.node,
AppProcess.node,
Catalog.node, Catalog.node,
Command.node, Command.node,
Config.node, Config.node,
@@ -238,8 +232,6 @@ const post = [
ConfigReferencePlugin.Plugin, ConfigReferencePlugin.Plugin,
ConfigAgentPlugin.Plugin, ConfigAgentPlugin.Plugin,
ConfigCommandPlugin.Plugin, ConfigCommandPlugin.Plugin,
ConfigFormatterPlugin.Plugin,
ConfigImagePlugin.Plugin,
ConfigSkillPlugin.Plugin, ConfigSkillPlugin.Plugin,
ConfigProviderPlugin.Plugin, ConfigProviderPlugin.Plugin,
ConfigWebSearchPlugin.Plugin, ConfigWebSearchPlugin.Plugin,
@@ -7,7 +7,7 @@ import { Effect } from "effect"
const urls = [/^https:\/\/mcp\.cloudflare\.com\/mcp$/, /^https:\/\/executor\.sh\/[^/]+\/mcp$/] const urls = [/^https:\/\/mcp\.cloudflare\.com\/mcp$/, /^https:\/\/executor\.sh\/[^/]+\/mcp$/]
export const Plugin = define({ export const Plugin = define({
id: "opencode.mcp.codemode.exclusion", id: "opencode.mcp.codemode-exclusion",
effect: Effect.fn(function* (ctx) { effect: Effect.fn(function* (ctx) {
yield* ctx.mcp.transform((draft) => { yield* ctx.mcp.transform((draft) => {
for (const [, server] of draft.list()) { for (const [, server] of draft.list()) {
+3 -8
View File
@@ -6,7 +6,7 @@ import { ModelsDev } from "../models-dev.js"
import { Provider } from "../provider.js" import { Provider } from "../provider.js"
export const ModelsDevPlugin = define({ export const ModelsDevPlugin = define({
id: "opencode.models.dev", id: "opencode.models-dev",
effect: Effect.fn(function* (ctx) { effect: Effect.fn(function* (ctx) {
const modelsDev = yield* ModelsDev.Service const modelsDev = yield* ModelsDev.Service
const bus = yield* Bus.Service const bus = yield* Bus.Service
@@ -55,13 +55,8 @@ export const ModelsDevPlugin = define({
}) })
function environmentNames(provider: ModelsDev.Snapshot) { function environmentNames(provider: ModelsDev.Snapshot) {
if (provider.info.id === Provider.ID.azure) if (provider.info.id !== Provider.ID.azure) return [...provider.environment]
return [...provider.environment.filter((name) => name.endsWith("_API_KEY")), "AZURE_COGNITIVE_SERVICES_API_KEY"] return [...provider.environment.filter((name) => name.endsWith("_API_KEY")), "AZURE_COGNITIVE_SERVICES_API_KEY"]
// models.dev advertises project, location, and the ADC credentials file path for
// Vertex. Those configure Google auth rather than carrying a key, so only the
// Express Mode key may become a credential; GoogleVertexPlugin handles activation.
if (provider.info.id === Provider.ID.googleVertex) return ["GOOGLE_VERTEX_API_KEY"]
return [...provider.environment]
} }
function snapshots(data: readonly ModelsDev.Snapshot[]) { function snapshots(data: readonly ModelsDev.Snapshot[]) {
@@ -60,7 +60,7 @@ function selectMantleModel(sdk: MantleSDK, modelID: string) {
} }
export const AmazonBedrockPlugin = define({ export const AmazonBedrockPlugin = define({
id: "opencode.provider.amazon.bedrock", id: "opencode.provider.amazon-bedrock",
effect: Effect.fn(function* (ctx) { effect: Effect.fn(function* (ctx) {
yield* ctx.catalog.transform((evt) => { yield* ctx.catalog.transform((evt) => {
for (const item of evt.provider.list()) { for (const item of evt.provider.list()) {
@@ -10,7 +10,7 @@ import { configuredSettings } from "./configured.js"
const providerID = Provider.ID.make("cloudflare-ai-gateway") const providerID = Provider.ID.make("cloudflare-ai-gateway")
export const CloudflareAIGatewayPlugin = define({ export const CloudflareAIGatewayPlugin = define({
id: "opencode.provider.cloudflare.ai.gateway", id: "opencode.provider.cloudflare-ai-gateway",
effect: Effect.fn(function* (ctx) { effect: Effect.fn(function* (ctx) {
const configured = yield* configuredSettings(providerID) const configured = yield* configuredSettings(providerID)
const form = iife(() => { const form = iife(() => {
@@ -10,7 +10,7 @@ import { configuredSettings } from "./configured.js"
const providerID = Provider.ID.make("cloudflare-workers-ai") const providerID = Provider.ID.make("cloudflare-workers-ai")
export const CloudflareWorkersAIPlugin = define({ export const CloudflareWorkersAIPlugin = define({
id: "opencode.provider.cloudflare.workers.ai", id: "opencode.provider.cloudflare-workers-ai",
effect: Effect.fn(function* (ctx) { effect: Effect.fn(function* (ctx) {
const configured = yield* configuredSettings(providerID) const configured = yield* configuredSettings(providerID)
const form = iife(() => { const form = iife(() => {
@@ -146,7 +146,7 @@ const oauth = (app: App.Info) =>
}) satisfies IntegrationOAuthMethodRegistration }) satisfies IntegrationOAuthMethodRegistration
export const GithubCopilotPlugin = define({ export const GithubCopilotPlugin = define({
id: "opencode.provider.github.copilot", id: "opencode.provider.github-copilot",
effect: Effect.fn(function* (ctx) { effect: Effect.fn(function* (ctx) {
const catalog = yield* Catalog.Service const catalog = yield* Catalog.Service
const bus = yield* Bus.Service const bus = yield* Bus.Service
@@ -241,22 +241,19 @@ export const GithubCopilotPlugin = define({
evt.sdk = mod.createOpenaiCompatible(evt.options) evt.sdk = mod.createOpenaiCompatible(evt.options)
}), }),
) )
yield* ctx.session.hook( yield* ctx.session.hook("http.request", (evt) =>
"http.request", Effect.gen(function* () {
(evt) => if (evt.model.providerID !== Provider.ID.githubCopilot) return
Effect.gen(function* () { if (evt.agent === Agent.ID.make("title"))
if (evt.model.providerID !== Provider.ID.githubCopilot) return evt.request.headers.set("X-Interaction-Type", "conversation-background")
if (evt.agent === Agent.ID.make("title")) if (evt.agent === Agent.ID.make("compaction"))
evt.request.headers.set("X-Interaction-Type", "conversation-background") evt.request.headers.set("X-Interaction-Type", "conversation-compaction")
if (evt.agent === Agent.ID.make("compaction")) const token = evt.request.headers.get("x-api-key")
evt.request.headers.set("X-Interaction-Type", "conversation-compaction") if (!token) return
const token = evt.request.headers.get("x-api-key") const text = yield* Effect.promise(() => evt.request.clone().text())
if (!token) return const body = Option.getOrUndefined(decodeBody(text))
const text = yield* Effect.promise(() => evt.request.clone().text()) applyHeaders(evt.request.headers, token, ctx.app, requestMetadata(evt.request.url, body), true)
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.aisdk.hook( yield* ctx.aisdk.hook(
"language", "language",
@@ -55,7 +55,7 @@ function authFetch(fetchWithRuntimeOptions?: unknown) {
} }
export const GoogleVertexPlugin = define({ export const GoogleVertexPlugin = define({
id: "opencode.provider.google.vertex", id: "opencode.provider.google-vertex",
effect: Effect.fn(function* (ctx) { effect: Effect.fn(function* (ctx) {
yield* ctx.catalog.transform((evt) => { yield* ctx.catalog.transform((evt) => {
for (const item of evt.provider.list()) { for (const item of evt.provider.list()) {
@@ -71,9 +71,6 @@ export const GoogleVertexPlugin = define({
const project = resolveProject(item.provider.settings ?? {}) const project = resolveProject(item.provider.settings ?? {})
const location = String(resolveLocation(item.provider.settings ?? {})) const location = String(resolveLocation(item.provider.settings ?? {}))
evt.provider.update(item.provider.id, (provider) => { evt.provider.update(item.provider.id, (provider) => {
// Vertex authenticates through ADC rather than a key credential, so a
// resolvable project is what makes the provider usable.
if (project && provider.activation === "auto") provider.activation = "enabled"
provider.settings = { provider.settings = {
...provider.settings, ...provider.settings,
...(project ? { project } : {}), ...(project ? { project } : {}),
@@ -2,7 +2,7 @@ import { Effect } from "effect"
import { define } from "@opencode-ai/plugin/effect/plugin" import { define } from "@opencode-ai/plugin/effect/plugin"
export const OpenAICompatiblePlugin = define({ export const OpenAICompatiblePlugin = define({
id: "opencode.provider.openai.compatible", id: "opencode.provider.openai-compatible",
effect: Effect.fn(function* (ctx) { effect: Effect.fn(function* (ctx) {
yield* ctx.aisdk.hook( yield* ctx.aisdk.hook(
"sdk", "sdk",
+10 -11
View File
@@ -5,6 +5,7 @@ import { App } from "../../app.js"
import { Credential } from "../../credential.js" import { Credential } from "../../credential.js"
import { Bus } from "../../bus.js" import { Bus } from "../../bus.js"
import { Integration } from "../../integration.js" import { Integration } from "../../integration.js"
import { Model } from "../../model.js"
import { OauthCallbackPage } from "../../oauth/page.js" import { OauthCallbackPage } from "../../oauth/page.js"
import { Provider } from "../../provider.js" import { Provider } from "../../provider.js"
import type { PluginInternal } from "../internal.js" import type { PluginInternal } from "../internal.js"
@@ -229,17 +230,15 @@ export const OpenAIPlugin = define({
}) })
} }
}) })
yield* ctx.session.hook( yield* ctx.session.hook("http.request", (evt) =>
"model.request", Effect.sync(() => {
(evt) => if (!chatgpt || evt.model.providerID !== Provider.ID.openai) return
Effect.sync(() => { const url = new URL(evt.request.url)
if (!chatgpt) return evt.request.headers.set("originator", "opencode")
if (evt.baseURL && URL.canParse(evt.baseURL) && new URL(evt.baseURL).origin === "https://api.openai.com") evt.request.headers.set("session-id", evt.sessionID)
evt.baseURL = codexBaseURL if (url.origin !== "https://api.openai.com") return
evt.headers.originator = "opencode" evt.request = new Request(`${codexBaseURL}${url.pathname.replace(/^\/v1/, "")}${url.search}`, evt.request)
evt.headers["session-id"] = evt.sessionID }),
}),
{ providerID: Provider.ID.openai },
) )
const refresh = () => loading.withPermit(load().pipe(Effect.andThen(ctx.catalog.reload()))) const refresh = () => loading.withPermit(load().pipe(Effect.andThen(ctx.catalog.reload())))
yield* bus.subscribe(Integration.Event.ConnectionUpdated).pipe( yield* bus.subscribe(Integration.Event.ConnectionUpdated).pipe(
@@ -6,7 +6,7 @@ import { Provider } from "../../provider.js"
import { importModule } from "@opencode-ai/util/runtime-import" import { importModule } from "@opencode-ai/util/runtime-import"
export const SapAICorePlugin = define({ export const SapAICorePlugin = define({
id: "opencode.provider.sap.ai.core", id: "opencode.provider.sap-ai-core",
effect: Effect.fn(function* (ctx) { effect: Effect.fn(function* (ctx) {
const npm = yield* Npm.Service const npm = yield* Npm.Service
yield* ctx.aisdk.hook( yield* ctx.aisdk.hook(
@@ -65,7 +65,7 @@ export function cortexFetch(upstream: FetchLike = fetch) {
} }
export const SnowflakeCortexPlugin = define({ export const SnowflakeCortexPlugin = define({
id: "opencode.provider.snowflake.cortex", id: "opencode.provider.snowflake-cortex",
effect: Effect.fn(function* (ctx) { effect: Effect.fn(function* (ctx) {
yield* ctx.aisdk.hook( yield* ctx.aisdk.hook(
"sdk", "sdk",
+1 -1
View File
@@ -35,7 +35,7 @@ export const layer = Layer.effect(
return Service.of({ return Service.of({
register: (plugin) => register: (plugin) =>
Effect.sync(() => { Effect.sync(() => {
plugins.set(plugin.id, { ...plugin, version: String(++revision), source: { type: "sdk" } }) plugins.set(plugin.id, { ...plugin, version: String(++revision) })
}).pipe(Effect.andThen(bus.publish(Updated, {})), Effect.asVoid), }).pipe(Effect.andThen(bus.publish(Updated, {})), Effect.asVoid),
all: () => [...plugins.values()], all: () => [...plugins.values()],
}) })
@@ -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") {}
+20 -47
View File
@@ -1,9 +1,8 @@
export * as PluginSupervisor from "./supervisor.js" 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 type { Plugin as PluginDefinition } from "@opencode-ai/plugin/effect/plugin"
import { Event } from "@opencode-ai/schema/config" import { Event } from "@opencode-ai/schema/config"
import { Cause, Deferred, Effect, Layer, Schema, Stream } from "effect" import { Context, Deferred, Effect, Layer, Schema, Stream } from "effect"
import path from "path" import path from "path"
import { pathToFileURL } from "url" import { pathToFileURL } from "url"
import { ConfigPluginSource } from "../config/plugin/source.js" import { ConfigPluginSource } from "../config/plugin/source.js"
@@ -15,20 +14,17 @@ import { PluginPromise } from "../plugin/promise.js"
import { PluginInternal } from "./internal.js" import { PluginInternal } from "./internal.js"
import { SdkPlugins } from "./sdk.js" import { SdkPlugins } from "./sdk.js"
import { importModule } from "@opencode-ai/util/runtime-import" import { importModule } from "@opencode-ai/util/runtime-import"
import { Service } from "./supervisor-service.js"
const PluginModule = Schema.Struct({ const PluginModule = Schema.Struct({
default: Schema.Union([ default: Schema.Union([
Schema.Struct({ Schema.Struct({
id: Schema.String, id: Schema.String,
tui: Schema.optional(Schema.Boolean),
effect: Schema.declare<PluginDefinition["effect"]>( effect: Schema.declare<PluginDefinition["effect"]>(
(input): input is PluginDefinition["effect"] => typeof input === "function", (input): input is PluginDefinition["effect"] => typeof input === "function",
), ),
}), }),
Schema.Struct({ Schema.Struct({
id: Schema.String, id: Schema.String,
tui: Schema.optional(Schema.Boolean),
setup: Schema.declare<Parameters<typeof PluginPromise.fromPromise>[0]["setup"]>( setup: Schema.declare<Parameters<typeof PluginPromise.fromPromise>[0]["setup"]>(
(input): input is Parameters<typeof PluginPromise.fromPromise>[0]["setup"] => typeof input === "function", (input): input is Parameters<typeof PluginPromise.fromPromise>[0]["setup"] => typeof input === "function",
), ),
@@ -46,12 +42,10 @@ const resolve = Effect.fn("PluginSupervisor.resolve")(function* (
const definitions = [...pre, ...post] const definitions = [...pre, ...post]
const enabled = new Set(definitions.map((plugin) => plugin.id)) const enabled = new Set(definitions.map((plugin) => plugin.id))
const packages = new Map<string, Plugin.Versioned>() const packages = new Map<string, Plugin.Versioned>()
const failures = new Map<string, Extract<Plugin.Info, { readonly status: "failed" }>>()
const plugins = () => [...definitions, ...packages.values()] const plugins = () => [...definitions, ...packages.values()]
for (const operation of operations) { for (const operation of operations) {
if (operation.type === "remove") { if (operation.type === "remove") {
if (operation.target === "*") failures.clear()
plugins() plugins()
.filter((plugin) => matches(operation.target, plugin.id)) .filter((plugin) => matches(operation.target, plugin.id))
.forEach((plugin) => enabled.delete(plugin.id)) .forEach((plugin) => enabled.delete(plugin.id))
@@ -71,35 +65,21 @@ const resolve = Effect.fn("PluginSupervisor.resolve")(function* (
const plugin = yield* load(operation).pipe( const plugin = yield* load(operation).pipe(
Effect.catchCause((cause) => Effect.catchCause((cause) =>
Effect.logWarning("failed to load plugin", { target: operation.target, cause }).pipe( Effect.logWarning("failed to load plugin", { target: operation.target, cause }).pipe(Effect.as(undefined)),
Effect.as({ error: Cause.pretty(cause) }),
),
), ),
) )
if ("error" in plugin) { if (!plugin) continue
failures.set(operation.target, {
source: pluginSource(operation.target),
status: "failed",
error: plugin.error,
tui: false,
})
continue
}
failures.delete(operation.target)
const previous = packages.get(operation.target) const previous = packages.get(operation.target)
if (previous) enabled.delete(previous.id) if (previous) enabled.delete(previous.id)
packages.set(operation.target, plugin) packages.set(operation.target, plugin)
enabled.add(plugin.id) enabled.add(plugin.id)
} }
return { return [
plugins: [ ...pre.filter((plugin) => enabled.has(plugin.id)),
...pre.filter((plugin) => enabled.has(plugin.id)), ...Array.from(packages.values()).filter((plugin) => enabled.has(plugin.id)),
...Array.from(packages.values()).filter((plugin) => enabled.has(plugin.id)), ...post.filter((plugin) => enabled.has(plugin.id)),
...post.filter((plugin) => enabled.has(plugin.id)), ]
],
failures: [...failures.values()],
}
}) })
const load = Effect.fn("PluginSupervisor.load")(function* ( const load = Effect.fn("PluginSupervisor.load")(function* (
@@ -109,7 +89,7 @@ const load = Effect.fn("PluginSupervisor.load")(function* (
const entrypoint = path.isAbsolute(operation.target) const entrypoint = path.isAbsolute(operation.target)
? pathToFileURL(operation.target).href ? pathToFileURL(operation.target).href
: (yield* npm.add(operation.target, { subpaths: ["server", ""] })).entrypoint : (yield* npm.add(operation.target, { subpaths: ["server", ""] })).entrypoint
if (!entrypoint) return yield* Effect.fail(new Error(`Plugin entrypoint not found: ${operation.target}`)) if (!entrypoint) return
// Bun currently ignores query parameters when caching file:// imports. // Bun currently ignores query parameters when caching file:// imports.
const source = const source =
operation.mtime === undefined operation.mtime === undefined
@@ -123,13 +103,18 @@ const load = Effect.fn("PluginSupervisor.load")(function* (
const plugin = "effect" in value ? value : PluginPromise.fromPromise(value) const plugin = "effect" in value ? value : PluginPromise.fromPromise(value)
return { return {
id: plugin.id, id: plugin.id,
tui: plugin.tui,
version: JSON.stringify(operation), version: JSON.stringify(operation),
source: pluginSource(operation.target),
effect: (host) => plugin.effect({ ...host, options: operation.options }), effect: (host) => plugin.effect({ ...host, options: operation.options }),
} satisfies Plugin.Versioned } 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( export const layer = Layer.effect(
Service, Service,
Effect.gen(function* () { Effect.gen(function* () {
@@ -144,20 +129,13 @@ export const layer = Layer.effect(
// Resolve OpenCode's internal plugins with their privileged Location services. // Resolve OpenCode's internal plugins with their privileged Location services.
const internal = yield* PluginInternal.list() const internal = yield* PluginInternal.list()
// Combine internal plugins with host-contributed SDK plugins in boot order. // Combine internal plugins with host-contributed SDK plugins in boot order.
const pre = [ const pre = [...internal.pre.map((plugin) => ({ ...plugin, version: "internal" })), ...sdk.all()]
...internal.pre.map((plugin) => ({ ...plugin, version: "internal", source: { type: "builtin" as const } })), const post = internal.post.map((plugin) => ({ ...plugin, version: "internal" }))
...sdk.all(),
]
const post = internal.post.map((plugin) => ({
...plugin,
version: "internal",
source: { type: "builtin" as const },
}))
const operations = yield* sources.operations() const operations = yield* sources.operations()
// Apply config operations and load enabled package plugins into one ordered generation. // Apply config operations and load enabled package plugins into one ordered generation.
const resolved = yield* resolve(pre, post, operations) const plugins = yield* resolve(pre, post, operations)
// Replace the active generation in one scoped, batched activation. // Replace the active generation in one scoped, batched activation.
yield* registry.activate(resolved.plugins, resolved.failures) yield* registry.activate(plugins)
}) })
const updates = Stream.merge(sources.changes(), bus.subscribe([Event.Updated, SdkPlugins.Updated])).pipe( const updates = Stream.merge(sources.changes(), bus.subscribe([Event.Updated, SdkPlugins.Updated])).pipe(
// Make accepted work visible to flush before coalescing the burst. // Make accepted work visible to flush before coalescing the burst.
@@ -194,9 +172,4 @@ const nodeDeps = [
PluginInternal.requirements, PluginInternal.requirements,
] as const ] as const
function pluginSource(target: string): Plugin.Source {
if (path.isAbsolute(target)) return { type: "local", path: target }
return { type: "package", package: target }
}
export const node = makeLocationNode({ service: Service, layer, deps: nodeDeps }) export const node = makeLocationNode({ service: Service, layer, deps: nodeDeps })
+1 -1
View File
@@ -33,7 +33,7 @@ export const Plugins = [OpenAIPlugin, GooglePlugin, AnthropicPlugin, KimiPlugin,
function make(id: string, select: (modelID: string) => string | undefined) { function make(id: string, select: (modelID: string) => string | undefined) {
return define({ return define({
id: `opencode.prompt.${id}`, id: `opencode.system-prompt.${id}`,
effect: Effect.fn(`SystemPromptPlugin.${id}`)(function* (ctx) { effect: Effect.fn(`SystemPromptPlugin.${id}`)(function* (ctx) {
yield* ctx.session.hook("context", (event) => yield* ctx.session.hook("context", (event) =>
Effect.gen(function* () { Effect.gen(function* () {
-11
View File
@@ -48,17 +48,6 @@ const builtins = new Map<string, () => Promise<unknown>>([
["@opencode-ai/ai/providers/azure/chat", () => import("@opencode-ai/ai/providers/azure/chat")], ["@opencode-ai/ai/providers/azure/chat", () => import("@opencode-ai/ai/providers/azure/chat")],
["@opencode-ai/ai/providers/azure/responses", () => import("@opencode-ai/ai/providers/azure/responses")], ["@opencode-ai/ai/providers/azure/responses", () => import("@opencode-ai/ai/providers/azure/responses")],
["@opencode-ai/ai/providers/google", () => import("@opencode-ai/ai/providers/google")], ["@opencode-ai/ai/providers/google", () => import("@opencode-ai/ai/providers/google")],
["@opencode-ai/ai/providers/google-vertex", () => import("@opencode-ai/ai/providers/google-vertex")],
["@opencode-ai/ai/providers/google-vertex/gemini", () => import("@opencode-ai/ai/providers/google-vertex/gemini")],
["@opencode-ai/ai/providers/google-vertex/chat", () => import("@opencode-ai/ai/providers/google-vertex/chat")],
[
"@opencode-ai/ai/providers/google-vertex/responses",
() => import("@opencode-ai/ai/providers/google-vertex/responses"),
],
[
"@opencode-ai/ai/providers/google-vertex/messages",
() => import("@opencode-ai/ai/providers/google-vertex/messages"),
],
["@opencode-ai/ai/providers/openai", () => import("@opencode-ai/ai/providers/openai")], ["@opencode-ai/ai/providers/openai", () => import("@opencode-ai/ai/providers/openai")],
["@opencode-ai/ai/providers/openai/chat", () => import("@opencode-ai/ai/providers/openai/chat")], ["@opencode-ai/ai/providers/openai/chat", () => import("@opencode-ai/ai/providers/openai/chat")],
["@opencode-ai/ai/providers/openai/responses", () => import("@opencode-ai/ai/providers/openai/responses")], ["@opencode-ai/ai/providers/openai/responses", () => import("@opencode-ai/ai/providers/openai/responses")],
+13 -9
View File
@@ -40,7 +40,6 @@ import { SessionRevert } from "./session/revert.js"
import { Session } from "@opencode-ai/schema/session" import { Session } from "@opencode-ai/schema/session"
import { FSUtil } from "@opencode-ai/util/fs-util" import { FSUtil } from "@opencode-ai/util/fs-util"
import { Image } from "./image.js" import { Image } from "./image.js"
import { PluginSupervisor } from "./plugin/supervisor-service.js"
import { Mime } from "./mime.js" import { Mime } from "./mime.js"
import type { EventLog } from "@opencode-ai/schema/event-log" import type { EventLog } from "@opencode-ai/schema/event-log"
import { Event } from "@opencode-ai/schema/event" 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)) if (session.revert) yield* SessionRevert.commit(session).pipe(Effect.provideService(Bus.Service, bus))
// Resolved lazily so prompt admission only boots location services when an // Resolved lazily so prompt admission only boots location services when an
// image attachment actually needs the resizer. // image attachment actually needs the resizer.
const image = Effect.gen(function* () { const image = Image.Service.pipe(Effect.provide(locations.get(session.location)))
const plugins = yield* PluginSupervisor.Service
yield* plugins.flush
return yield* Image.Service
}).pipe(Effect.provide(locations.get(session.location)))
const skills = Skill.Service.pipe(Effect.provide(locations.get(session.location))) const skills = Skill.Service.pipe(Effect.provide(locations.get(session.location)))
const prompt = yield* resolvePrompt( const prompt = yield* resolvePrompt(
{ text: input.text, files: input.files, agents: input.agents, skills: input.skills }, { text: input.text, files: input.files, agents: input.agents, skills: input.skills },
@@ -785,7 +780,7 @@ const layer = Layer.effect(
payload, payload,
delivery: input.delivery ?? "steer", delivery: input.delivery ?? "steer",
}) })
yield* SessionInbox.serialized( const recovered = yield* SessionInbox.serialized(
input.sessionID, input.sessionID,
Effect.gen(function* () { Effect.gen(function* () {
const latest = yield* result.get(input.sessionID) const latest = yield* result.get(input.sessionID)
@@ -796,16 +791,25 @@ const layer = Layer.effect(
) )
const moved = [SessionEvent.Moved, { sessionID: input.sessionID, ...payload }] as const const moved = [SessionEvent.Moved, { sessionID: input.sessionID, ...payload }] as const
const first = cancellations[0] const first = cancellations[0]
if (!first) return yield* bus.publish(...moved).pipe(Effect.asVoid) if (!first) {
return yield* bus.publishAll([first, ...cancellations.slice(1), moved]) yield* bus.publish(...moved)
return true
}
yield* bus.publishAll([first, ...cancellations.slice(1), moved])
return true
} }
yield* SessionInbox.admit(db, bus, { yield* SessionInbox.admit(db, bus, {
id: SessionMessage.ID.create(), id: SessionMessage.ID.create(),
sessionID: input.sessionID, sessionID: input.sessionID,
item, item,
}) })
return false
}), }),
) )
if (recovered) {
yield* execution.wakeActive(input.sessionID)
return
}
yield* execution.wake(input.sessionID) yield* execution.wake(input.sessionID)
}), }),
compact: Effect.fn("Session.compact")(function* (input) { compact: Effect.fn("Session.compact")(function* (input) {
+15 -18
View File
@@ -12,7 +12,6 @@ import { llmClient } from "../effect/app-node-platform.js"
import { SessionEvent } from "./event.js" import { SessionEvent } from "./event.js"
import type { SessionMessage } from "./message.js" import type { SessionMessage } from "./message.js"
import { SessionModelHeaders } from "./model-headers.js" import { SessionModelHeaders } from "./model-headers.js"
import { SessionModelHook } from "./model-hook.js"
import { SessionModelHttp } from "./model-http.js" import { SessionModelHttp } from "./model-http.js"
import { SessionPromptCacheKey } from "./prompt-cache-key.js" import { SessionPromptCacheKey } from "./prompt-cache-key.js"
import { App } from "../app.js" import { App } from "../app.js"
@@ -271,25 +270,23 @@ const make = (dependencies: Dependencies) => {
}) })
: Effect.void, : 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 yield* dependencies.llm
.stream(request, { .stream(
http: SessionModelHttp.middleware(dependencies.hooks, { LLM.request({
sessionID: plan.session.id, model: plan.model,
agent: Agent.ID.make("compaction"), promptCacheKey: SessionPromptCacheKey.make(plan.session.id),
model: plan.ref, 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( .pipe(
Stream.runForEach((event) => { Stream.runForEach((event) => {
if (LLMEvent.is.providerError(event)) if (LLMEvent.is.providerError(event))
+11 -7
View File
@@ -21,6 +21,8 @@ export interface Interface {
readonly resume: (sessionID: SessionSchema.ID) => Effect.Effect<void, SessionRunner.RunError> readonly resume: (sessionID: SessionSchema.ID) => Effect.Effect<void, SessionRunner.RunError>
/** Registers newly recorded work. Repeated wakeups may coalesce. */ /** Registers newly recorded work. Repeated wakeups may coalesce. */
readonly wake: (sessionID: SessionSchema.ID) => Effect.Effect<void> readonly wake: (sessionID: SessionSchema.ID) => Effect.Effect<void>
/** Wakes only an active execution, preserving its current input eligibility. */
readonly wakeActive: (sessionID: SessionSchema.ID) => Effect.Effect<void>
/** Interrupt active work owned by this process. Idle interruption is a no-op. */ /** Interrupt active work owned by this process. Idle interruption is a no-op. */
readonly interrupt: (sessionID: SessionSchema.ID, options?: { readonly continue?: boolean }) => Effect.Effect<void> readonly interrupt: (sessionID: SessionSchema.ID, options?: { readonly continue?: boolean }) => Effect.Effect<void>
/** Resolves once this process owns no active execution for the Session. Returns immediately when idle and never starts work. */ /** Resolves once this process owns no active execution for the Session. Returns immediately when idle and never starts work. */
@@ -135,15 +137,16 @@ export const layer = Layer.effect(
return Service.of({ return Service.of({
active: coordinator.active, active: coordinator.active,
interrupt: (sessionID, options) => interrupt: (sessionID, options) =>
Effect.gen(function* () { coordinator.interrupt(
yield* coordinator.interrupt(sessionID, "user") sessionID,
if (!options?.continue) return "user",
// Resume only steering input from the interrupted intent. Queued next-turn work options?.continue
// stays parked: a steer-scoped drain never promotes queue-delivery rows. ? { continue: { request: "steer", when: SessionInbox.has(db, sessionID, "steer") } }
if (yield* SessionInbox.has(db, sessionID, "steer")) yield* coordinator.wake(sessionID, "steer") : undefined,
}), ),
resume: coordinator.run, resume: coordinator.run,
wake: coordinator.wake, wake: coordinator.wake,
wakeActive: coordinator.wakeActive,
awaitIdle: coordinator.awaitIdle, awaitIdle: coordinator.awaitIdle,
}) })
}), }),
@@ -162,6 +165,7 @@ export const noopLayer = Layer.succeed(
active: Effect.succeed(new Set()), active: Effect.succeed(new Set()),
resume: () => Effect.void, resume: () => Effect.void,
wake: () => Effect.void, wake: () => Effect.void,
wakeActive: () => Effect.void,
interrupt: () => Effect.void, interrupt: () => Effect.void,
awaitIdle: () => Effect.void, awaitIdle: () => Effect.void,
}), }),
+8 -11
View File
@@ -11,7 +11,6 @@ import { SessionContext } from "./context.js"
import { SessionGenerate } from "./generate.js" import { SessionGenerate } from "./generate.js"
import { SessionHistory } from "./history.js" import { SessionHistory } from "./history.js"
import { SessionModelHeaders } from "./model-headers.js" import { SessionModelHeaders } from "./model-headers.js"
import { SessionModelHook } from "./model-hook.js"
import { SessionModelHttp } from "./model-http.js" import { SessionModelHttp } from "./model-http.js"
import { SessionPromptCacheKey } from "./prompt-cache-key.js" import { SessionPromptCacheKey } from "./prompt-cache-key.js"
import { SessionRunnerModel } from "./runner/model.js" import { SessionRunnerModel } from "./runner/model.js"
@@ -72,9 +71,7 @@ export const layer = Layer.effect(
providerID: model.ref.providerID, providerID: model.ref.providerID,
modelID: model.ref.id, modelID: model.ref.id,
}) })
const request = yield* SessionModelHook.apply( const response = yield* llm.generate(
hooks,
{ sessionID: selection.session.id, agent: selection.agent.id, model: model.ref },
LLM.request({ LLM.request({
model: model.model, model: model.model,
http: { headers: SessionModelHeaders.make(selection.session, app) }, http: { headers: SessionModelHeaders.make(selection.session, app) },
@@ -83,14 +80,14 @@ export const layer = Layer.effect(
messages: contextEvent.messages, messages: contextEvent.messages,
tools: hookedTools, 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 }) yield* Effect.logInfo("session generation usage diagnostic", { usage: response.usage })
return response.text return response.text
}), }),
+7 -21
View File
@@ -180,27 +180,13 @@ export const admitCompaction = Effect.fn("SessionInbox.admitCompaction")(functio
bus: Bus.Interface, bus: Bus.Interface,
input: { readonly id: SessionMessage.ID; readonly sessionID: SessionSchema.ID; readonly delivery: Delivery }, input: { readonly id: SessionMessage.ID; readonly sessionID: SessionSchema.ID; readonly delivery: Delivery },
) { ) {
return yield* serialized( const admitted = yield* admit(db, bus, {
input.sessionID, id: input.id,
Effect.gen(function* () { sessionID: input.sessionID,
const exact = yield* find(db, input.id) item: Item.make({ type: "compaction", payload: {}, delivery: input.delivery }),
if (exact) { })
if (exact.type === "compaction" && exact.sessionID === input.sessionID) return exact if (admitted.type === "compaction") return admitted
return yield* Effect.die(new LifecycleConflict({ id: input.id })) 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 }))
}),
)
}) })
export const projectAdmitted = Effect.fn("SessionInbox.projectAdmitted")(function* ( 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,
}),
})
})
+13 -20
View File
@@ -14,7 +14,6 @@ import { QuestionTool } from "../tool/plugin/question.js"
import { Tool } from "../tool.js" import { Tool } from "../tool.js"
import { SessionContext } from "./context.js" import { SessionContext } from "./context.js"
import { SessionModelHeaders } from "./model-headers.js" import { SessionModelHeaders } from "./model-headers.js"
import { SessionModelHook } from "./model-hook.js"
import { SessionModelHttp } from "./model-http.js" import { SessionModelHttp } from "./model-http.js"
import { SessionModelTransport } from "./model-transport.js" import { SessionModelTransport } from "./model-transport.js"
import { SessionPromptCacheKey } from "./prompt-cache-key.js" import { SessionPromptCacheKey } from "./prompt-cache-key.js"
@@ -227,25 +226,19 @@ export const layer = Layer.effect(
return [[name, { ...tool, description: definition.description, inputSchema: definition.input }] as const] return [[name, { ...tool, description: definition.description, inputSchema: definition.input }] as const]
}), }),
) )
const request = yield* SessionModelHook.apply( const request = LLM.request({
hooks, model,
{ sessionID: session.id, agent: agent.id, model: resolved.ref }, http: {
LLM.request({ headers: SessionModelHeaders.make(session, app),
model, },
http: { promptCacheKey: SessionPromptCacheKey.make(session.id),
headers: SessionModelHeaders.make(session, app), system: context.system,
}, messages: boundImages(unsupportedParts(context.messages, resolved.capabilities)),
// TODO: Persist cache lineage so nested forks reuse the root session's cache key. tools: Array.from(hooked, ([name, tool]) => ({ ...tool, name })),
promptCacheKey: SessionPromptCacheKey.make(session.fork?.sessionID ?? session.id), toolChoice: stepLimitReached ? "none" : undefined,
system: context.system, })
messages: boundImages(unsupportedParts(context.messages, resolved.capabilities)),
tools: Array.from(hooked, ([name, tool]) => ({ ...tool, name })),
toolChoice: stepLimitReached ? "none" : undefined,
}),
)
const webSocketEligible = const webSocketEligible =
!(yield* hooks.has("session", "http.request", resolved.ref.providerID)) && !(yield* hooks.has("session", "http.request")) && !(yield* hooks.has("session", "http.response"))
!(yield* hooks.has("session", "http.response", resolved.ref.providerID))
const http = webSocketEligible const http = webSocketEligible
? undefined ? undefined
: SessionModelHttp.middleware(hooks, { : SessionModelHttp.middleware(hooks, {
@@ -258,7 +251,7 @@ export const layer = Layer.effect(
...(webSocket && ...(webSocket &&
webSocketEligible && webSocketEligible &&
resolved.ref.providerID === Provider.ID.openai && resolved.ref.providerID === Provider.ID.openai &&
request.model.route.id === "openai-responses" model.route.id === "openai-responses"
? { webSocket: transport.bind(session.id) } ? { webSocket: transport.bind(session.id) }
: {}), : {}),
} }
+74 -22
View File
@@ -10,17 +10,25 @@ export interface Coordinator<Key, E, Reason = never> {
/** Starts an execution while idle, or joins the active execution and returns its exit. */ /** Starts an execution while idle, or joins the active execution and returns its exit. */
readonly run: (key: Key) => Effect.Effect<void, E> readonly run: (key: Key) => Effect.Effect<void, E>
/** Rings the doorbell: an idle key starts an execution; an active one drains again before settling. */ /** Rings the doorbell: an idle key starts an execution; an active one drains again before settling. */
readonly wake: (key: Key, scope?: Promotable) => Effect.Effect<void> readonly wake: (key: Key, request?: Request) => Effect.Effect<void>
/** Rings the current execution's doorbell with its existing request. Idle keys remain idle. */
readonly wakeActive: (key: Key) => Effect.Effect<void>
/** Stops the active execution, clears its doorbell, and waits for cleanup. No-op when idle. */ /** Stops the active execution, clears its doorbell, and waits for cleanup. No-op when idle. */
readonly interrupt: (key: Key, reason?: Reason) => Effect.Effect<void> readonly interrupt: (
key: Key,
reason?: Reason,
options?: { readonly continue?: { readonly request: Request; readonly when: Effect.Effect<boolean> } },
) => Effect.Effect<void>
/** Resolves once no execution is active for the key. Returns immediately when already idle and never starts work. */ /** Resolves once no execution is active for the key. Returns immediately when already idle and never starts work. */
readonly awaitIdle: (key: Key) => Effect.Effect<void> readonly awaitIdle: (key: Key) => Effect.Effect<void>
} }
export type Request = Promotable
/** /**
* One execution is a busy period for one key: one fiber that drains from the first wake * One execution is a busy period for one key: one fiber that drains from the first wake
* until the key would stay idle. `pendingWake` is the doorbell: work recorded during the * until the key would stay idle. `pendingWake` is the doorbell: work recorded during the
* execution rings it with the scope that work needs, and the execution loop drains again * execution rings it with its eligibility request, and the execution loop drains again
* instead of ending. The doorbell closes the gap between a drain's last eligibility check * instead of ending. The doorbell closes the gap between a drain's last eligibility check
* and the idle transition, since those cannot be one atomic step. `done` resolves joiners * and the idle transition, since those cannot be one atomic step. `done` resolves joiners
* with this execution's exit. * with this execution's exit.
@@ -28,10 +36,15 @@ export interface Coordinator<Key, E, Reason = never> {
type Execution<E, Reason> = { type Execution<E, Reason> = {
readonly done: Deferred.Deferred<void, E> readonly done: Deferred.Deferred<void, E>
owner?: Fiber.Fiber<void> owner?: Fiber.Fiber<void>
scope: Promotable request: Request
pendingWake?: Promotable pendingWake?: Request
stopping: boolean stopping: boolean
interruptionReason?: Reason interruptionReason?: Reason
continuation?: {
readonly request: Request
readonly when: Effect.Effect<boolean>
signaled: boolean
}
} }
/** /**
@@ -46,7 +59,7 @@ type Execution<E, Reason> = {
* ``` * ```
*/ */
export const make = <Key, E, Reason = never>(options: { export const make = <Key, E, Reason = never>(options: {
readonly drain: (key: Key, force: boolean, scope: Promotable) => Effect.Effect<void, E> readonly drain: (key: Key, force: boolean, request: Request) => Effect.Effect<void, E>
/** Runs once when a process-local busy period begins, before its first drain. */ /** Runs once when a process-local busy period begins, before its first drain. */
readonly started?: (key: Key) => Effect.Effect<void> readonly started?: (key: Key) => Effect.Effect<void>
/** /**
@@ -60,11 +73,11 @@ export const make = <Key, E, Reason = never>(options: {
const fork = yield* FiberSet.makeRuntime<never, void, never>() const fork = yield* FiberSet.makeRuntime<never, void, never>()
const loop = (key: Key, execution: Execution<E, Reason>, force: boolean): Effect.Effect<void, E> => const loop = (key: Key, execution: Execution<E, Reason>, force: boolean): Effect.Effect<void, E> =>
Effect.suspend(() => options.drain(key, force, execution.scope)).pipe( Effect.suspend(() => options.drain(key, force, execution.request)).pipe(
Effect.flatMap(() => Effect.flatMap(() =>
Effect.suspend(() => { Effect.suspend(() => {
if (execution.stopping || execution.pendingWake === undefined) return Effect.void if (execution.stopping || execution.pendingWake === undefined) return Effect.void
execution.scope = execution.pendingWake execution.request = execution.pendingWake
execution.pendingWake = undefined execution.pendingWake = undefined
// Trampoline so drains that complete synchronously cannot grow the stack. // Trampoline so drains that complete synchronously cannot grow the stack.
return Effect.yieldNow.pipe(Effect.andThen(loop(key, execution, false))) return Effect.yieldNow.pipe(Effect.andThen(loop(key, execution, false)))
@@ -72,10 +85,10 @@ export const make = <Key, E, Reason = never>(options: {
), ),
) )
const start = (key: Key, force: boolean, scope: Promotable) => { const start = (key: Key, force: boolean, request: Request) => {
const execution: Execution<E, Reason> = { const execution: Execution<E, Reason> = {
done: Deferred.makeUnsafe<void, E>(), done: Deferred.makeUnsafe<void, E>(),
scope, request,
stopping: false, stopping: false,
} }
executions.set(key, execution) executions.set(key, execution)
@@ -91,7 +104,7 @@ export const make = <Key, E, Reason = never>(options: {
execution.owner = undefined execution.owner = undefined
}).pipe(Effect.andThen(options.settled?.(key, exit, execution.interruptionReason) ?? Effect.void)), }).pipe(Effect.andThen(options.settled?.(key, exit, execution.interruptionReason) ?? Effect.void)),
), ),
Effect.onExit((exit) => Effect.sync(() => settle(key, execution, exit))), Effect.onExit((exit) => finish(key, execution, exit)),
Effect.exit, Effect.exit,
Effect.asVoid, Effect.asVoid,
), ),
@@ -101,12 +114,22 @@ export const make = <Key, E, Reason = never>(options: {
// A doorbell that survives the execution loop (rung after the loop decided to end, or // A doorbell that survives the execution loop (rung after the loop decided to end, or
// during failure or interruption cleanup) starts a fresh execution for the remaining work. // during failure or interruption cleanup) starts a fresh execution for the remaining work.
const settle = (key: Key, execution: Execution<E, Reason>, exit: Exit.Exit<void, E>) => { const settle = (key: Key, execution: Execution<E, Reason>, exit: Exit.Exit<void, E>, resume: boolean) => {
if (execution.pendingWake) start(key, false, execution.pendingWake) if (resume && execution.continuation) start(key, false, execution.continuation.request)
else if (execution.pendingWake) start(key, false, execution.pendingWake)
else executions.delete(key) else executions.delete(key)
Deferred.doneUnsafe(execution.done, exit) Deferred.doneUnsafe(execution.done, exit)
} }
const finish = (key: Key, execution: Execution<E, Reason>, exit: Exit.Exit<void, E>) => {
if (!execution.continuation) return Effect.sync(() => settle(key, execution, exit, false))
return execution.continuation.when.pipe(
Effect.flatMap((ready) =>
Effect.sync(() => settle(key, execution, exit, ready || execution.continuation?.signaled === true)),
),
)
}
const run = (key: Key): Effect.Effect<void, E> => const run = (key: Key): Effect.Effect<void, E> =>
Effect.suspend(() => { Effect.suspend(() => {
const execution = executions.get(key) const execution = executions.get(key)
@@ -118,26 +141,55 @@ export const make = <Key, E, Reason = never>(options: {
return Deferred.await(start(key, true, "input").done) return Deferred.await(start(key, true, "input").done)
}) })
const wake = (key: Key, scope: Promotable = "input") => const wake = (key: Key, request: Request = "input") =>
Effect.sync(() => { Effect.sync(() => {
const execution = executions.get(key) const execution = executions.get(key)
if (execution !== undefined) { if (execution !== undefined) {
// Coalesced wakes keep the widest scope: "input" subsumes "steer". if (execution.stopping) {
execution.pendingWake = execution.pendingWake === "input" ? "input" : scope if (execution.continuation) execution.continuation.signaled = true
else execution.continuation = { request, when: Effect.succeed(true), signaled: true }
return
}
// Coalesced wakes keep the widest request: "input" subsumes "steer".
execution.pendingWake = execution.pendingWake === "input" ? "input" : request
return return
} }
start(key, false, scope) start(key, false, request)
}) })
const interrupt = (key: Key, reason?: Reason): Effect.Effect<void> => const wakeActive = (key: Key) =>
Effect.suspend(() => { Effect.suspend(() => {
const execution = executions.get(key) const execution = executions.get(key)
if (execution?.owner === undefined || execution.stopping) return Effect.void return execution ? wake(key, execution.request) : Effect.void
})
const interrupt = (
key: Key,
reason?: Reason,
options?: { readonly continue?: { readonly request: Request; readonly when: Effect.Effect<boolean> } },
): Effect.Effect<void> =>
Effect.suspend(() => {
const execution = executions.get(key)
if (execution === undefined) return Effect.void
if (execution.stopping) {
if (options?.continue)
execution.continuation = {
...options.continue,
signaled: execution.continuation?.signaled ?? false,
}
return Deferred.await(execution.done).pipe(Effect.exit, Effect.asVoid)
}
if (execution.owner === undefined) {
if (!options?.continue) return Effect.void
execution.stopping = true
execution.pendingWake = undefined
execution.continuation = { ...options.continue, signaled: false }
return Deferred.await(execution.done).pipe(Effect.exit, Effect.asVoid)
}
execution.stopping = true execution.stopping = true
// Wakes recorded so far belong to the interrupted intent; the interrupt claims them.
// Wakes arriving during cleanup are new admissions and restart normally at settle.
execution.pendingWake = undefined execution.pendingWake = undefined
execution.interruptionReason = reason execution.interruptionReason = reason
if (options?.continue) execution.continuation = { ...options.continue, signaled: false }
return Fiber.interrupt(execution.owner) return Fiber.interrupt(execution.owner)
}) })
@@ -150,5 +202,5 @@ export const make = <Key, E, Reason = never>(options: {
return Deferred.await(execution.done).pipe(Effect.exit, Effect.andThen(awaitIdle(key))) return Deferred.await(execution.done).pipe(Effect.exit, Effect.andThen(awaitIdle(key)))
}) })
return { active: Effect.sync(() => new Set(executions.keys())), run, wake, interrupt, awaitIdle } return { active: Effect.sync(() => new Set(executions.keys())), run, wake, wakeActive, interrupt, awaitIdle }
}) })
+1 -1
View File
@@ -54,7 +54,7 @@ const layer = Layer.effect(
const decodeMessage = Schema.decodeUnknownEffect(SessionMessage.Info) const decodeMessage = Schema.decodeUnknownEffect(SessionMessage.Info)
return Service.of({ return Service.of({
get: Effect.fnUntraced(function* (sessionID) { get: Effect.fn("SessionStore.get")(function* (sessionID) {
const row = yield* db.select().from(SessionTable).where(eq(SessionTable.id, sessionID)).get().pipe(Effect.orDie) const row = yield* db.select().from(SessionTable).where(eq(SessionTable.id, sessionID)).get().pipe(Effect.orDie)
return row ? fromRow(row) : undefined return row ? fromRow(row) : undefined
}), }),
+15 -18
View File
@@ -14,7 +14,6 @@ import { PluginHooks } from "../plugin/hooks.js"
import { SessionEvent } from "./event.js" import { SessionEvent } from "./event.js"
import { SessionHistory } from "./history.js" import { SessionHistory } from "./history.js"
import { SessionModelHeaders } from "./model-headers.js" import { SessionModelHeaders } from "./model-headers.js"
import { SessionModelHook } from "./model-hook.js"
import { SessionModelHttp } from "./model-http.js" import { SessionModelHttp } from "./model-http.js"
import { SessionRunnerModel } from "./runner/model.js" import { SessionRunnerModel } from "./runner/model.js"
import { SessionSchema } from "./schema.js" import { SessionSchema } from "./schema.js"
@@ -81,25 +80,23 @@ const make = (dependencies: Dependencies) => {
}) })
: Effect.void, : 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 const streamed = yield* dependencies.llm
.stream(request, { .stream(
http: SessionModelHttp.middleware(dependencies.hooks, { LLM.request({
sessionID: session.id, model: resolved.model,
agent: agent.id, http: { headers: SessionModelHeaders.make(session, dependencies.app) },
model: resolved.ref, system: agent.system,
messages: [Message.user(firstUser.text)],
tools: [],
}), }),
}) {
http: SessionModelHttp.middleware(dependencies.hooks, {
sessionID: session.id,
agent: agent.id,
model: resolved.ref,
}),
},
)
.pipe( .pipe(
Stream.runForEach((event) => { Stream.runForEach((event) => {
if (LLMEvent.is.providerError(event)) failed = true if (LLMEvent.is.providerError(event)) failed = true
+5 -6
View File
@@ -5,8 +5,7 @@ import { Money } from "@opencode-ai/schema/money"
import type { TokenUsage } from "@opencode-ai/schema/token-usage" import type { TokenUsage } from "@opencode-ai/schema/token-usage"
import type { Model } from "../model.js" import type { Model } from "../model.js"
const finite = (value: number) => (Number.isFinite(value) ? value : 0) const safe = (value: number | undefined) => Math.max(0, Number.isFinite(value) ? (value ?? 0) : 0)
const safe = (value: number | undefined) => Math.max(0, finite(value ?? 0))
export const tokens = (usage: Usage | undefined): TokenUsage.Info => ({ export const tokens = (usage: Usage | undefined): TokenUsage.Info => ({
input: safe(usage?.nonCachedInputTokens), input: safe(usage?.nonCachedInputTokens),
@@ -27,10 +26,10 @@ export function calculateCost(costs: Model.Info["cost"], usage: TokenUsage.Info)
const cost = tier ?? costs.find((cost) => cost.tier === undefined) const cost = tier ?? costs.find((cost) => cost.tier === undefined)
if (!cost) return Money.USD.zero if (!cost) return Money.USD.zero
return Money.USD.make( return Money.USD.make(
(usage.input * finite(cost.input) + (usage.input * cost.input +
(usage.output + usage.reasoning) * finite(cost.output) + (usage.output + usage.reasoning) * cost.output +
usage.cache.read * finite(cost.cache.read) + usage.cache.read * cost.cache.read +
usage.cache.write * finite(cost.cache.write)) / usage.cache.write * cost.cache.write) /
1_000_000, 1_000_000,
) )
} }
+2 -2
View File
@@ -101,7 +101,7 @@ export const layer = (options?: ShellSelect.Options) =>
}), }),
) )
const require = Effect.fnUntraced(function* (id: Shell.ID) { const require = Effect.fn("Shell.require")(function* (id: Shell.ID) {
const session = sessions.get(id) const session = sessions.get(id)
if (!session) return yield* new NotFoundError({ id }) if (!session) return yield* new NotFoundError({ id })
return session return session
@@ -153,7 +153,7 @@ export const layer = (options?: ShellSelect.Options) =>
const name = () => resolve().pipe(Effect.map(ShellSelect.name)) const name = () => resolve().pipe(Effect.map(ShellSelect.name))
const output = Effect.fnUntraced(function* (id: Shell.ID, input?: Shell.OutputInput) { const output = Effect.fn("Shell.output")(function* (id: Shell.ID, input?: Shell.OutputInput) {
const session = yield* require(id) const session = yield* require(id)
const cursor = input?.cursor ?? 0 const cursor = input?.cursor ?? 0
const limit = input?.limit ?? 65536 const limit = input?.limit ?? 65536
+2 -2
View File
@@ -153,7 +153,7 @@ const ARITY: Record<string, number> = {
"yarn run": 3, "yarn run": 3,
} }
export const scan = Effect.fnUntraced(function* ( export const scan = Effect.fn("ShellParse.scan")(function* (
command: string, command: string,
shell: string, shell: string,
cwd: string, cwd: string,
@@ -163,7 +163,7 @@ export const scan = Effect.fnUntraced(function* (
return yield* scanLegacy(command, shell, cwd) return yield* scanLegacy(command, shell, cwd)
}) })
const scanLegacy = Effect.fnUntraced(function* (command: string, shell: string, cwd: string) { const scanLegacy = Effect.fn("ShellParse.scanLegacy")(function* (command: string, shell: string, cwd: string) {
const parsers = yield* Effect.promise(load) const parsers = yield* Effect.promise(load)
const powershell = ShellSelect.ps(shell) const powershell = ShellSelect.ps(shell)
const tree = (powershell ? parsers.ps : parsers.bash).parse(command) const tree = (powershell ? parsers.ps : parsers.bash).parse(command)
+4 -1
View File
@@ -97,7 +97,10 @@ export function create<State, DraftApi>(options: Options<State, DraftApi>): Inte
const materialize = Effect.fnUntraced(function* () { const materialize = Effect.fnUntraced(function* () {
const next = options.initial() const next = options.initial()
const api = options.draft(next) const api = options.draft(next)
for (const transform of transforms) yield* apply(transform.run, api) for (const transform of transforms)
yield* apply(transform.run, api).pipe(
Effect.withSpan("State.reload.update", { attributes: { state: options.name ?? "anonymous" } }),
)
yield* commit(next) yield* commit(next)
}) })
+1 -1
View File
@@ -51,7 +51,7 @@ const layer = Layer.effect(
const global = yield* Global.Service const global = yield* Global.Service
const directory = path.join(global.data, DIRECTORY) const directory = path.join(global.data, DIRECTORY)
const truncate = Effect.fnUntraced(function* (result: Result) { const truncate = Effect.fn("ToolOutput.truncate")(function* (result: Result) {
if (result.metadata?.truncated !== undefined) return result if (result.metadata?.truncated !== undefined) return result
const content = const content =
typeof result.content === "string" ? [{ type: "text" as const, text: result.content }] : (result.content ?? []) typeof result.content === "string" ? [{ type: "text" as const, text: result.content }] : (result.content ?? [])
+1 -1
View File
@@ -50,7 +50,7 @@ const layer = Layer.effect(
const image = yield* Image.Service const image = yield* Image.Service
type NormalizedItem = Tool.Content | "decode" | "size" type NormalizedItem = Tool.Content | "decode" | "size"
const normalizeImages = Effect.fnUntraced(function* (content: ReadonlyArray<Tool.Content>) { const normalizeImages = Effect.fn("Tool.normalizeImages")(function* (content: ReadonlyArray<Tool.Content>) {
const normalized = yield* Effect.forEach(content, (item): Effect.Effect<NormalizedItem> => { const normalized = yield* Effect.forEach(content, (item): Effect.Effect<NormalizedItem> => {
if (item.type !== "file" || !item.mime.startsWith("image/")) return Effect.succeed(item) if (item.type !== "file" || !item.mime.startsWith("image/")) return Effect.succeed(item)
const base64 = /^data:[^,]*;base64,(.*)$/s.exec(item.uri)?.[1] const base64 = /^data:[^,]*;base64,(.*)$/s.exec(item.uri)?.[1]
+2 -2
View File
@@ -208,7 +208,7 @@ export const Plugin = {
) )
yield* context.progress({ shellID: info.id }) yield* context.progress({ shellID: info.id })
const captureShell = Effect.fnUntraced(function* () { const captureShell = Effect.fn("ShellTool.captureShell")(function* () {
const configured = Config.latest(yield* config.entries(), "tool_output") const configured = Config.latest(yield* config.entries(), "tool_output")
const maxLines = configured?.max_lines ?? ToolOutput.MAX_LINES const maxLines = configured?.max_lines ?? ToolOutput.MAX_LINES
const maxBytes = configured?.max_bytes ?? ToolOutput.MAX_BYTES const maxBytes = configured?.max_bytes ?? ToolOutput.MAX_BYTES
@@ -228,7 +228,7 @@ export const Plugin = {
} }
}) })
const settleShell = Effect.fnUntraced(function* () { const settleShell = Effect.fn("ShellTool.settleShell")(function* () {
const final = yield* shell.wait(info.id) const final = yield* shell.wait(info.id)
const capture = yield* captureShell() const capture = yield* captureShell()
+11 -6
View File
@@ -4,6 +4,7 @@ import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin
import { ToolFailure } from "@opencode-ai/ai" import { ToolFailure } from "@opencode-ai/ai"
import { Effect, Schema, Semaphore } from "effect" import { Effect, Schema, Semaphore } from "effect"
import { HttpClientError } from "effect/unstable/http" import { HttpClientError } from "effect/unstable/http"
import { Config } from "../../config.js"
import { Form } from "../../form.js" import { Form } from "../../form.js"
import { Permission } from "../../permission.js" import { Permission } from "../../permission.js"
import { WebSearch } from "../../websearch.js" import { WebSearch } from "../../websearch.js"
@@ -29,6 +30,7 @@ export const Plugin = {
effect: Effect.fn("WebSearchTool.Plugin")(function* (ctx: PluginContext) { effect: Effect.fn("WebSearchTool.Plugin")(function* (ctx: PluginContext) {
const permission = yield* Permission.Service const permission = yield* Permission.Service
const forms = yield* Form.Service const forms = yield* Form.Service
const config = yield* Config.Service
const websearch = yield* WebSearch.Service const websearch = yield* WebSearch.Service
yield* ctx.tool yield* ctx.tool
@@ -95,7 +97,9 @@ export const Plugin = {
if (response.status === "cancelled") if (response.status === "cancelled")
return yield* Effect.fail(new Error("Web search cancelled")) return yield* Effect.fail(new Error("Web search cancelled"))
if (response.answer.choice === "disable") { if (response.answer.choice === "disable") {
yield* websearch.select(false) yield* config.update((draft) => {
draft.websearch = false
})
return yield* new WebSearch.DisabledError() return yield* new WebSearch.DisabledError()
} }
const selection = const selection =
@@ -127,7 +131,11 @@ export const Plugin = {
(providerID !== "random" && !providers.some((provider) => provider.id === providerID)) (providerID !== "random" && !providers.some((provider) => provider.id === providerID))
) )
return yield* new WebSearch.ProviderRequiredError() return yield* new WebSearch.ProviderRequiredError()
yield* websearch.select(providerID === "random" ? "random" : WebSearch.ID.make(providerID)) yield* config.update((draft) => {
draft.websearch = {
provider: providerID === "random" ? "random" : WebSearch.ID.make(providerID),
}
})
if (providerID !== "random") return WebSearch.ID.make(providerID) if (providerID !== "random") return WebSearch.ID.make(providerID)
return providers[Math.floor(Math.random() * providers.length)]?.id return providers[Math.floor(Math.random() * providers.length)]?.id
}), }),
@@ -198,10 +206,7 @@ export const Plugin = {
yield* ctx.session.hook("context", (event) => yield* ctx.session.hook("context", (event) =>
Effect.gen(function* () { Effect.gen(function* () {
const disabled = yield* websearch.default().pipe( const disabled = Config.latest(yield* config.entries(), "websearch") === false
Effect.as(false),
Effect.catchTag("WebSearch.Disabled", () => Effect.succeed(true)),
)
if (disabled) delete event.tools[name] if (disabled) delete event.tools[name]
}), }),
) )
+8 -22
View File
@@ -1,10 +1,9 @@
export * as WebSearch from "./websearch.js" export * as WebSearch from "./websearch.js"
import { WebSearch } from "@opencode-ai/schema/websearch" import { WebSearch } from "@opencode-ai/schema/websearch"
import { Context, Effect, Layer, Option, Schema } from "effect" import { Context, Effect, Layer, Schema } from "effect"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node" import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { Bus } from "./bus.js" import { Bus } from "./bus.js"
import { KV } from "./kv.js"
import { State } from "./state.js" import { State } from "./state.js"
export const ID = WebSearch.ID export const ID = WebSearch.ID
@@ -25,10 +24,6 @@ export type Result = WebSearch.Result
export const Response = WebSearch.Response export const Response = WebSearch.Response
export type Response = WebSearch.Response export type Response = WebSearch.Response
export const ProviderKey = "websearch:provider"
export const Selection = Schema.Union([ID, Schema.Literal("random"), Schema.Literal(false)])
export type Selection = typeof Selection.Type
export interface ProviderImplementation extends Provider { export interface ProviderImplementation extends Provider {
readonly execute: (input: ProviderInput) => Effect.Effect<readonly Result[], unknown> readonly execute: (input: ProviderInput) => Effect.Effect<readonly Result[], unknown>
} }
@@ -54,7 +49,6 @@ export type Error = ProviderRequiredError | ProviderNotFoundError | DisabledErro
export interface Interface extends State.Transformable<Draft> { export interface Interface extends State.Transformable<Draft> {
readonly providers: () => Effect.Effect<readonly Provider[]> readonly providers: () => Effect.Effect<readonly Provider[]>
readonly default: () => Effect.Effect<Provider | undefined, DisabledError> readonly default: () => Effect.Effect<Provider | undefined, DisabledError>
readonly select: (selection: Selection) => Effect.Effect<void>
readonly query: (input: Input) => Effect.Effect<Response, Error> readonly query: (input: Input) => Effect.Effect<Response, Error>
} }
@@ -62,14 +56,14 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/We
type Data = { type Data = {
readonly providers: Map<ID, ProviderImplementation> readonly providers: Map<ID, ProviderImplementation>
selection?: Selection selection?: ID | "random" | false
} }
export type Draft = { export type Draft = {
add: (provider: ProviderImplementation) => void add: (provider: ProviderImplementation) => void
default: { default: {
get: () => Selection | undefined get: () => ID | "random" | false | undefined
set: (selection: Selection) => void set: (selection: ID | "random" | false) => void
} }
} }
@@ -77,7 +71,6 @@ const layer = Layer.effect(
Service, Service,
Effect.gen(function* () { Effect.gen(function* () {
const bus = yield* Bus.Service const bus = yield* Bus.Service
const kv = yield* KV.Service
const decodeResults = Schema.decodeUnknownEffect(Schema.Array(Result)) const decodeResults = Schema.decodeUnknownEffect(Schema.Array(Result))
const state = State.create<Data, Draft>({ const state = State.create<Data, Draft>({
initial: () => ({ providers: new Map() }), initial: () => ({ providers: new Map() }),
@@ -98,16 +91,12 @@ const layer = Layer.effect(
const defaultProvider = Effect.fn("WebSearch.default")(function* () { const defaultProvider = Effect.fn("WebSearch.default")(function* () {
const data = state.get() const data = state.get()
const stored = data.selection === undefined ? yield* kv.get(ProviderKey) : undefined if (data.selection === false) return yield* new DisabledError()
const decoded = Schema.decodeUnknownOption(Selection)(stored) if (data.selection === "random") {
if (stored !== undefined && Option.isNone(decoded)) yield* kv.remove(ProviderKey)
const selection = data.selection ?? Option.getOrUndefined(decoded)
if (selection === false) return yield* new DisabledError()
if (selection === "random") {
const providers = Array.from(data.providers.values()) const providers = Array.from(data.providers.values())
return providers[Math.floor(Math.random() * providers.length)] return providers[Math.floor(Math.random() * providers.length)]
} }
return selection ? data.providers.get(selection) : undefined return data.selection ? data.providers.get(data.selection) : undefined
}) })
const resolve = Effect.fn("WebSearch.resolve")(function* (input: Input) { const resolve = Effect.fn("WebSearch.resolve")(function* (input: Input) {
@@ -131,9 +120,6 @@ const layer = Layer.effect(
const provider = yield* defaultProvider() const provider = yield* defaultProvider()
return provider && { id: provider.id, name: provider.name } return provider && { id: provider.id, name: provider.name }
}), }),
select: Effect.fn("WebSearch.select")(function* (selection) {
yield* kv.set(ProviderKey, selection)
}),
query: Effect.fn("WebSearch.query")(function* (input) { query: Effect.fn("WebSearch.query")(function* (input) {
const provider = yield* resolve(input) const provider = yield* resolve(input)
const results = yield* provider.execute({ query: input.query }).pipe( const results = yield* provider.execute({ query: input.query }).pipe(
@@ -149,5 +135,5 @@ const layer = Layer.effect(
export const node = makeLocationNode({ export const node = makeLocationNode({
service: Service, service: Service,
layer, layer,
deps: [Bus.node, KV.node], deps: [Bus.node],
}) })
-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"))
})
@@ -150,16 +150,6 @@ describe("ConfigNormalize", () => {
}) })
test("migrates the legacy small model to the title agent", () => { test("migrates the legacy small model to the title agent", () => {
const result = normalized({ small_model: "anthropic/claude-haiku-4-5" })
expect(result.encoded.agents).toEqual({
title: {
model: { providerID: "anthropic", model: "claude-haiku-4-5" },
},
})
expect(result.diagnostics).toEqual([])
})
test("merges the legacy small model with the title agent", () => {
const result = normalized({ const result = normalized({
small_model: "anthropic/claude-haiku-4-5", small_model: "anthropic/claude-haiku-4-5",
agent: { title: { prompt: "Custom title prompt" } }, agent: { title: { prompt: "Custom title prompt" } },
+2 -23
View File
@@ -44,9 +44,7 @@ describe("PluginSupervisor config", () => {
const plugins = yield* Plugin.Service const plugins = yield* Plugin.Service
yield* ready() yield* ready()
expect( expect(
(yield* plugins.list()) (yield* plugins.list()).map((plugin) => plugin.id).filter((id) => id.startsWith("opencode.provider.")),
.flatMap((plugin) => (plugin.id ? [plugin.id] : []))
.filter((id) => id.startsWith("opencode.provider.")),
).toEqual([Plugin.ID.make("opencode.provider.openai")]) ).toEqual([Plugin.ID.make("opencode.provider.openai")])
}), }),
), ),
@@ -66,20 +64,10 @@ describe("PluginSupervisor config", () => {
Effect.gen(function* () { Effect.gen(function* () {
yield* ready() yield* ready()
const agents = yield* Agent.Service const agents = yield* Agent.Service
const plugins = yield* Plugin.Service
expect(yield* agents.get(Agent.ID.make("configured"))).toMatchObject({ expect(yield* agents.get(Agent.ID.make("configured"))).toMatchObject({
description: "Loaded from config", description: "Loaded from config",
mode: "subagent", mode: "subagent",
}) })
expect((yield* plugins.list()).find((plugin) => plugin.id === "config-promise-plugin")).toEqual({
id: Plugin.ID.make("config-promise-plugin"),
source: {
type: "local",
path: path.join(import.meta.dir, "../plugin/fixtures/config-promise-plugin.ts"),
},
status: "active",
tui: true,
})
}), }),
), ),
) )
@@ -155,7 +143,6 @@ describe("PluginSupervisor config", () => {
Effect.gen(function* () { Effect.gen(function* () {
yield* ready() yield* ready()
const agents = yield* Agent.Service const agents = yield* Agent.Service
const plugins = yield* Plugin.Service
expect(yield* agents.get(Agent.ID.make("configured"))).toMatchObject({ expect(yield* agents.get(Agent.ID.make("configured"))).toMatchObject({
description: "Loaded after invalid plugins", description: "Loaded after invalid plugins",
}) })
@@ -163,12 +150,6 @@ describe("PluginSupervisor config", () => {
path.join(import.meta.dir, "../plugin/fixtures/missing-plugin.ts"), path.join(import.meta.dir, "../plugin/fixtures/missing-plugin.ts"),
path.join(import.meta.dir, "../plugin/fixtures/invalid-plugin.ts"), path.join(import.meta.dir, "../plugin/fixtures/invalid-plugin.ts"),
]) ])
expect(
(yield* plugins.list()).filter((plugin) => plugin.status === "failed").map((plugin) => plugin.source),
).toEqual([
{ type: "local", path: path.join(import.meta.dir, "../plugin/fixtures/missing-plugin.ts") },
{ type: "local", path: path.join(import.meta.dir, "../plugin/fixtures/invalid-plugin.ts") },
])
}), }),
).pipe(Effect.provide(Logger.layer([logger]))) ).pipe(Effect.provide(Logger.layer([logger])))
}) })
@@ -265,12 +246,10 @@ describe("PluginSupervisor config", () => {
Effect.gen(function* () { Effect.gen(function* () {
yield* ready() yield* ready()
const plugins = yield* Plugin.Service const plugins = yield* Plugin.Service
const inventory = yield* plugins.list() const ids = (yield* plugins.list()).map((plugin) => String(plugin.id))
const ids = inventory.map((plugin) => String(plugin.id))
expect(ids).toContain("opencode.agent") expect(ids).toContain("opencode.agent")
expect(ids).toContain("static-sdk") expect(ids).toContain("static-sdk")
expect(ids).not.toContain("config-promise-plugin") expect(ids).not.toContain("config-promise-plugin")
expect(inventory.find((plugin) => plugin.id === "static-sdk")?.source).toEqual({ type: "sdk" })
const agents = yield* Agent.Service const agents = yield* Agent.Service
expect(yield* agents.get(Agent.ID.make("directory"))).toBeUndefined() expect(yield* agents.get(Agent.ID.make("directory"))).toBeUndefined()
+1 -26
View File
@@ -6,7 +6,7 @@ import { expect, test } from "bun:test"
import { SqliteClient } from "@effect/sql-sqlite-bun" import { SqliteClient } from "@effect/sql-sqlite-bun"
import { eq, sql } from "drizzle-orm" import { eq, sql } from "drizzle-orm"
import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core" import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core"
import { Effect, Tracer } from "effect" import { Effect } from "effect"
import type { SqlClient as SqlClientService } from "effect/unstable/sql/SqlClient" import type { SqlClient as SqlClientService } from "effect/unstable/sql/SqlClient"
import { isSqlError } from "effect/unstable/sql/SqlError" import { isSqlError } from "effect/unstable/sql/SqlError"
import { EffectDrizzleSqlite } from "@opencode-ai/core/database/drizzle" import { EffectDrizzleSqlite } from "@opencode-ai/core/database/drizzle"
@@ -49,31 +49,6 @@ test("selects rows through Effect-yieldable query builders", async () => {
) )
}) })
test("suppresses statement spans", async () => {
const spans: Tracer.NativeSpan[] = []
const tracer = Tracer.make({
span(options) {
const span = new Tracer.NativeSpan(options)
spans.push(span)
return span
},
})
await Effect.runPromise(
Effect.gen(function* () {
const db = yield* makeDb
yield* db.transaction((tx) => tx.insert(users).values({ name: "Grace" }))
yield* db.select().from(users)
}).pipe(
Effect.provideService(Tracer.Tracer, tracer),
Effect.provide(SqliteClient.layer({ filename: ":memory:", disableWAL: true })),
Effect.scoped,
),
)
expect(spans.map((span) => span.name)).not.toContain("sql.execute")
})
test("commits successful transactions", async () => { test("commits successful transactions", async () => {
await run( await run(
Effect.gen(function* () { Effect.gen(function* () {
+118 -193
View File
@@ -1,30 +1,41 @@
import fs from "fs/promises" import fs from "fs/promises"
import path from "path" import path from "path"
import { describe, expect } from "bun:test" 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 { 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 { AbsolutePath } from "@opencode-ai/core/schema"
import { Info } from "@opencode-ai/schema/config" import { Npm } from "@opencode-ai/util/npm"
import { Global } from "@opencode-ai/util/global" import { Document, Info } from "@opencode-ai/schema/config"
import { LayerNode } from "@opencode-ai/util/effect/layer-node" import { Config } from "../src/config"
import { Formatter } from "../src/formatter" import { Formatter } from "../src/formatter"
import { Location } from "../src/location" import { Location } from "../src/location"
import { tempGlobalLayer } from "./fixture/global" import { location } from "./fixture/location"
import { tmpdir } from "./fixture/tmpdir" import { tmpdir } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect" import { testEffect } from "./lib/effect"
const it = testEffect( const it = testEffect(Layer.empty)
AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SdkPlugins.node, LocationServiceMap.node]), [
[Global.node, tempGlobalLayer],
]),
)
type ConfigInput = typeof Info.Encoded 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>) { function withTemp<A, E, R>(body: (directory: string) => Effect.Effect<A, E, R>) {
return Effect.acquireUseRelease( return Effect.acquireUseRelease(
Effect.promise(() => tmpdir()), 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>( describe("Formatter", () => {
configured: ConfigInput["formatter"], it.live("does not run formatters marked as disabled in config", () =>
body: (formatter: Formatter.Interface, directory: string) => Effect.Effect<A, E, R>, withTemp((directory) =>
) { Effect.gen(function* () {
return withTemp((directory) => const file = path.join(directory, "test.disabled")
Effect.promise(() => expect(yield* Formatter.Service.use((formatter) => formatter.file(file))).toBe(false)
fs.writeFile(path.join(directory, "opencode.json"), JSON.stringify({ formatter: configured })), }).pipe(
).pipe( Effect.provide(
Effect.andThen( formatterLayer(directory, {
Effect.gen(function* () { disabled: {
const plugins = yield* PluginSupervisor.Service disabled: true,
yield* plugins.flush command: [process.execPath, "-e", "process.exit(0)", "$FILE"],
return yield* body(yield* Formatter.Service, directory) extensions: [".disabled"],
}).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", () =>
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", () => it.live("file() returns false when no formatter runs", () =>
withFormatter(false, (formatter, directory) => withTemp((directory) =>
Effect.gen(function* () { Effect.gen(function* () {
const file = path.join(directory, "test.txt") const file = path.join(directory, "test.txt")
yield* Effect.promise(() => fs.writeFile(file, "x")) 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", () => it.live("loads formatter state per directory", () =>
withFormatter(false, (disabledFormatter, off) => withTemp((off) =>
withFormatter( withTemp((on) =>
{ Effect.gen(function* () {
isolated: { const offFile = path.join(off, "test.isolated")
command: [process.execPath, "-e", "process.exit(0)", "$FILE"], const onFile = path.join(on, "test.isolated")
extensions: [".isolated"], const disabled = yield* Formatter.Service.use((formatter) => formatter.file(offFile)).pipe(
}, Effect.provide(formatterLayer(off, false)),
}, )
(enabledFormatter, on) => const enabled = yield* Formatter.Service.use((formatter) => formatter.file(onFile)).pipe(
Effect.gen(function* () { Effect.provide(
const offFile = path.join(off, "test.isolated") formatterLayer(on, {
const onFile = path.join(on, "test.isolated") isolated: {
const disabled = yield* disabledFormatter.file(offFile) command: [process.execPath, "-e", "process.exit(0)", "$FILE"],
const enabled = yield* enabledFormatter.file(onFile) extensions: [".isolated"],
expect(disabled).toBe(false) },
expect(enabled).toBe(true) }),
}), ),
)
expect(disabled).toBe(false)
expect(enabled).toBe(true)
}),
), ),
), ),
) )
it.live("stops after the first matching formatter succeeds", () => it.live("stops after the first matching formatter succeeds", () =>
withFormatter( withTemp((directory) =>
{ Effect.gen(function* () {
first: { const file = path.join(directory, "test.seq")
command: [ yield* Effect.promise(() => fs.writeFile(file, "x"))
process.execPath, expect(yield* Formatter.Service.use((formatter) => formatter.file(file))).toBe(true)
"-e", expect(yield* Effect.promise(() => fs.readFile(file, "utf8"))).toBe("xA")
"const fs = require('fs'); const file = process.argv.at(-1); fs.appendFileSync(file, 'A')", }).pipe(
"$FILE", Effect.provide(
], formatterLayer(directory, {
extensions: [".seq"], first: {
}, command: [
second: { process.execPath,
command: [ "-e",
process.execPath, "const fs = require('fs'); const file = process.argv.at(-1); fs.appendFileSync(file, 'A')",
"-e", "$FILE",
"const fs = require('fs'); const file = process.argv.at(-1); fs.appendFileSync(file, 'B')", ],
"$FILE", extensions: [".seq"],
], },
extensions: [".seq"], second: {
}, command: [
}, process.execPath,
(formatter, directory) => "-e",
Effect.gen(function* () { "const fs = require('fs'); const file = process.argv.at(-1); fs.appendFileSync(file, 'B')",
const file = path.join(directory, "test.seq") "$FILE",
yield* Effect.promise(() => fs.writeFile(file, "x")) ],
expect(yield* formatter.file(file)).toBe(true) extensions: [".seq"],
expect(yield* Effect.promise(() => fs.readFile(file, "utf8"))).toBe("xA") },
}), }),
),
),
), ),
) )
it.live("tries the next matching formatter when the first fails", () => it.live("tries the next matching formatter when the first fails", () =>
withFormatter( withTemp((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"],
},
},
(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) =>
Effect.gen(function* () { Effect.gen(function* () {
const command = { suffix: "A" } const file = path.join(directory, "test.fallback")
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")
yield* Effect.promise(() => fs.writeFile(file, "x")) yield* Effect.promise(() => fs.writeFile(file, "x"))
expect(yield* formatter.file(file)).toBe(true) expect(yield* Formatter.Service.use((formatter) => formatter.file(file))).toBe(true)
expect(yield* Effect.promise(() => fs.readFile(file, "utf8"))).toBe("xB")
command.suffix = "B" }).pipe(
yield* formatter.reload() Effect.provide(
formatterLayer(directory, {
expect(yield* formatter.file(file)).toBe(true) first: {
expect(yield* Effect.promise(() => fs.readFile(file, "utf8"))).toBe("xAB") command: [process.execPath, "-e", "process.exit(1)", "$FILE"],
}), extensions: [".fallback"],
), },
) second: {
command: [
it.live("does not cache a command resolved before reload", () => process.execPath,
withFormatter(false, (formatter, directory) => "-e",
Effect.gen(function* () { "const fs = require('fs'); const file = process.argv.at(-1); fs.appendFileSync(file, 'B')",
const resolving = yield* Deferred.make<void>() "$FILE",
const release = yield* Deferred.make<void>() ],
const command = { suffix: "A" } extensions: [".fallback"],
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")
}),
), ),
) )
}) })
+2 -10
View File
@@ -428,18 +428,10 @@ describe("LocationServiceMap", () => {
), ),
) )
for (let attempt = 0; attempt < 100; attempt++) { for (let attempt = 0; attempt < 100; attempt++) {
if ((yield* registry.list()).some((plugin) => plugin.status === "failed")) break if ((yield* registry.list()).length === 0) break
yield* Effect.sleep("20 millis") yield* Effect.sleep("20 millis")
} }
expect(yield* registry.list()).toEqual([ expect(yield* registry.list()).toEqual([])
{
id: Plugin.ID.make("failing-plugin"),
source: { type: "local", path: path.join(import.meta.dir, "plugin/fixtures/failing-plugin.ts") },
status: "failed",
error: expect.stringContaining("plugin failed"),
tui: false,
},
])
yield* Effect.promise(() => fs.writeFile(file, JSON.stringify({ plugins: ["-*", "opencode.agent"] }))) yield* Effect.promise(() => fs.writeFile(file, JSON.stringify({ plugins: ["-*", "opencode.agent"] })))
for (let attempt = 0; attempt < 100; attempt++) { for (let attempt = 0; attempt < 100; attempt++) {
+13 -48
View File
@@ -89,7 +89,13 @@ describe("Plugin", () => {
yield* host.mcp.connect({ location, server: "routed" }).pipe(Effect.orDie) yield* host.mcp.connect({ location, server: "routed" }).pipe(Effect.orDie)
yield* host.mcp.disconnect({ location, server: "routed" }).pipe(Effect.orDie) yield* host.mcp.disconnect({ location, server: "routed" }).pipe(Effect.orDie)
expect((yield* host.mcp.list({ location }).pipe(Effect.orDie)).location.directory).toBe(target) expect((yield* host.mcp.list({ location }).pipe(Effect.orDie)).location.directory).toBe(target)
expect(routed).toEqual(["add:/target", "remove:/target", "connect:/target", "disconnect:/target", "list:/target"]) expect(routed).toEqual([
"add:/target",
"remove:/target",
"connect:/target",
"disconnect:/target",
"list:/target",
])
}), }),
) )
@@ -132,22 +138,9 @@ describe("Plugin", () => {
expect(updates).toBe(2) expect(updates).toBe(2)
expect((yield* agents.get(Agent.ID.make("configured")))?.description).toBe("second") expect((yield* agents.get(Agent.ID.make("configured")))?.description).toBe("second")
yield* plugins.activate(
[versioned(managed(), "2")],
[
{
source: { type: "package", package: "broken" },
status: "failed",
error: "failed to resolve",
tui: false,
},
],
)
expect(updates).toBe(3)
yield* plugins.activate([]) yield* plugins.activate([])
expect(yield* agents.get(Agent.ID.make("configured"))).toBeUndefined() expect(yield* agents.get(Agent.ID.make("configured"))).toBeUndefined()
expect(updates).toBe(4) expect(updates).toBe(3)
yield* unsubscribe yield* unsubscribe
}), }),
) )
@@ -167,7 +160,7 @@ describe("Plugin", () => {
.pipe(Effect.exit) .pipe(Effect.exit)
expect(Exit.isFailure(result)).toBe(true) expect(Exit.isFailure(result)).toBe(true)
expect(yield* plugins.list()).toEqual([{ id: active, source: { type: "builtin" }, status: "active", tui: false }]) expect(yield* plugins.list()).toEqual([{ id: active }])
}), }),
) )
@@ -196,24 +189,12 @@ describe("Plugin", () => {
}) })
yield* plugins.activate([versioned(good), versioned(bad)]) yield* plugins.activate([versioned(good), versioned(bad)])
expect(yield* plugins.list()).toEqual([ expect(yield* plugins.list()).toEqual([{ id: Plugin.ID.make("good") }])
{ id: Plugin.ID.make("good"), source: { type: "builtin" }, status: "active", tui: false },
{
id: Plugin.ID.make("bad"),
source: { type: "builtin" },
status: "failed",
error: expect.stringContaining("materialization failed"),
tui: false,
},
])
expect((yield* agents.get(Agent.ID.make("configured")))?.description).toBe("loaded") expect((yield* agents.get(Agent.ID.make("configured")))?.description).toBe("loaded")
fail = false fail = false
yield* plugins.activate([versioned(good), versioned(bad, "2")]) yield* plugins.activate([versioned(good), versioned(bad, "2")])
expect(yield* plugins.list()).toEqual([ expect(yield* plugins.list()).toEqual([{ id: Plugin.ID.make("good") }, { id: Plugin.ID.make("bad") }])
{ id: Plugin.ID.make("good"), source: { type: "builtin" }, status: "active", tui: false },
{ id: Plugin.ID.make("bad"), source: { type: "builtin" }, status: "active", tui: false },
])
}), }),
) )
@@ -248,15 +229,7 @@ describe("Plugin", () => {
yield* plugins.activate([versioned(previous)]) yield* plugins.activate([versioned(previous)])
yield* plugins.activate([versioned(replacement, "2")]) yield* plugins.activate([versioned(replacement, "2")])
expect(yield* plugins.list()).toEqual([ expect(yield* plugins.list()).toEqual([{ id: Plugin.ID.make("managed") }])
{
id: Plugin.ID.make("managed"),
source: { type: "builtin" },
status: "failed",
error: expect.stringContaining("replacement failed"),
tui: false,
},
])
expect((yield* agents.get(Agent.ID.make("configured")))?.description).toBe("previous") expect((yield* agents.get(Agent.ID.make("configured")))?.description).toBe("previous")
}), }),
) )
@@ -288,15 +261,7 @@ describe("Plugin", () => {
yield* plugins.activate([versioned(previous)]) yield* plugins.activate([versioned(previous)])
yield* plugins.activate([versioned(replacement, "2")]) yield* plugins.activate([versioned(replacement, "2")])
expect(yield* plugins.list()).toEqual([ expect(yield* plugins.list()).toEqual([])
{
id: Plugin.ID.make("managed"),
source: { type: "builtin" },
status: "failed",
error: expect.stringContaining("replacement failed"),
tui: false,
},
])
expect(yield* agents.get(Agent.ID.make("configured"))).toBeUndefined() expect(yield* agents.get(Agent.ID.make("configured"))).toBeUndefined()
}), }),
) )
@@ -2,7 +2,6 @@ import { Plugin } from "@opencode-ai/plugin"
export default Plugin.define({ export default Plugin.define({
id: "config-promise-plugin", id: "config-promise-plugin",
tui: true,
setup: async (ctx) => { setup: async (ctx) => {
await ctx.agent.transform((agents) => { await ctx.agent.transform((agents) => {
agents.update("configured", (agent) => { agents.update("configured", (agent) => {
+2 -42
View File
@@ -421,48 +421,8 @@ describe("ModelsDevPlugin", () => {
expect(yield* integrations.get(Integration.ID.make("google-vertex"))).toBeDefined() expect(yield* integrations.get(Integration.ID.make("google-vertex"))).toBeDefined()
expect(yield* integrations.get(Integration.ID.make("azure-cognitive-services"))).toBeUndefined() expect(yield* integrations.get(Integration.ID.make("azure-cognitive-services"))).toBeUndefined()
expect(yield* integrations.get(Integration.ID.make("google-vertex-anthropic"))).toBeUndefined() expect(yield* integrations.get(Integration.ID.make("google-vertex-anthropic"))).toBeUndefined()
expect(ProviderPlugins.map((plugin) => plugin.id)).not.toContain("opencode.provider.azure.cognitive.services") expect(ProviderPlugins.map((plugin) => plugin.id)).not.toContain("opencode.provider.azure-cognitive-services")
expect(ProviderPlugins.map((plugin) => plugin.id)).not.toContain("opencode.provider.google.vertex.anthropic") expect(ProviderPlugins.map((plugin) => plugin.id)).not.toContain("opencode.provider.google-vertex-anthropic")
}),
)
it.effect("advertises only key-bearing Google Vertex environment variables", () =>
Effect.gen(function* () {
const integrations = yield* Integration.Service
const catalog = yield* Catalog.Service
yield* ModelsDevPlugin.effect(
host({
catalog: catalogHost(catalog),
integration: integrationHost(integrations),
}),
).pipe(
Effect.provideService(
ModelsDev.Service,
ModelsDev.Service.of({
get: () =>
Effect.succeed([
{
info: {
id: Provider.ID.make("google-vertex"),
name: "Google Vertex",
activation: "auto",
package: Provider.aisdk("@ai-sdk/google-vertex"),
},
environment: ["GOOGLE_VERTEX_PROJECT", "GOOGLE_VERTEX_LOCATION", "GOOGLE_APPLICATION_CREDENTIALS"],
models: [],
},
] satisfies readonly ModelsDev.Snapshot[]),
refresh: () => Effect.void,
}),
),
)
// Vertex authenticates through ADC; project, location, and the credentials
// file path are configuration, not API keys.
expect(yield* integrations.get(Integration.ID.make("google-vertex"))).toMatchObject({
methods: [{ type: "key" }, { type: "env", names: ["GOOGLE_VERTEX_API_KEY"] }],
})
}), }),
) )
+4 -16
View File
@@ -320,14 +320,10 @@ describe("fromPromise", () => {
define({ define({
id: "promise-session-http", id: "promise-session-http",
setup: async (ctx) => { setup: async (ctx) => {
await ctx.session.hook( await ctx.session.hook("http.request", (event) => {
"http.request", event.request = new Request("https://provider.test/changed", event.request)
(event) => { event.request.headers.set("x-hook", "promise")
event.request = new Request("https://provider.test/changed", event.request) })
event.request.headers.set("x-hook", "promise")
},
{ providerID: "test" },
)
await ctx.session.hook("http.response", async (event) => { await ctx.session.hook("http.response", async (event) => {
event.response = new Response(`${await event.response.text()}-response`, { event.response = new Response(`${await event.response.text()}-response`, {
status: event.response.status, status: event.response.status,
@@ -346,11 +342,6 @@ describe("fromPromise", () => {
...context, ...context,
request: new Request("https://provider.test", { method: "POST", body: "payload" }), 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", { const response = yield* hooks.trigger("session", "http.response", {
...context, ...context,
request: request.request, request: request.request,
@@ -358,9 +349,6 @@ describe("fromPromise", () => {
}) })
expect(request.request.url).toBe("https://provider.test/changed") 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") expect(yield* Effect.promise(() => response.response.text())).toBe("promise-response")
}), }),
) )
@@ -141,52 +141,6 @@ describe("GoogleVertexPlugin", () => {
), ),
) )
it.effect("enables the provider when a project resolves and leaves it automatic otherwise", () =>
withEnv(
{
GOOGLE_VERTEX_PROJECT: undefined,
GOOGLE_CLOUD_PROJECT: undefined,
GCP_PROJECT: undefined,
GCLOUD_PROJECT: undefined,
},
() =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
yield* catalog.transform((catalog) =>
catalog.provider.update(Provider.ID.make("google-vertex"), (provider) => {
provider.package = Provider.aisdk("@ai-sdk/google-vertex")
}),
)
yield* addPlugin()
expect(required(yield* catalog.provider.get(Provider.ID.make("google-vertex"))).activation).toBe("auto")
}),
),
)
it.effect("enables the provider when a project resolves from env", () =>
withEnv(
{
GOOGLE_VERTEX_PROJECT: undefined,
GOOGLE_CLOUD_PROJECT: "adc-project",
GCP_PROJECT: undefined,
GCLOUD_PROJECT: undefined,
},
() =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
yield* catalog.transform((catalog) =>
catalog.provider.update(Provider.ID.make("google-vertex"), (provider) => {
provider.package = Provider.aisdk("@ai-sdk/google-vertex")
}),
)
yield* addPlugin()
expect(required(yield* catalog.provider.get(Provider.ID.make("google-vertex"))).activation).toBe("enabled")
}),
),
)
it.effect("resolves the advertised GOOGLE_VERTEX_PROJECT env for provider updates and SDKs", () => it.effect("resolves the advertised GOOGLE_VERTEX_PROJECT env for provider updates and SDKs", () =>
withEnv( withEnv(
{ {
@@ -1,25 +1,17 @@
import { Money } from "@opencode-ai/schema/money" import { Money } from "@opencode-ai/schema/money"
import { Agent } from "@opencode-ai/schema/agent" import { Agent } from "@opencode-ai/schema/agent"
import { Session } from "@opencode-ai/schema/session" import { Session } from "@opencode-ai/schema/session"
import { OpenAIResponses } from "@opencode-ai/ai/protocols/openai-responses"
import { describe, expect } from "bun:test" import { describe, expect } from "bun:test"
import { ConfigProvider, DateTime, Effect } from "effect" import { Effect } from "effect"
import { Catalog } from "@opencode-ai/core/catalog" import { Catalog } from "@opencode-ai/core/catalog"
import { Credential } from "@opencode-ai/core/credential" import { Credential } from "@opencode-ai/core/credential"
import { Integration } from "@opencode-ai/core/integration" import { Integration } from "@opencode-ai/core/integration"
import { Location } from "@opencode-ai/core/location"
import { Model } from "@opencode-ai/core/model" import { Model } from "@opencode-ai/core/model"
import { Plugin } from "@opencode-ai/core/plugin" import { Plugin } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host" import { PluginHost } from "@opencode-ai/core/plugin/host"
import { PluginHooks } from "@opencode-ai/core/plugin/hooks" 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 { OpenAIPlugin } from "@opencode-ai/core/plugin/provider/openai"
import { Project } from "@opencode-ai/core/project"
import { Provider } from "@opencode-ai/core/provider" 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 { testEffect } from "../lib/effect"
import { PluginTestLayer } from "./fixture" import { PluginTestLayer } from "./fixture"
@@ -32,33 +24,19 @@ const addPlugin = Effect.fn(function* () {
yield* OpenAIPlugin.effect(host).pipe(Effect.provideService(Integration.Service, integrations)) 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 { function required<T>(value: T | undefined): T {
if (value === undefined) throw new Error("Expected value") if (value === undefined) throw new Error("Expected value")
return value return value
} }
const request = Effect.fn(function* (providerID: Provider.ID, baseURL: string) { const http = Effect.fn(function* (providerID: Provider.ID, url: string) {
const hooks = yield* PluginHooks.Service const event = yield* (yield* PluginHooks.Service).trigger("session", "http.request", {
const event = yield* hooks.trigger("session", "model.request", {
sessionID: Session.ID.make("ses_test"), sessionID: Session.ID.make("ses_test"),
agent: Agent.ID.make("build"), agent: Agent.ID.make("build"),
model: Model.Ref.make({ providerID, id: Model.ID.make("gpt-5.5") }), model: Model.Ref.make({ providerID, id: Model.ID.make("gpt-5.5") }),
baseURL, request: new Request(url, { method: "POST", body: "{}" }),
headers: {},
}) })
return { return { url: event.request.url, headers: Object.fromEntries(event.request.headers.entries()) }
baseURL: event.baseURL,
headers: event.headers,
hasHttpHooks:
(yield* hooks.has("session", "http.request", providerID)) ||
(yield* hooks.has("session", "http.response", providerID)),
}
}) })
describe("OpenAIPlugin", () => { describe("OpenAIPlugin", () => {
@@ -132,19 +110,18 @@ describe("OpenAIPlugin", () => {
}) })
yield* addPlugin() 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 custom = yield* request(Provider.ID.make("custom-openai"), "https://custom.example/v1") const custom = yield* http(Provider.ID.make("custom-openai"), "https://custom.example/v1/responses")
const proxy = yield* request(Provider.ID.openai, "https://proxy.example/v1?region=us") const proxy = yield* http(Provider.ID.openai, "https://proxy.example/v1/responses?region=us")
const provider = required(yield* catalog.provider.get(Provider.ID.openai)) const provider = required(yield* catalog.provider.get(Provider.ID.openai))
expect(provider.package).toBe("@opencode-ai/ai/providers/openai") expect(provider.package).toBe("@opencode-ai/ai/providers/openai")
expect(provider.settings).toMatchObject({ baseURL: "https://chatgpt.com/backend-api/codex" }) expect(provider.settings).toMatchObject({ baseURL: "https://chatgpt.com/backend-api/codex" })
expect(provider.headers).toMatchObject({ originator: "opencode", "chatgpt-account-id": "acct_123" }) expect(provider.headers).toMatchObject({ originator: "opencode", "chatgpt-account-id": "acct_123" })
expect(direct.baseURL).toBe("https://chatgpt.com/backend-api/codex") expect(request.url).toBe("https://chatgpt.com/backend-api/codex/responses")
expect(direct.headers).toMatchObject({ originator: "opencode", "session-id": "ses_test" }) expect(request.headers).toMatchObject({ originator: "opencode", "session-id": "ses_test" })
expect(direct.hasHttpHooks).toBe(false)
expect(custom.headers).not.toHaveProperty("originator") 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" }) 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"))) 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") expect(eligible.package).toBe("@opencode-ai/ai/providers/openai")
@@ -190,77 +167,16 @@ describe("OpenAIPlugin", () => {
}) })
yield* addPlugin() 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 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"))) 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.package).toBe("@opencode-ai/ai/providers/openai")
expect(model.enabled).toBe(true) expect(model.enabled).toBe(true)
expect(model.limit).toEqual({ context: 1_050_000, input: 922_000, output: 128_000 }) expect(model.limit).toEqual({ context: 1_050_000, input: 922_000, output: 128_000 })
expect(direct.headers).not.toHaveProperty("originator") expect(request.headers).not.toHaveProperty("originator")
expect(direct.hasHttpHooks).toBe(false)
expect(provider.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) 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()
}),
)
}) })
@@ -43,10 +43,10 @@ function withEnv<A, E, R>(vars: Record<string, string | undefined>, effect: () =
describe("SnowflakeCortexPlugin", () => { describe("SnowflakeCortexPlugin", () => {
it.effect("is registered in ProviderPlugins before OpenAICompatiblePlugin", () => it.effect("is registered in ProviderPlugins before OpenAICompatiblePlugin", () =>
Effect.sync(() => { Effect.sync(() => {
expect(ProviderPlugins.map((item) => item.id)).toContain("opencode.provider.snowflake.cortex") expect(ProviderPlugins.map((item) => item.id)).toContain("opencode.provider.snowflake-cortex")
const ids = ProviderPlugins.map((p) => p.id) const ids = ProviderPlugins.map((p) => p.id)
expect(ids.indexOf("opencode.provider.snowflake.cortex")).toBeLessThan( expect(ids.indexOf("opencode.provider.snowflake-cortex")).toBeLessThan(
ids.indexOf("opencode.provider.openai.compatible"), ids.indexOf("opencode.provider.openai-compatible"),
) )
}), }),
) )
@@ -48,12 +48,12 @@ describe("SystemPromptPlugin", () => {
test("uses granular IDs with a common prefix", () => { test("uses granular IDs with a common prefix", () => {
expect(SystemPromptPlugin.Plugins.map((plugin) => plugin.id)).toEqual([ expect(SystemPromptPlugin.Plugins.map((plugin) => plugin.id)).toEqual([
"opencode.prompt.openai", "opencode.system-prompt.openai",
"opencode.prompt.google", "opencode.system-prompt.google",
"opencode.prompt.anthropic", "opencode.system-prompt.anthropic",
"opencode.prompt.kimi", "opencode.system-prompt.kimi",
"opencode.prompt.arcee", "opencode.system-prompt.arcee",
"opencode.prompt.meta", "opencode.system-prompt.meta",
]) ])
}) })
-20
View File
@@ -1,20 +0,0 @@
import { describe, expect, test } from "bun:test"
import { Effect } from "effect"
import { Provider } from "@opencode-ai/core/provider"
describe("Provider", () => {
test("loads Vertex native provider entrypoints", async () => {
const packages = [
"@opencode-ai/ai/providers/google-vertex",
"@opencode-ai/ai/providers/google-vertex/gemini",
"@opencode-ai/ai/providers/google-vertex/chat",
"@opencode-ai/ai/providers/google-vertex/responses",
"@opencode-ai/ai/providers/google-vertex/messages",
]
for (const specifier of packages) {
const loaded = await Effect.runPromise(Provider.loadPackage(specifier))
expect(loaded.model).toBeFunction()
}
})
})

Some files were not shown because too many files have changed in this diff Show More