From 4e7a60dac60bf4cad94f225e54450ac92fcce7ee Mon Sep 17 00:00:00 2001 From: opencode Date: Fri, 15 May 2026 00:39:54 +0000 Subject: [PATCH 001/164] sync release versions for v1.14.51 --- bun.lock | 34 +++++++++++++------------- packages/app/package.json | 2 +- packages/console/app/package.json | 2 +- packages/console/core/package.json | 2 +- packages/console/function/package.json | 2 +- packages/console/mail/package.json | 2 +- packages/core/package.json | 2 +- packages/desktop/package.json | 2 +- packages/enterprise/package.json | 2 +- packages/extensions/zed/extension.toml | 12 ++++----- packages/function/package.json | 2 +- packages/http-recorder/package.json | 2 +- packages/llm/package.json | 2 +- packages/opencode/package.json | 2 +- packages/plugin/package.json | 2 +- packages/sdk/js/package.json | 2 +- packages/slack/package.json | 2 +- packages/ui/package.json | 2 +- packages/web/package.json | 2 +- sdks/vscode/package.json | 2 +- 20 files changed, 41 insertions(+), 41 deletions(-) diff --git a/bun.lock b/bun.lock index a22033aa9da..f1ebf9d9a2f 100644 --- a/bun.lock +++ b/bun.lock @@ -29,7 +29,7 @@ }, "packages/app": { "name": "@opencode-ai/app", - "version": "1.14.50", + "version": "1.14.51", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/core": "workspace:*", @@ -84,7 +84,7 @@ }, "packages/console/app": { "name": "@opencode-ai/console-app", - "version": "1.14.50", + "version": "1.14.51", "dependencies": { "@cloudflare/vite-plugin": "1.15.2", "@ibm/plex": "6.4.1", @@ -119,7 +119,7 @@ }, "packages/console/core": { "name": "@opencode-ai/console-core", - "version": "1.14.50", + "version": "1.14.51", "dependencies": { "@aws-sdk/client-sts": "3.782.0", "@jsx-email/render": "1.1.1", @@ -146,7 +146,7 @@ }, "packages/console/function": { "name": "@opencode-ai/console-function", - "version": "1.14.50", + "version": "1.14.51", "dependencies": { "@ai-sdk/anthropic": "3.0.64", "@ai-sdk/openai": "3.0.48", @@ -168,7 +168,7 @@ }, "packages/console/mail": { "name": "@opencode-ai/console-mail", - "version": "1.14.50", + "version": "1.14.51", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", @@ -192,7 +192,7 @@ }, "packages/core": { "name": "@opencode-ai/core", - "version": "1.14.50", + "version": "1.14.51", "bin": { "opencode": "./bin/opencode", }, @@ -253,7 +253,7 @@ }, "packages/desktop": { "name": "@opencode-ai/desktop", - "version": "1.14.50", + "version": "1.14.51", "dependencies": { "drizzle-orm": "catalog:", "effect": "catalog:", @@ -307,7 +307,7 @@ }, "packages/enterprise": { "name": "@opencode-ai/enterprise", - "version": "1.14.50", + "version": "1.14.51", "dependencies": { "@opencode-ai/core": "workspace:*", "@opencode-ai/ui": "workspace:*", @@ -337,7 +337,7 @@ }, "packages/function": { "name": "@opencode-ai/function", - "version": "1.14.50", + "version": "1.14.51", "dependencies": { "@octokit/auth-app": "8.0.1", "@octokit/rest": "catalog:", @@ -353,7 +353,7 @@ }, "packages/http-recorder": { "name": "@opencode-ai/http-recorder", - "version": "1.14.50", + "version": "1.14.51", "dependencies": { "@effect/platform-node": "catalog:", "effect": "catalog:", @@ -366,7 +366,7 @@ }, "packages/llm": { "name": "@opencode-ai/llm", - "version": "1.14.50", + "version": "1.14.51", "dependencies": { "@smithy/eventstream-codec": "4.2.14", "@smithy/util-utf8": "4.2.2", @@ -384,7 +384,7 @@ }, "packages/opencode": { "name": "opencode", - "version": "1.14.50", + "version": "1.14.51", "bin": { "opencode": "./bin/opencode", }, @@ -520,7 +520,7 @@ }, "packages/plugin": { "name": "@opencode-ai/plugin", - "version": "1.14.50", + "version": "1.14.51", "dependencies": { "@opencode-ai/sdk": "workspace:*", "effect": "catalog:", @@ -558,7 +558,7 @@ }, "packages/sdk/js": { "name": "@opencode-ai/sdk", - "version": "1.14.50", + "version": "1.14.51", "dependencies": { "cross-spawn": "catalog:", }, @@ -573,7 +573,7 @@ }, "packages/slack": { "name": "@opencode-ai/slack", - "version": "1.14.50", + "version": "1.14.51", "dependencies": { "@opencode-ai/sdk": "workspace:*", "@slack/bolt": "^3.17.1", @@ -608,7 +608,7 @@ }, "packages/ui": { "name": "@opencode-ai/ui", - "version": "1.14.50", + "version": "1.14.51", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/core": "workspace:*", @@ -657,7 +657,7 @@ }, "packages/web": { "name": "@opencode-ai/web", - "version": "1.14.50", + "version": "1.14.51", "dependencies": { "@astrojs/cloudflare": "12.6.3", "@astrojs/markdown-remark": "6.3.1", diff --git a/packages/app/package.json b/packages/app/package.json index c371427e73f..f20bbf406f3 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/app", - "version": "1.14.50", + "version": "1.14.51", "description": "", "type": "module", "exports": { diff --git a/packages/console/app/package.json b/packages/console/app/package.json index 8a906c102fc..a84909b24c3 100644 --- a/packages/console/app/package.json +++ b/packages/console/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-app", - "version": "1.14.50", + "version": "1.14.51", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/console/core/package.json b/packages/console/core/package.json index e8585f20def..fe648d32510 100644 --- a/packages/console/core/package.json +++ b/packages/console/core/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/console-core", - "version": "1.14.50", + "version": "1.14.51", "private": true, "type": "module", "license": "MIT", diff --git a/packages/console/function/package.json b/packages/console/function/package.json index 3ebebd2b90c..2ed10a8c29e 100644 --- a/packages/console/function/package.json +++ b/packages/console/function/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-function", - "version": "1.14.50", + "version": "1.14.51", "$schema": "https://json.schemastore.org/package.json", "private": true, "type": "module", diff --git a/packages/console/mail/package.json b/packages/console/mail/package.json index baac89ebfd5..8f46ecbc6a2 100644 --- a/packages/console/mail/package.json +++ b/packages/console/mail/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-mail", - "version": "1.14.50", + "version": "1.14.51", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", diff --git a/packages/core/package.json b/packages/core/package.json index ab540238b34..69257ed102b 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.14.50", + "version": "1.14.51", "name": "@opencode-ai/core", "type": "module", "license": "MIT", diff --git a/packages/desktop/package.json b/packages/desktop/package.json index 978997228f3..87788a83459 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@opencode-ai/desktop", "private": true, - "version": "1.14.50", + "version": "1.14.51", "type": "module", "license": "MIT", "homepage": "https://opencode.ai", diff --git a/packages/enterprise/package.json b/packages/enterprise/package.json index bad16ab0b33..6ff718ee2db 100644 --- a/packages/enterprise/package.json +++ b/packages/enterprise/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/enterprise", - "version": "1.14.50", + "version": "1.14.51", "private": true, "type": "module", "license": "MIT", diff --git a/packages/extensions/zed/extension.toml b/packages/extensions/zed/extension.toml index 782a66409c8..2fdd111f86f 100644 --- a/packages/extensions/zed/extension.toml +++ b/packages/extensions/zed/extension.toml @@ -1,7 +1,7 @@ id = "opencode" name = "OpenCode" description = "The open source coding agent." -version = "1.14.50" +version = "1.14.51" schema_version = 1 authors = ["Anomaly"] repository = "https://github.com/anomalyco/opencode" @@ -11,26 +11,26 @@ name = "OpenCode" icon = "./icons/opencode.svg" [agent_servers.opencode.targets.darwin-aarch64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.50/opencode-darwin-arm64.zip" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.51/opencode-darwin-arm64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.darwin-x86_64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.50/opencode-darwin-x64.zip" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.51/opencode-darwin-x64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.linux-aarch64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.50/opencode-linux-arm64.tar.gz" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.51/opencode-linux-arm64.tar.gz" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.linux-x86_64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.50/opencode-linux-x64.tar.gz" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.51/opencode-linux-x64.tar.gz" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.windows-x86_64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.50/opencode-windows-x64.zip" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.51/opencode-windows-x64.zip" cmd = "./opencode.exe" args = ["acp"] diff --git a/packages/function/package.json b/packages/function/package.json index 00350005bd5..930ac534cf5 100644 --- a/packages/function/package.json +++ b/packages/function/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/function", - "version": "1.14.50", + "version": "1.14.51", "$schema": "https://json.schemastore.org/package.json", "private": true, "type": "module", diff --git a/packages/http-recorder/package.json b/packages/http-recorder/package.json index ab22d691099..ef5ae3ad307 100644 --- a/packages/http-recorder/package.json +++ b/packages/http-recorder/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.14.50", + "version": "1.14.51", "name": "@opencode-ai/http-recorder", "type": "module", "license": "MIT", diff --git a/packages/llm/package.json b/packages/llm/package.json index f68b262f0fc..a51bedfd0ab 100644 --- a/packages/llm/package.json +++ b/packages/llm/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.14.50", + "version": "1.14.51", "name": "@opencode-ai/llm", "type": "module", "license": "MIT", diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 40bd3adc163..2412911ef9c 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.14.50", + "version": "1.14.51", "name": "opencode", "type": "module", "license": "MIT", diff --git a/packages/plugin/package.json b/packages/plugin/package.json index 2c558bf7846..35462626459 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/plugin", - "version": "1.14.50", + "version": "1.14.51", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/sdk/js/package.json b/packages/sdk/js/package.json index b021dfce40b..c854526bab4 100644 --- a/packages/sdk/js/package.json +++ b/packages/sdk/js/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/sdk", - "version": "1.14.50", + "version": "1.14.51", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/slack/package.json b/packages/slack/package.json index f6a14b46f0b..9b9d8659c43 100644 --- a/packages/slack/package.json +++ b/packages/slack/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/slack", - "version": "1.14.50", + "version": "1.14.51", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/ui/package.json b/packages/ui/package.json index dda7cb6b654..39515fbb2a7 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/ui", - "version": "1.14.50", + "version": "1.14.51", "type": "module", "license": "MIT", "exports": { diff --git a/packages/web/package.json b/packages/web/package.json index 448e0e0e5ee..4bed593062a 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -2,7 +2,7 @@ "name": "@opencode-ai/web", "type": "module", "license": "MIT", - "version": "1.14.50", + "version": "1.14.51", "scripts": { "dev": "astro dev", "dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev", diff --git a/sdks/vscode/package.json b/sdks/vscode/package.json index 217251b39ab..918ccc99e93 100644 --- a/sdks/vscode/package.json +++ b/sdks/vscode/package.json @@ -2,7 +2,7 @@ "name": "opencode", "displayName": "opencode", "description": "opencode for VS Code", - "version": "1.14.50", + "version": "1.14.51", "publisher": "sst-dev", "repository": { "type": "git", From 73cdba959b81ebf340180477ed03d4a2ee304ac3 Mon Sep 17 00:00:00 2001 From: Luna Seemann Date: Fri, 15 May 2026 02:48:29 +0200 Subject: [PATCH 002/164] feat(desktop): auto-hide menu bar on Linux and Windows (#27618) --- packages/desktop/src/main/windows.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/desktop/src/main/windows.ts b/packages/desktop/src/main/windows.ts index 41abfc784df..3150a5237bf 100644 --- a/packages/desktop/src/main/windows.ts +++ b/packages/desktop/src/main/windows.ts @@ -84,6 +84,7 @@ export function createMainWindow() { width: state.width, height: state.height, show: false, + autoHideMenuBar: true, title: "OpenCode", icon: iconPath(), backgroundColor, @@ -142,6 +143,7 @@ export function createLoadingWindow() { resizable: false, center: true, show: true, + autoHideMenuBar: true, icon: iconPath(), backgroundColor, ...(process.platform === "darwin" ? { titleBarStyle: "hidden" as const } : {}), From e11e089e42200fee8399fdcf15946032411868ae Mon Sep 17 00:00:00 2001 From: Dax Date: Thu, 14 May 2026 20:50:23 -0400 Subject: [PATCH 003/164] Add Effect-native core event system (#27415) --- packages/core/src/catalog.ts | 25 +- packages/core/src/event.ts | 157 +++ packages/core/src/instance-layer.ts | 12 - packages/core/src/instance.ts | 10 - packages/core/src/location-layer.ts | 12 + packages/core/src/location.ts | 11 + .../src/v2 => core/src}/session-event.ts | 145 ++- .../src}/session-message-updater.ts | 0 .../src/v2 => core/src}/session-message.ts | 24 +- packages/core/src/session.ts | 13 + packages/core/test/catalog.test.ts | 40 +- packages/core/test/event.test.ts | 133 +++ .../test/plugin/provider-opencode.test.ts | 6 +- packages/opencode/src/bus/bus-event.ts | 33 +- packages/opencode/src/cli/cmd/debug/v2.ts | 6 +- packages/opencode/src/effect/app-runtime.ts | 2 + packages/opencode/src/event-v2-bridge.ts | 99 ++ .../routes/instance/httpapi/groups/global.ts | 1 + .../groups/v2/{instance.ts => location.ts} | 36 +- .../instance/httpapi/groups/v2/message.ts | 2 +- .../instance/httpapi/groups/v2/model.ts | 8 +- .../instance/httpapi/groups/v2/provider.ts | 12 +- .../instance/httpapi/groups/v2/session.ts | 2 +- .../routes/instance/httpapi/handlers/v2.ts | 4 +- .../instance/httpapi/handlers/v2/message.ts | 2 +- .../server/routes/instance/httpapi/server.ts | 2 + packages/opencode/src/session/compaction.ts | 27 +- packages/opencode/src/session/processor.ts | 53 +- .../opencode/src/session/projectors-next.ts | 57 +- packages/opencode/src/session/prompt.ts | 24 +- packages/opencode/src/session/schema.ts | 9 +- packages/opencode/src/session/session.sql.ts | 2 +- packages/opencode/src/sync/index.ts | 63 +- packages/opencode/src/v2/event.ts | 43 - packages/opencode/src/v2/session.ts | 16 +- .../test/server/httpapi-session.test.ts | 2 +- .../opencode/test/session/compaction.test.ts | 3 + .../test/session/processor-effect.test.ts | 2 + packages/opencode/test/session/prompt.test.ts | 2 + .../test/session/snapshot-tool-race.test.ts | 2 + .../test/v2/session-message-updater.test.ts | 6 +- packages/sdk/js/src/v2/gen/sdk.gen.ts | 12 +- packages/sdk/js/src/v2/gen/types.gen.ts | 897 ++++++++++++++---- 43 files changed, 1500 insertions(+), 517 deletions(-) create mode 100644 packages/core/src/event.ts delete mode 100644 packages/core/src/instance-layer.ts delete mode 100644 packages/core/src/instance.ts create mode 100644 packages/core/src/location-layer.ts create mode 100644 packages/core/src/location.ts rename packages/{opencode/src/v2 => core/src}/session-event.ts (71%) rename packages/{opencode/src/v2 => core/src}/session-message-updater.ts (100%) rename packages/{opencode/src/v2 => core/src}/session-message.ts (87%) create mode 100644 packages/core/src/session.ts create mode 100644 packages/core/test/event.test.ts create mode 100644 packages/opencode/src/event-v2-bridge.ts rename packages/opencode/src/server/routes/instance/httpapi/groups/v2/{instance.ts => location.ts} (55%) delete mode 100644 packages/opencode/src/v2/event.ts diff --git a/packages/core/src/catalog.ts b/packages/core/src/catalog.ts index 70cdba78563..d27f17bfb87 100644 --- a/packages/core/src/catalog.ts +++ b/packages/core/src/catalog.ts @@ -5,7 +5,8 @@ import { produce, type Draft } from "immer" import { ModelV2 } from "./model" import { PluginV2 } from "./plugin" import { ProviderV2 } from "./provider" -import { Instance } from "./instance" +import { Location } from "./location" +import { EventV2 } from "./event" type ProviderRecord = { provider: ProviderV2.Info @@ -24,6 +25,15 @@ export class ModelNotFoundError extends Schema.TaggedErrorClass Effect.Effect @@ -57,10 +67,11 @@ export class Service extends Context.Service()("@opencode/v2 export const layer = Layer.effect( Service, Effect.gen(function* () { - yield* Instance.Service + yield* Location.Service let records = HashMap.empty() let defaultModel: { providerID: ProviderV2.ID; modelID: ModelV2.ID } | undefined const plugin = yield* PluginV2.Service + const events = yield* EventV2.Service const resolve = (model: ModelV2.Info) => { const provider = Option.getOrThrow(HashMap.get(records, model.providerID)).provider @@ -157,14 +168,12 @@ export const layer = Layer.effect( ) const updated = yield* plugin.trigger("model.update", {}, { model, cancel: false }) if (updated.cancel) return + const next = new ModelV2.Info({ ...updated.model, id: modelID, providerID }) records = HashMap.set(records, providerID, { provider: record.provider, - models: HashMap.set( - record.models, - modelID, - new ModelV2.Info({ ...updated.model, id: modelID, providerID }), - ), + models: HashMap.set(record.models, modelID, next), }) + yield* events.publish(Event.ModelUpdated, { model: resolve(next) }) return }), @@ -257,4 +266,4 @@ export const layer = Layer.effect( const SMALL_MODEL_RE = /\b(nano|flash|lite|mini|haiku|small|fast)\b/ -export const defaultLayer = layer.pipe(Layer.provide(PluginV2.defaultLayer)) +export const defaultLayer = layer.pipe(Layer.provideMerge(EventV2.defaultLayer), Layer.provide(PluginV2.defaultLayer)) diff --git a/packages/core/src/event.ts b/packages/core/src/event.ts new file mode 100644 index 00000000000..e01dc5b0d63 --- /dev/null +++ b/packages/core/src/event.ts @@ -0,0 +1,157 @@ +import { Context, Effect, Layer, Option, PubSub, Schema, Stream } from "effect" +import { Location } from "./location" +import { withStatics } from "./schema" +import { Identifier } from "./util/identifier" + +export const ID = Schema.String.pipe( + Schema.brand("Event.ID"), + withStatics((schema) => ({ create: () => schema.make("evt_" + Identifier.ascending()) })), +) +export type ID = typeof ID.Type + +export type Definition = { + readonly type: Type + readonly version?: number + readonly aggregate?: string + readonly data: DataSchema +} + +export type Data = Schema.Schema.Type + +export type Payload = { + readonly id: ID + readonly type: D["type"] + readonly data: Data + readonly version?: number + readonly location?: Location.Ref + readonly metadata?: Record +} + +export type Sync = (event: Payload) => Effect.Effect + +export const registry = new Map() + +export function define(input: { + readonly type: Type + readonly version?: number + readonly aggregate?: string + readonly schema: Fields +}): Schema.Schema>>> & Definition> { + const Data = Schema.Struct(input.schema) + const Payload = Schema.Struct({ + id: ID, + metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)), + type: Schema.Literal(input.type), + version: Schema.optional(Schema.Number), + location: Schema.optional(Location.Ref), + data: Data, + }).annotate({ identifier: input.type }) + + const definition = Object.assign(Payload, { + type: input.type, + ...(input.version === undefined ? {} : { version: input.version }), + ...(input.aggregate === undefined ? {} : { aggregate: input.aggregate }), + data: Data, + }) + registry.set(input.type, definition) + return definition as Schema.Schema>>> & + Definition> +} + +export function definitions() { + return registry.values().toArray() +} + +export interface PublishOptions { + readonly id?: ID + readonly metadata?: Record +} + +export type Unsubscribe = Effect.Effect + +export interface Interface { + readonly publish: ( + definition: D, + data: Data, + options?: PublishOptions, + ) => Effect.Effect> + readonly publishEvent: (event: Payload) => Effect.Effect> + readonly subscribe: (definition: D) => Stream.Stream> + readonly all: () => Stream.Stream + readonly sync: (handler: Sync) => Effect.Effect +} + +export class Service extends Context.Service()("@opencode/Event") {} + +export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const all = yield* PubSub.unbounded() + const typed = new Map>() + const syncHandlers = new Array() + + const getOrCreate = (definition: Definition) => + Effect.gen(function* () { + const existing = typed.get(definition.type) + if (existing) return existing + const pubsub = yield* PubSub.unbounded() + typed.set(definition.type, pubsub) + return pubsub + }) + + yield* Effect.addFinalizer(() => + Effect.gen(function* () { + yield* PubSub.shutdown(all) + yield* Effect.forEach(typed.values(), PubSub.shutdown, { discard: true }) + }), + ) + + function publishEvent(event: Payload) { + return Effect.gen(function* () { + for (const sync of syncHandlers) { + yield* sync(event as Payload) + } + const pubsub = typed.get(event.type) + if (pubsub) yield* PubSub.publish(pubsub, event as Payload) + yield* PubSub.publish(all, event as Payload) + return event + }) + } + + function publish(definition: D, data: Data, options?: PublishOptions) { + return Effect.gen(function* () { + const location = Option.getOrUndefined(yield* Effect.serviceOption(Location.Service)) + const event = { + id: options?.id ?? ID.create(), + ...(options?.metadata ? { metadata: options.metadata } : {}), + type: definition.type, + ...(definition.version === undefined ? {} : { version: definition.version }), + ...(location ? { location } : {}), + data, + } as Payload + return yield* publishEvent(event) + }) + } + + const subscribe = (definition: D): Stream.Stream> => + Stream.unwrap(getOrCreate(definition).pipe(Effect.map((pubsub) => Stream.fromPubSub(pubsub)))).pipe( + Stream.map((event) => event as Payload), + ) + + const streamAll = (): Stream.Stream => Stream.fromPubSub(all) + const sync = (handler: Sync): Effect.Effect => + Effect.sync(() => { + syncHandlers.push(handler) + return Effect.sync(() => { + const index = syncHandlers.indexOf(handler) + if (index >= 0) syncHandlers.splice(index, 1) + }) + }) + + return Service.of({ publish, publishEvent, subscribe, all: streamAll, sync }) + }), +) + +export const defaultLayer = layer + +export * as EventV2 from "./event" diff --git a/packages/core/src/instance-layer.ts b/packages/core/src/instance-layer.ts deleted file mode 100644 index 20e6ac1aceb..00000000000 --- a/packages/core/src/instance-layer.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { Layer, LayerMap } from "effect" -import { Instance } from "./instance" -import { Catalog } from "./catalog" -import { PluginBoot } from "./plugin/boot" - -export class InstanceServiceMap extends LayerMap.Service()("@opencode/example/InstanceServiceMap", { - lookup: (ref: Instance.Ref) => { - const instance = Layer.succeed(Instance.Service, Instance.Service.of(ref)) - return Layer.mergeAll(Catalog.defaultLayer, PluginBoot.defaultLayer).pipe(Layer.provide(instance)) - }, - idleTimeToLive: "5 minutes", -}) {} diff --git a/packages/core/src/instance.ts b/packages/core/src/instance.ts deleted file mode 100644 index af6de951be4..00000000000 --- a/packages/core/src/instance.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { Context } from "effect" - -export * as Instance from "./instance" - -export type Ref = { - readonly directory: string - readonly workspaceID?: string -} - -export class Service extends Context.Service()("@opencode/Instance") {} diff --git a/packages/core/src/location-layer.ts b/packages/core/src/location-layer.ts new file mode 100644 index 00000000000..84dfb3dfae7 --- /dev/null +++ b/packages/core/src/location-layer.ts @@ -0,0 +1,12 @@ +import { Layer, LayerMap } from "effect" +import { Location } from "./location" +import { Catalog } from "./catalog" +import { PluginBoot } from "./plugin/boot" + +export class LocationServiceMap extends LayerMap.Service()("@opencode/example/LocationServiceMap", { + lookup: (ref: Location.Ref) => { + const location = Layer.succeed(Location.Service, Location.Service.of(ref)) + return Layer.mergeAll(Catalog.defaultLayer, PluginBoot.defaultLayer).pipe(Layer.provide(location)) + }, + idleTimeToLive: "5 minutes", +}) {} diff --git a/packages/core/src/location.ts b/packages/core/src/location.ts new file mode 100644 index 00000000000..00ff9cd3ea7 --- /dev/null +++ b/packages/core/src/location.ts @@ -0,0 +1,11 @@ +import { Context, Schema } from "effect" + +export * as Location from "./location" + +export const Ref = Schema.Struct({ + directory: Schema.String, + workspaceID: Schema.optional(Schema.String), +}).annotate({ identifier: "Location.Ref" }) +export type Ref = typeof Ref.Type + +export class Service extends Context.Service()("@opencode/Location") {} diff --git a/packages/opencode/src/v2/session-event.ts b/packages/core/src/session-event.ts similarity index 71% rename from packages/opencode/src/v2/session-event.ts rename to packages/core/src/session-event.ts index 1fd0f909d5a..a98d9cc0514 100644 --- a/packages/opencode/src/v2/session-event.ts +++ b/packages/core/src/session-event.ts @@ -1,12 +1,13 @@ -import { SessionID } from "@/session/schema" -import { NonNegativeInt } from "@opencode-ai/core/schema" -import { EventV2 } from "./event" -import { FileAttachment, Prompt } from "@opencode-ai/core/session-prompt" import { Schema } from "effect" +import { EventV2 } from "./event" +import { ModelV2 } from "./model" +import { NonNegativeInt } from "./schema" +import { Session } from "./session" +import { FileAttachment, Prompt } from "./session-prompt" +import { ToolOutput } from "./tool-output" +import { V2Schema } from "./v2-schema" + export { FileAttachment } -import { ToolOutput } from "@opencode-ai/core/tool-output" -import { V2Schema } from "@opencode-ai/core/v2-schema" -import { ModelV2 } from "@opencode-ai/core/model" export const Source = Schema.Struct({ start: NonNegativeInt, @@ -15,92 +16,94 @@ export const Source = Schema.Struct({ }).annotate({ identifier: "session.next.event.source", }) -export type Source = Schema.Schema.Type +export type Source = typeof Source.Type const Base = { timestamp: V2Schema.DateTimeUtcFromMillis, - sessionID: SessionID, + sessionID: Session.ID, } +const options = { + aggregate: "sessionID", + version: 1, +} as const + export const UnknownError = Schema.Struct({ type: Schema.Literal("unknown"), message: Schema.String, }).annotate({ identifier: "Session.Error.Unknown", }) -export type UnknownError = Schema.Schema.Type +export type UnknownError = typeof UnknownError.Type export const AgentSwitched = EventV2.define({ type: "session.next.agent.switched", - aggregate: "sessionID", - version: 1, + ...options, schema: { ...Base, agent: Schema.String, }, }) -export type AgentSwitched = Schema.Schema.Type +export type AgentSwitched = typeof AgentSwitched.Type export const ModelSwitched = EventV2.define({ type: "session.next.model.switched", - aggregate: "sessionID", - version: 1, + ...options, schema: { ...Base, model: ModelV2.Ref, }, }) -export type ModelSwitched = Schema.Schema.Type +export type ModelSwitched = typeof ModelSwitched.Type export const Prompted = EventV2.define({ type: "session.next.prompted", - aggregate: "sessionID", - version: 1, + ...options, schema: { ...Base, prompt: Prompt, }, }) -export type Prompted = Schema.Schema.Type +export type Prompted = typeof Prompted.Type export const Synthetic = EventV2.define({ type: "session.next.synthetic", - aggregate: "sessionID", + ...options, schema: { ...Base, text: Schema.String, }, }) -export type Synthetic = Schema.Schema.Type +export type Synthetic = typeof Synthetic.Type export namespace Shell { export const Started = EventV2.define({ type: "session.next.shell.started", - aggregate: "sessionID", + ...options, schema: { ...Base, callID: Schema.String, command: Schema.String, }, }) - export type Started = Schema.Schema.Type + export type Started = typeof Started.Type export const Ended = EventV2.define({ type: "session.next.shell.ended", - aggregate: "sessionID", + ...options, schema: { ...Base, callID: Schema.String, output: Schema.String, }, }) - export type Ended = Schema.Schema.Type + export type Ended = typeof Ended.Type } export namespace Step { export const Started = EventV2.define({ type: "session.next.step.started", - aggregate: "sessionID", + ...options, schema: { ...Base, agent: Schema.String, @@ -108,11 +111,11 @@ export namespace Step { snapshot: Schema.String.pipe(Schema.optional), }, }) - export type Started = Schema.Schema.Type + export type Started = typeof Started.Type export const Ended = EventV2.define({ type: "session.next.step.ended", - aggregate: "sessionID", + ...options, schema: { ...Base, finish: Schema.String, @@ -129,123 +132,123 @@ export namespace Step { snapshot: Schema.String.pipe(Schema.optional), }, }) - export type Ended = Schema.Schema.Type + export type Ended = typeof Ended.Type export const Failed = EventV2.define({ type: "session.next.step.failed", - aggregate: "sessionID", + ...options, schema: { ...Base, error: UnknownError, }, }) - export type Failed = Schema.Schema.Type + export type Failed = typeof Failed.Type } export namespace Text { export const Started = EventV2.define({ type: "session.next.text.started", - aggregate: "sessionID", + ...options, schema: { ...Base, }, }) - export type Started = Schema.Schema.Type + export type Started = typeof Started.Type export const Delta = EventV2.define({ type: "session.next.text.delta", - aggregate: "sessionID", + ...options, schema: { ...Base, delta: Schema.String, }, }) - export type Delta = Schema.Schema.Type + export type Delta = typeof Delta.Type export const Ended = EventV2.define({ type: "session.next.text.ended", - aggregate: "sessionID", + ...options, schema: { ...Base, text: Schema.String, }, }) - export type Ended = Schema.Schema.Type + export type Ended = typeof Ended.Type } export namespace Reasoning { export const Started = EventV2.define({ type: "session.next.reasoning.started", - aggregate: "sessionID", + ...options, schema: { ...Base, reasoningID: Schema.String, }, }) - export type Started = Schema.Schema.Type + export type Started = typeof Started.Type export const Delta = EventV2.define({ type: "session.next.reasoning.delta", - aggregate: "sessionID", + ...options, schema: { ...Base, reasoningID: Schema.String, delta: Schema.String, }, }) - export type Delta = Schema.Schema.Type + export type Delta = typeof Delta.Type export const Ended = EventV2.define({ type: "session.next.reasoning.ended", - aggregate: "sessionID", + ...options, schema: { ...Base, reasoningID: Schema.String, text: Schema.String, }, }) - export type Ended = Schema.Schema.Type + export type Ended = typeof Ended.Type } export namespace Tool { export namespace Input { export const Started = EventV2.define({ type: "session.next.tool.input.started", - aggregate: "sessionID", + ...options, schema: { ...Base, callID: Schema.String, name: Schema.String, }, }) - export type Started = Schema.Schema.Type + export type Started = typeof Started.Type export const Delta = EventV2.define({ type: "session.next.tool.input.delta", - aggregate: "sessionID", + ...options, schema: { ...Base, callID: Schema.String, delta: Schema.String, }, }) - export type Delta = Schema.Schema.Type + export type Delta = typeof Delta.Type export const Ended = EventV2.define({ type: "session.next.tool.input.ended", - aggregate: "sessionID", + ...options, schema: { ...Base, callID: Schema.String, text: Schema.String, }, }) - export type Ended = Schema.Schema.Type + export type Ended = typeof Ended.Type } export const Called = EventV2.define({ type: "session.next.tool.called", - aggregate: "sessionID", + ...options, schema: { ...Base, callID: Schema.String, @@ -257,11 +260,11 @@ export namespace Tool { }), }, }) - export type Called = Schema.Schema.Type + export type Called = typeof Called.Type export const Progress = EventV2.define({ type: "session.next.tool.progress", - aggregate: "sessionID", + ...options, schema: { ...Base, callID: Schema.String, @@ -269,11 +272,11 @@ export namespace Tool { content: Schema.Array(ToolOutput.Content), }, }) - export type Progress = Schema.Schema.Type + export type Progress = typeof Progress.Type export const Success = EventV2.define({ type: "session.next.tool.success", - aggregate: "sessionID", + ...options, schema: { ...Base, callID: Schema.String, @@ -285,11 +288,11 @@ export namespace Tool { }), }, }) - export type Success = Schema.Schema.Type + export type Success = typeof Success.Type export const Failed = EventV2.define({ type: "session.next.tool.failed", - aggregate: "sessionID", + ...options, schema: { ...Base, callID: Schema.String, @@ -300,7 +303,7 @@ export namespace Tool { }), }, }) - export type Failed = Schema.Schema.Type + export type Failed = typeof Failed.Type } export const RetryError = Schema.Struct({ @@ -313,49 +316,50 @@ export const RetryError = Schema.Struct({ }).annotate({ identifier: "session.next.retry_error", }) -export type RetryError = Schema.Schema.Type +export type RetryError = typeof RetryError.Type export const Retried = EventV2.define({ type: "session.next.retried", - aggregate: "sessionID", + ...options, schema: { ...Base, attempt: Schema.Finite, error: RetryError, }, }) -export type Retried = Schema.Schema.Type +export type Retried = typeof Retried.Type export namespace Compaction { export const Started = EventV2.define({ type: "session.next.compaction.started", - aggregate: "sessionID", + ...options, schema: { ...Base, reason: Schema.Union([Schema.Literal("auto"), Schema.Literal("manual")]), }, }) - export type Started = Schema.Schema.Type + export type Started = typeof Started.Type export const Delta = EventV2.define({ type: "session.next.compaction.delta", - aggregate: "sessionID", + ...options, schema: { ...Base, text: Schema.String, }, }) + export type Delta = typeof Delta.Type export const Ended = EventV2.define({ type: "session.next.compaction.ended", - aggregate: "sessionID", + ...options, schema: { ...Base, text: Schema.String, include: Schema.String.pipe(Schema.optional), }, }) - export type Ended = Schema.Schema.Type + export type Ended = typeof Ended.Type } export const All = Schema.Union( @@ -392,16 +396,7 @@ export const All = Schema.Union( }, ).pipe(Schema.toTaggedUnion("type")) -// user -// assistant -// assistant -// assistant -// user -// compaction marker -// -> text -// assistant - -export type Event = Schema.Schema.Type +export type Event = typeof All.Type export type Type = Event["type"] export * as SessionEvent from "./session-event" diff --git a/packages/opencode/src/v2/session-message-updater.ts b/packages/core/src/session-message-updater.ts similarity index 100% rename from packages/opencode/src/v2/session-message-updater.ts rename to packages/core/src/session-message-updater.ts diff --git a/packages/opencode/src/v2/session-message.ts b/packages/core/src/session-message.ts similarity index 87% rename from packages/opencode/src/v2/session-message.ts rename to packages/core/src/session-message.ts index fa7c299ae57..73b6dd7da2b 100644 --- a/packages/opencode/src/v2/session-message.ts +++ b/packages/core/src/session-message.ts @@ -1,10 +1,10 @@ import { Schema } from "effect" -import { Prompt } from "@opencode-ai/core/session-prompt" +import { Prompt } from "./session-prompt" import { SessionEvent } from "./session-event" import { EventV2 } from "./event" -import { ToolOutput } from "@opencode-ai/core/tool-output" -import { V2Schema } from "@opencode-ai/core/v2-schema" -import { ModelV2 } from "@opencode-ai/core/model" +import { ToolOutput } from "./tool-output" +import { V2Schema } from "./v2-schema" +import { ModelV2 } from "./model" export const ID = EventV2.ID export type ID = Schema.Schema.Type @@ -20,7 +20,7 @@ const Base = { export class AgentSwitched extends Schema.Class("Session.Message.AgentSwitched")({ ...Base, type: Schema.Literal("agent-switched"), - agent: SessionEvent.AgentSwitched.fields.data.fields.agent, + agent: SessionEvent.AgentSwitched.data.fields.agent, }) {} export class ModelSwitched extends Schema.Class("Session.Message.ModelSwitched")({ @@ -43,16 +43,16 @@ export class User extends Schema.Class("Session.Message.User")({ export class Synthetic extends Schema.Class("Session.Message.Synthetic")({ ...Base, - sessionID: SessionEvent.Synthetic.fields.data.fields.sessionID, - text: SessionEvent.Synthetic.fields.data.fields.text, + sessionID: SessionEvent.Synthetic.data.fields.sessionID, + text: SessionEvent.Synthetic.data.fields.text, type: Schema.Literal("synthetic"), }) {} export class Shell extends Schema.Class("Session.Message.Shell")({ ...Base, type: Schema.Literal("shell"), - callID: SessionEvent.Shell.Started.fields.data.fields.callID, - command: SessionEvent.Shell.Started.fields.data.fields.command, + callID: SessionEvent.Shell.Started.data.fields.callID, + command: SessionEvent.Shell.Started.data.fields.command, output: Schema.String, time: Schema.Struct({ created: V2Schema.DateTimeUtcFromMillis, @@ -130,7 +130,7 @@ export class Assistant extends Schema.Class("Session.Message.Assistan ...Base, type: Schema.Literal("assistant"), agent: Schema.String, - model: SessionEvent.Step.Started.fields.data.fields.model, + model: SessionEvent.Step.Started.data.fields.model, content: AssistantContent.pipe(Schema.Array), snapshot: Schema.Struct({ start: Schema.String.pipe(Schema.optional), @@ -147,7 +147,7 @@ export class Assistant extends Schema.Class("Session.Message.Assistan write: Schema.Finite, }), }).pipe(Schema.optional), - error: SessionEvent.Step.Failed.fields.data.fields.error.pipe(Schema.optional), + error: SessionEvent.Step.Failed.data.fields.error.pipe(Schema.optional), time: Schema.Struct({ created: V2Schema.DateTimeUtcFromMillis, completed: V2Schema.DateTimeUtcFromMillis.pipe(Schema.optional), @@ -156,7 +156,7 @@ export class Assistant extends Schema.Class("Session.Message.Assistan export class Compaction extends Schema.Class("Session.Message.Compaction")({ type: Schema.Literal("compaction"), - reason: SessionEvent.Compaction.Started.fields.data.fields.reason, + reason: SessionEvent.Compaction.Started.data.fields.reason, summary: Schema.String, include: Schema.String.pipe(Schema.optional), ...Base, diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts new file mode 100644 index 00000000000..756531e3280 --- /dev/null +++ b/packages/core/src/session.ts @@ -0,0 +1,13 @@ +export * as Session from "./session" + +import { Schema } from "effect" +import { withStatics } from "./schema" +import { Identifier } from "./util/identifier" + +export const ID = Schema.String.check(Schema.isStartsWith("ses")).pipe( + Schema.brand("SessionID"), + withStatics((schema) => ({ + descending: (id?: string) => schema.make(id ?? "ses_" + Identifier.descending()), + })), +) +export type ID = typeof ID.Type diff --git a/packages/core/test/catalog.test.ts b/packages/core/test/catalog.test.ts index c5ac2c06cc4..594f42d1c8b 100644 --- a/packages/core/test/catalog.test.ts +++ b/packages/core/test/catalog.test.ts @@ -1,14 +1,21 @@ import { describe, expect } from "bun:test" -import { DateTime, Effect, Layer, Option } from "effect" +import { DateTime, Effect, Fiber, Layer, Option, Stream } from "effect" import { Catalog } from "@opencode-ai/core/catalog" -import { Instance } from "@opencode-ai/core/instance" +import { EventV2 } from "@opencode-ai/core/event" +import { Location } from "@opencode-ai/core/location" import { ModelV2 } from "@opencode-ai/core/model" import { PluginV2 } from "@opencode-ai/core/plugin" import { ProviderV2 } from "@opencode-ai/core/provider" import { testEffect } from "./lib/effect" -const instanceLayer = Layer.succeed(Instance.Service, Instance.Service.of({ directory: "test" })) -const it = testEffect(Catalog.layer.pipe(Layer.provideMerge(PluginV2.defaultLayer), Layer.provide(instanceLayer))) +const locationLayer = Layer.succeed(Location.Service, Location.Service.of({ directory: "test" })) +const it = testEffect( + Catalog.layer.pipe( + Layer.provideMerge(EventV2.defaultLayer), + Layer.provideMerge(PluginV2.defaultLayer), + Layer.provideMerge(locationLayer), + ), +) describe("CatalogV2", () => { it.effect("normalizes provider baseURL into endpoint url", () => @@ -69,6 +76,31 @@ describe("CatalogV2", () => { }), ) + it.effect("publishes model updated events", () => + Effect.gen(function* () { + const catalog = yield* Catalog.Service + const events = yield* EventV2.Service + const providerID = ProviderV2.ID.make("test") + const modelID = ModelV2.ID.make("model") + const fiber = yield* events + .subscribe(Catalog.Event.ModelUpdated) + .pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped) + + yield* Effect.yieldNow + yield* catalog.provider.update(providerID, () => {}) + yield* catalog.model.update(providerID, modelID, (model) => { + model.name = "Updated Model" + }) + const event = Array.from(yield* Fiber.join(fiber))[0] + + expect(event?.type).toBe("catalog.model.updated") + expect(event?.data.model.providerID).toBe(providerID) + expect(event?.data.model.id).toBe(modelID) + expect(event?.data.model.name).toBe("Updated Model") + expect(event?.location).toEqual({ directory: "test" }) + }), + ) + it.effect("resolves unknown model endpoint from provider endpoint", () => Effect.gen(function* () { const catalog = yield* Catalog.Service diff --git a/packages/core/test/event.test.ts b/packages/core/test/event.test.ts new file mode 100644 index 00000000000..b3969bf9ae2 --- /dev/null +++ b/packages/core/test/event.test.ts @@ -0,0 +1,133 @@ +import { describe, expect } from "bun:test" +import { Effect, Fiber, Layer, Schema, Stream } from "effect" +import { EventV2 } from "@opencode-ai/core/event" +import { Location } from "@opencode-ai/core/location" +import { testEffect } from "./lib/effect" + +const locationLayer = Layer.succeed( + Location.Service, + Location.Service.of({ directory: "project", workspaceID: "workspace" }), +) +const it = testEffect(EventV2.layer.pipe(Layer.provideMerge(locationLayer))) +const itWithoutLocation = testEffect(EventV2.layer) + +const Message = EventV2.define({ + type: "test.message", + schema: { + text: Schema.String, + }, +}) + +const GlobalMessage = EventV2.define({ + type: "test.global", + schema: { + text: Schema.String, + }, +}) + +const VersionedMessage = EventV2.define({ + type: "test.versioned", + version: 2, + schema: { + text: Schema.String, + }, +}) + +describe("EventV2", () => { + it.effect("publishes events with the current location", () => + Effect.gen(function* () { + const events = yield* EventV2.Service + const fiber = yield* events.subscribe(Message).pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped) + yield* Effect.yieldNow + const event = yield* events.publish(Message, { text: "hello" }) + const received = Array.from(yield* Fiber.join(fiber)) + + expect(received).toEqual([event]) + expect(event.type).toBe("test.message") + expect(event).not.toHaveProperty("version") + expect(event.data).toEqual({ text: "hello" }) + expect(event.location).toEqual({ directory: "project", workspaceID: "workspace" }) + }), + ) + + itWithoutLocation.effect("omits location when no location is available", () => + Effect.gen(function* () { + const events = yield* EventV2.Service + const event = yield* events.publish(GlobalMessage, { text: "hello" }) + + expect(event).not.toHaveProperty("location") + expect(event.type).toBe("test.global") + }), + ) + + it.effect("publishes definition version", () => + Effect.gen(function* () { + const events = yield* EventV2.Service + const event = yield* events.publish(VersionedMessage, { text: "hello" }) + + expect(event.type).toBe("test.versioned") + expect(event.version).toBe(2) + }), + ) + + it.effect("stores definitions in the exported registry", () => + Effect.sync(() => { + expect(EventV2.registry.get(Message.type)).toBe(Message) + }), + ) + + it.effect("publishes to typed and wildcard subscriptions", () => + Effect.gen(function* () { + const events = yield* EventV2.Service + const typed = yield* events.subscribe(Message).pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped) + const wildcard = yield* events.all().pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped) + yield* Effect.yieldNow + const event = yield* events.publish(Message, { text: "hello" }) + + expect(Array.from(yield* Fiber.join(typed))).toEqual([event]) + expect(Array.from(yield* Fiber.join(wildcard))).toEqual([event]) + }), + ) + + it.effect("runs sync handlers inline", () => + Effect.gen(function* () { + const events = yield* EventV2.Service + const received = new Array() + const unsubscribe = yield* events.sync((event) => + Effect.sync(() => { + received.push(event) + }), + ) + + const event = yield* events.publish(Message, { text: "hello" }) + yield* unsubscribe + yield* events.publish(Message, { text: "after unsubscribe" }) + + expect(received).toEqual([event]) + }), + ) + + it.effect("runs sync handlers before publishing to streams", () => + Effect.gen(function* () { + const events = yield* EventV2.Service + const received = new Array() + const fiber = yield* events.all().pipe( + Stream.take(1), + Stream.runForEach(() => Effect.sync(() => received.push("stream"))), + Effect.forkScoped, + ) + yield* events.sync((event) => + Effect.sync(() => { + received.push(event.type) + }), + ) + + yield* Effect.yieldNow + yield* events.publish(Message, { text: "hello" }) + yield* Fiber.join(fiber) + + expect(received).toEqual([Message.type, "stream"]) + }), + ) + +}) diff --git a/packages/core/test/plugin/provider-opencode.test.ts b/packages/core/test/plugin/provider-opencode.test.ts index 8271a4a5c82..ed82686a21c 100644 --- a/packages/core/test/plugin/provider-opencode.test.ts +++ b/packages/core/test/plugin/provider-opencode.test.ts @@ -1,7 +1,7 @@ import { describe, expect } from "bun:test" import { DateTime, Effect, Layer, Option } from "effect" import { Catalog } from "@opencode-ai/core/catalog" -import { Instance } from "@opencode-ai/core/instance" +import { Location } from "@opencode-ai/core/location" import { ModelV2 } from "@opencode-ai/core/model" import { PluginV2 } from "@opencode-ai/core/plugin" import { OpencodePlugin } from "@opencode-ai/core/plugin/provider/opencode" @@ -9,7 +9,7 @@ import { ProviderV2 } from "@opencode-ai/core/provider" import { it, model, provider, withEnv } from "./provider-helper" const cost = (input: number, output = 0) => [{ input, output, cache: { read: 0, write: 0 } }] -const instanceLayer = Layer.succeed(Instance.Service, Instance.Service.of({ directory: "test" })) +const locationLayer = Layer.succeed(Location.Service, Location.Service.of({ directory: "test" })) describe("OpencodePlugin", () => { it.effect("uses a public key and cancels paid models without credentials", () => @@ -192,6 +192,6 @@ describe("OpencodePlugin", () => { const selected = yield* catalog.model.small(providerID) expect(Option.getOrUndefined(selected)?.id).toBe(ModelV2.ID.make("gpt-5-nano")) - }).pipe(Effect.provide(Catalog.defaultLayer.pipe(Layer.provide(instanceLayer)))), + }).pipe(Effect.provide(Catalog.defaultLayer.pipe(Layer.provide(locationLayer)))), ) }) diff --git a/packages/opencode/src/bus/bus-event.ts b/packages/opencode/src/bus/bus-event.ts index 35337063185..5a9e52ef073 100644 --- a/packages/opencode/src/bus/bus-event.ts +++ b/packages/opencode/src/bus/bus-event.ts @@ -1,4 +1,5 @@ import { Schema } from "effect" +import { EventV2 } from "@opencode-ai/core/event" export type Definition = { type: Type @@ -17,16 +18,28 @@ export function define( } export function effectPayloads() { - return registry - .entries() - .map(([type, def]) => - Schema.Struct({ - id: Schema.String, - type: Schema.Literal(type), - properties: def.properties, - }).annotate({ identifier: `Event.${type}` }), - ) - .toArray() + return [ + ...registry + .entries() + .map(([type, def]) => + Schema.Struct({ + id: Schema.String, + type: Schema.Literal(type), + properties: def.properties, + }).annotate({ identifier: `Event.${type}` }), + ) + .toArray(), + ...EventV2.registry + .values() + .map((definition) => + Schema.Struct({ + id: Schema.String, + type: Schema.Literal(definition.type), + properties: definition.data, + }).annotate({ identifier: `Event.${definition.type}` }), + ) + .toArray(), + ] } export * as BusEvent from "./bus-event" diff --git a/packages/opencode/src/cli/cmd/debug/v2.ts b/packages/opencode/src/cli/cmd/debug/v2.ts index 507f82cdf53..836f5813672 100644 --- a/packages/opencode/src/cli/cmd/debug/v2.ts +++ b/packages/opencode/src/cli/cmd/debug/v2.ts @@ -1,11 +1,11 @@ import { EOL } from "os" import { Effect, Layer, Option } from "effect" import { Catalog } from "@opencode-ai/core/catalog" -import { InstanceServiceMap } from "@opencode-ai/core/instance-layer" +import { LocationServiceMap } from "@opencode-ai/core/location-layer" import { PluginBoot } from "@opencode-ai/core/plugin/boot" import { effectCmd } from "../../effect-cmd" -const Runtime = Layer.mergeAll(InstanceServiceMap.layer) +const Runtime = Layer.mergeAll(LocationServiceMap.layer) export const V2Command = effectCmd({ command: "v2", @@ -37,7 +37,7 @@ export const V2Command = effectCmd({ process.stdout.write(JSON.stringify(result, null, 2) + EOL) }, Effect.provide( - InstanceServiceMap.get({ + LocationServiceMap.get({ directory: process.cwd(), }), ), diff --git a/packages/opencode/src/effect/app-runtime.ts b/packages/opencode/src/effect/app-runtime.ts index ec0336785a1..0ce876ddc65 100644 --- a/packages/opencode/src/effect/app-runtime.ts +++ b/packages/opencode/src/effect/app-runtime.ts @@ -56,6 +56,7 @@ import { Npm } from "@opencode-ai/core/npm" import { memoMap } from "@opencode-ai/core/effect/memo-map" import { DataMigration } from "@/data-migration" import { BackgroundJob } from "@/background/job" +import { EventV2Bridge } from "@/event-v2-bridge" import { RuntimeFlags } from "@/effect/runtime-flags" export const AppLayer = Layer.mergeAll( @@ -111,6 +112,7 @@ export const AppLayer = Layer.mergeAll( ShareNext.defaultLayer, SessionShare.defaultLayer, SyncEvent.defaultLayer, + EventV2Bridge.defaultLayer, DataMigration.defaultLayer, ).pipe(Layer.provideMerge(InstanceLayer.layer), Layer.provideMerge(Observability.layer)) diff --git a/packages/opencode/src/event-v2-bridge.ts b/packages/opencode/src/event-v2-bridge.ts new file mode 100644 index 00000000000..7ea2e9d1bba --- /dev/null +++ b/packages/opencode/src/event-v2-bridge.ts @@ -0,0 +1,99 @@ +// Temporary V2 bridge: core events are the publish path, but the rest of +// opencode and the HTTP event stream still expect legacy bus/sync payloads. +// This layer goes away once consumers subscribe to core EventV2 directly. +import { Bus as ProjectBus } from "@/bus" +import { GlobalBus } from "@/bus/global" +import { InstanceRef, WorkspaceRef } from "@/effect/instance-ref" +import { InstanceStore } from "@/project/instance-store" +import { SyncEvent } from "@/sync" +import { EventV2 } from "@opencode-ai/core/event" +import "@opencode-ai/core/catalog" +import "@opencode-ai/core/session-event" +import { Context, Effect, Layer, Option } from "effect" + +const syncDefinitions = new WeakMap() + +export function toSyncDefinition( + definition: D, +): SyncEvent.Definition { + const cached = syncDefinitions.get(definition) + if (cached) return cached as SyncEvent.Definition + if (definition.version === undefined) + throw new Error(`Event.toSyncDefinition: version required for ${definition.type}`) + if (!definition.aggregate) throw new Error(`Event.toSyncDefinition: aggregate required for ${definition.type}`) + const result = { + type: definition.type, + version: definition.version, + aggregate: definition.aggregate, + schema: definition.data, + properties: definition.data, + } + syncDefinitions.set(definition, result) + return result +} + +export class Service extends Context.Service()("@opencode/EventV2Bridge") {} + +export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const events = yield* EventV2.Service + const bus = yield* ProjectBus.Service + const sync = yield* SyncEvent.Service + + const publishGlobal = (event: EventV2.Payload) => + Effect.sync(() => { + GlobalBus.emit("event", { + workspace: event.location?.workspaceID, + payload: { + id: event.id, + type: event.type, + properties: event.data, + }, + }) + }) + + const provideEventLocation = (event: EventV2.Payload, effect: Effect.Effect) => { + return Effect.gen(function* () { + const ctx = yield* InstanceRef + if (ctx) return yield* effect + const store = Option.getOrUndefined(yield* Effect.serviceOption(InstanceStore.Service)) + if (!event.location?.directory || !store) return yield* publishGlobal(event) + return yield* store.load({ directory: event.location.directory }).pipe( + Effect.flatMap((ctx) => { + const withInstance = effect.pipe(Effect.provideService(InstanceRef, ctx)) + if (!event.location?.workspaceID) return withInstance + return withInstance.pipe(Effect.provideService(WorkspaceRef, event.location.workspaceID)) + }), + ) + }) + } + + const unsubscribe = yield* events.sync((event) => { + const definition = EventV2.registry.get(event.type) + if (!definition) return Effect.void + const aggregateID = definition.aggregate + ? (event.data as Record)[definition.aggregate] + : undefined + + if (definition.version !== undefined && typeof aggregateID === "string") { + return provideEventLocation(event, sync.run(toSyncDefinition(definition), event.data)) + } + + return provideEventLocation( + event, + bus.publish({ type: definition.type, properties: definition.data }, event.data, { id: event.id }), + ) + }) + yield* Effect.addFinalizer(() => unsubscribe) + return Service.of(events) + }), +) + +export const defaultLayer = layer.pipe( + Layer.provideMerge(EventV2.defaultLayer), + Layer.provide(SyncEvent.defaultLayer), + Layer.provide(ProjectBus.defaultLayer), +) + +export * as EventV2Bridge from "./event-v2-bridge" diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/global.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/global.ts index 75441b4ca4a..5b8a44d2cf1 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/global.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/global.ts @@ -1,6 +1,7 @@ import { Config } from "@/config/config" import { BusEvent } from "@/bus/bus-event" import { SyncEvent } from "@/sync" +import "@/event-v2-bridge" import "@/server/event" import { Schema } from "effect" import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/v2/instance.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/v2/location.ts similarity index 55% rename from packages/opencode/src/server/routes/instance/httpapi/groups/v2/instance.ts rename to packages/opencode/src/server/routes/instance/httpapi/groups/v2/location.ts index 56ab0564b8d..f2a9a33557a 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/v2/instance.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/v2/location.ts @@ -1,28 +1,28 @@ import { Catalog } from "@opencode-ai/core/catalog" -import { Instance } from "@opencode-ai/core/instance" -import { InstanceServiceMap } from "@opencode-ai/core/instance-layer" +import { Location } from "@opencode-ai/core/location" +import { LocationServiceMap } from "@opencode-ai/core/location-layer" import { PluginBoot } from "@opencode-ai/core/plugin/boot" import { Effect, Layer, Schema } from "effect" import { HttpServerRequest } from "effect/unstable/http" import { HttpApiMiddleware, OpenApi } from "effect/unstable/httpapi" -export const InstanceQuery = Schema.Struct({ - instance: Schema.optional( +export const LocationQuery = Schema.Struct({ + location: Schema.optional( Schema.Struct({ directory: Schema.optional(Schema.String), workspace: Schema.optional(Schema.String), }), ), -}).annotate({ identifier: "V2InstanceQuery" }) +}).annotate({ identifier: "V2LocationQuery" }) -export const instanceQueryOpenApi = OpenApi.annotations({ +export const locationQueryOpenApi = OpenApi.annotations({ transform: (operation) => { const parameters = operation.parameters if (!Array.isArray(parameters)) return operation return { ...operation, parameters: parameters.map((parameter) => - parameter?.name === "instance" && parameter?.in === "query" + parameter?.name === "location" && parameter?.in === "query" ? { ...parameter, style: "deepObject", explode: true } : parameter, ), @@ -30,30 +30,30 @@ export const instanceQueryOpenApi = OpenApi.annotations({ }, }) -export class V2InstanceMiddleware extends HttpApiMiddleware.Service< - V2InstanceMiddleware, +export class V2LocationMiddleware extends HttpApiMiddleware.Service< + V2LocationMiddleware, { provides: Catalog.Service | PluginBoot.Service } ->()("@opencode/ExperimentalHttpApiV2Instance") {} +>()("@opencode/ExperimentalHttpApiV2Location") {} -function ref(request: HttpServerRequest.HttpServerRequest): Instance.Ref { +function ref(request: HttpServerRequest.HttpServerRequest): Location.Ref { const query = new URL(request.url, "http://localhost").searchParams return { - directory: query.get("instance[directory]") || request.headers["x-opencode-directory"] || process.cwd(), - workspaceID: query.get("instance[workspace]") || request.headers["x-opencode-workspace"], + directory: query.get("location[directory]") || request.headers["x-opencode-directory"] || process.cwd(), + workspaceID: query.get("location[workspace]") || request.headers["x-opencode-workspace"], } } export const layer = Layer.effect( - V2InstanceMiddleware, + V2LocationMiddleware, Effect.gen(function* () { - const instances = yield* InstanceServiceMap - return V2InstanceMiddleware.of((effect) => + const locations = yield* LocationServiceMap + return V2LocationMiddleware.of((effect) => Effect.gen(function* () { const request = yield* HttpServerRequest.HttpServerRequest - return yield* effect.pipe(Effect.provide(instances.get(ref(request)))) + return yield* effect.pipe(Effect.provide(locations.get(ref(request)))) }), ) }), -).pipe(Layer.provide(InstanceServiceMap.layer)) +).pipe(Layer.provide(LocationServiceMap.layer)) diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/v2/message.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/v2/message.ts index 060c6c8a83e..131a1425861 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/v2/message.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/v2/message.ts @@ -1,5 +1,5 @@ import { SessionID } from "@/session/schema" -import { SessionMessage } from "@/v2/session-message" +import { SessionMessage } from "@opencode-ai/core/session-message" import { Schema } from "effect" import { HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { Authorization } from "../../middleware/authorization" diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/v2/model.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/v2/model.ts index 47b8ec89655..d265ac7fc24 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/v2/model.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/v2/model.ts @@ -2,15 +2,15 @@ import { ModelV2 } from "@opencode-ai/core/model" import { Schema } from "effect" import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { Authorization } from "../../middleware/authorization" -import { InstanceQuery, instanceQueryOpenApi, V2InstanceMiddleware } from "./instance" +import { LocationQuery, locationQueryOpenApi, V2LocationMiddleware } from "./location" export const ModelGroup = HttpApiGroup.make("v2.model") .add( HttpApiEndpoint.get("models", "/api/model", { - query: InstanceQuery, + query: LocationQuery, success: Schema.Array(ModelV2.Info), }) - .annotateMerge(instanceQueryOpenApi) + .annotateMerge(locationQueryOpenApi) .annotateMerge( OpenApi.annotations({ identifier: "v2.model.list", @@ -25,5 +25,5 @@ export const ModelGroup = HttpApiGroup.make("v2.model") description: "Experimental v2 model routes.", }), ) - .middleware(V2InstanceMiddleware) + .middleware(V2LocationMiddleware) .middleware(Authorization) diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/v2/provider.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/v2/provider.ts index e62cfc3240d..7a482ce1149 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/v2/provider.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/v2/provider.ts @@ -3,15 +3,15 @@ import { Schema } from "effect" import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { ApiNotFoundError } from "../../errors" import { Authorization } from "../../middleware/authorization" -import { InstanceQuery, instanceQueryOpenApi, V2InstanceMiddleware } from "./instance" +import { LocationQuery, locationQueryOpenApi, V2LocationMiddleware } from "./location" export const ProviderGroup = HttpApiGroup.make("v2.provider") .add( HttpApiEndpoint.get("providers", "/api/provider", { - query: InstanceQuery, + query: LocationQuery, success: Schema.Array(ProviderV2.Info), }) - .annotateMerge(instanceQueryOpenApi) + .annotateMerge(locationQueryOpenApi) .annotateMerge( OpenApi.annotations({ identifier: "v2.provider.list", @@ -23,11 +23,11 @@ export const ProviderGroup = HttpApiGroup.make("v2.provider") .add( HttpApiEndpoint.get("provider", "/api/provider/:providerID", { params: { providerID: ProviderV2.ID }, - query: InstanceQuery, + query: LocationQuery, success: ProviderV2.Info, error: ApiNotFoundError, }) - .annotateMerge(instanceQueryOpenApi) + .annotateMerge(locationQueryOpenApi) .annotateMerge( OpenApi.annotations({ identifier: "v2.provider.get", @@ -43,5 +43,5 @@ export const ProviderGroup = HttpApiGroup.make("v2.provider") description: "Experimental v2 provider routes.", }), ) - .middleware(V2InstanceMiddleware) + .middleware(V2LocationMiddleware) .middleware(Authorization) diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/v2/session.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/v2/session.ts index 3776f5c72a3..0313b5c0977 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/v2/session.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/v2/session.ts @@ -1,5 +1,5 @@ import { SessionID } from "@/session/schema" -import { SessionMessage } from "@/v2/session-message" +import { SessionMessage } from "@opencode-ai/core/session-message" import { Prompt } from "@opencode-ai/core/session-prompt" import { SessionV2 } from "@/v2/session" import { Schema } from "effect" diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/v2.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/v2.ts index 7739001db7e..daa799b7a8b 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/v2.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/v2.ts @@ -1,12 +1,12 @@ import { SessionV2 } from "@/v2/session" import { Layer } from "effect" -import { layer as v2InstanceLayer } from "../groups/v2/instance" +import { layer as v2LocationLayer } from "../groups/v2/location" import { messageHandlers } from "./v2/message" import { modelHandlers } from "./v2/model" import { providerHandlers } from "./v2/provider" import { sessionHandlers } from "./v2/session" export const v2Handlers = Layer.mergeAll(sessionHandlers, messageHandlers, modelHandlers, providerHandlers).pipe( - Layer.provide(v2InstanceLayer), + Layer.provide(v2LocationLayer), Layer.provide(SessionV2.defaultLayer), ) diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/v2/message.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/v2/message.ts index 92e37142b40..fd710ba954a 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/v2/message.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/v2/message.ts @@ -1,4 +1,4 @@ -import { SessionMessage } from "@/v2/session-message" +import { SessionMessage } from "@opencode-ai/core/session-message" import { SessionV2 } from "@/v2/session" import { Effect, Schema } from "effect" import * as DateTime from "effect/DateTime" diff --git a/packages/opencode/src/server/routes/instance/httpapi/server.ts b/packages/opencode/src/server/routes/instance/httpapi/server.ts index fd9c3e67f27..539ceef35f7 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/server.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/server.ts @@ -45,6 +45,7 @@ import { SessionSummary } from "@/session/summary" import { Todo } from "@/session/todo" import { SessionShare } from "@/share/session" import { ShareNext } from "@/share/share-next" +import { EventV2Bridge } from "@/event-v2-bridge" import { Skill } from "@/skill" import { Snapshot } from "@/snapshot" import { SyncEvent } from "@/sync" @@ -221,6 +222,7 @@ export function createRoutes( ShareNext.defaultLayer, Snapshot.defaultLayer, SyncEvent.defaultLayer, + EventV2Bridge.defaultLayer, Skill.defaultLayer, Todo.defaultLayer, ToolRegistry.defaultLayer, diff --git a/packages/opencode/src/session/compaction.ts b/packages/opencode/src/session/compaction.ts index fabdc01f09d..59137352390 100644 --- a/packages/opencode/src/session/compaction.ts +++ b/packages/opencode/src/session/compaction.ts @@ -19,8 +19,9 @@ import { isOverflow as overflow, usable } from "./overflow" import { makeRuntime } from "@/effect/run-service" import { serviceUse } from "@/effect/service-use" import { RuntimeFlags } from "@/effect/runtime-flags" -import { SyncEvent } from "@/sync" -import { SessionEvent } from "@/v2/session-event" +import { EventV2 } from "@opencode-ai/core/event" +import { EventV2Bridge } from "@/event-v2-bridge" +import { SessionEvent } from "@opencode-ai/core/session-event" const log = Log.create({ service: "session.compaction" }) @@ -210,19 +211,7 @@ export class Service extends Context.Service()("@opencode/Se export const use = serviceUse(Service) -export const layer: Layer.Layer< - Service, - never, - | Bus.Service - | Config.Service - | Session.Service - | Agent.Service - | Plugin.Service - | SessionProcessor.Service - | Provider.Service - | SyncEvent.Service - | RuntimeFlags.Service -> = Layer.effect( +export const layer = Layer.effect( Service, Effect.gen(function* () { const bus = yield* Bus.Service @@ -232,7 +221,7 @@ export const layer: Layer.Layer< const plugin = yield* Plugin.Service const processors = yield* SessionProcessor.Service const provider = yield* Provider.Service - const sync = yield* SyncEvent.Service + const events = yield* EventV2Bridge.Service const flags = yield* RuntimeFlags.Service const isOverflow = Effect.fn("SessionCompaction.isOverflow")(function* (input: { @@ -577,7 +566,7 @@ export const layer: Layer.Layer< }, ) if (flags.experimentalEventSystem) { - yield* sync.run(SessionEvent.Compaction.Ended.Sync, { + yield* events.publish(SessionEvent.Compaction.Ended, { sessionID: input.sessionID, timestamp: DateTime.makeUnsafe(Date.now()), text: summary ?? "", @@ -613,7 +602,7 @@ export const layer: Layer.Layer< overflow: input.overflow, }) if (flags.experimentalEventSystem) { - yield* sync.run(SessionEvent.Compaction.Started.Sync, { + yield* events.publish(SessionEvent.Compaction.Started, { sessionID: input.sessionID, timestamp: DateTime.makeUnsafe(Date.now()), reason: input.auto ? "auto" : "manual", @@ -639,8 +628,8 @@ export const defaultLayer = Layer.suspend(() => Layer.provide(Plugin.defaultLayer), Layer.provide(Bus.layer), Layer.provide(Config.defaultLayer), - Layer.provide(SyncEvent.defaultLayer), Layer.provide(RuntimeFlags.defaultLayer), + Layer.provide(EventV2Bridge.defaultLayer), ), ) diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index caf5a2478b7..aac893075f0 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -21,8 +21,9 @@ import { Question } from "@/question" import { errorMessage } from "@/util/error" import * as Log from "@opencode-ai/core/util/log" import { isRecord } from "@/util/record" -import { SyncEvent } from "@/sync" -import { SessionEvent } from "@/v2/session-event" +import { EventV2 } from "@opencode-ai/core/event" +import { EventV2Bridge } from "@/event-v2-bridge" +import { SessionEvent } from "@opencode-ai/core/session-event" import { ModelV2 } from "@opencode-ai/core/model" import { ProviderV2 } from "@opencode-ai/core/provider" import * as DateTime from "effect/DateTime" @@ -84,23 +85,7 @@ type StreamEvent = Event export class Service extends Context.Service()("@opencode/SessionProcessor") {} -export const layer: Layer.Layer< - Service, - never, - | Session.Service - | Config.Service - | Bus.Service - | Snapshot.Service - | Agent.Service - | LLM.Service - | Permission.Service - | Plugin.Service - | Image.Service - | SessionSummary.Service - | SessionStatus.Service - | SyncEvent.Service - | RuntimeFlags.Service -> = Layer.effect( +export const layer = Layer.effect( Service, Effect.gen(function* () { const session = yield* Session.Service @@ -115,7 +100,7 @@ export const layer: Layer.Layer< const scope = yield* Scope.Scope const status = yield* SessionStatus.Service const image = yield* Image.Service - const sync = yield* SyncEvent.Service + const events = yield* EventV2Bridge.Service const flags = yield* RuntimeFlags.Service const create = Effect.fn("SessionProcessor.create")(function* (input: Input) { @@ -236,7 +221,7 @@ export const layer: Layer.Layer< if (value.id in ctx.reasoningMap) return // TODO(v2): Temporary dual-write while migrating session messages to v2 events. if (flags.experimentalEventSystem) { - yield* sync.run(SessionEvent.Reasoning.Started.Sync, { + yield* events.publish(SessionEvent.Reasoning.Started, { sessionID: ctx.sessionID, reasoningID: value.id, timestamp: DateTime.makeUnsafe(Date.now()), @@ -271,7 +256,7 @@ export const layer: Layer.Layer< if (!(value.id in ctx.reasoningMap)) return // TODO(v2): Temporary dual-write while migrating session messages to v2 events. if (flags.experimentalEventSystem) { - yield* sync.run(SessionEvent.Reasoning.Ended.Sync, { + yield* events.publish(SessionEvent.Reasoning.Ended, { sessionID: ctx.sessionID, reasoningID: value.id, text: ctx.reasoningMap[value.id].text, @@ -292,7 +277,7 @@ export const layer: Layer.Layer< } // TODO(v2): Temporary dual-write while migrating session messages to v2 events. if (flags.experimentalEventSystem) { - yield* sync.run(SessionEvent.Tool.Input.Started.Sync, { + yield* events.publish(SessionEvent.Tool.Input.Started, { sessionID: ctx.sessionID, callID: value.id, name: value.toolName, @@ -323,7 +308,7 @@ export const layer: Layer.Layer< case "tool-input-end": { // TODO(v2): Temporary dual-write while migrating session messages to v2 events. if (flags.experimentalEventSystem) { - yield* sync.run(SessionEvent.Tool.Input.Ended.Sync, { + yield* events.publish(SessionEvent.Tool.Input.Ended, { sessionID: ctx.sessionID, callID: value.id, text: "", @@ -340,7 +325,7 @@ export const layer: Layer.Layer< const toolCall = yield* readToolCall(value.toolCallId) // TODO(v2): Temporary dual-write while migrating session messages to v2 events. if (flags.experimentalEventSystem) { - yield* sync.run(SessionEvent.Tool.Called.Sync, { + yield* events.publish(SessionEvent.Tool.Called, { sessionID: ctx.sessionID, callID: value.toolCallId, tool: value.toolName, @@ -428,7 +413,7 @@ export const layer: Layer.Layer< } // TODO(v2): Temporary dual-write while migrating session messages to v2 events. if (flags.experimentalEventSystem) { - yield* sync.run(SessionEvent.Tool.Success.Sync, { + yield* events.publish(SessionEvent.Tool.Success, { sessionID: ctx.sessionID, callID: value.toolCallId, structured: output.metadata, @@ -458,7 +443,7 @@ export const layer: Layer.Layer< const toolCall = yield* readToolCall(value.toolCallId) // TODO(v2): Temporary dual-write while migrating session messages to v2 events. if (flags.experimentalEventSystem) { - yield* sync.run(SessionEvent.Tool.Failed.Sync, { + yield* events.publish(SessionEvent.Tool.Failed, { sessionID: ctx.sessionID, callID: value.toolCallId, error: { @@ -483,7 +468,7 @@ export const layer: Layer.Layer< if (!ctx.assistantMessage.summary) { // TODO(v2): Temporary dual-write while migrating session messages to v2 events. if (flags.experimentalEventSystem) { - yield* sync.run(SessionEvent.Step.Started.Sync, { + yield* events.publish(SessionEvent.Step.Started, { sessionID: ctx.sessionID, agent: input.assistantMessage.agent, model: { @@ -515,7 +500,7 @@ export const layer: Layer.Layer< if (!ctx.assistantMessage.summary) { // TODO(v2): Temporary dual-write while migrating session messages to v2 events. if (flags.experimentalEventSystem) { - yield* sync.run(SessionEvent.Step.Ended.Sync, { + yield* events.publish(SessionEvent.Step.Ended, { sessionID: ctx.sessionID, finish: value.finishReason, cost: usage.cost, @@ -572,7 +557,7 @@ export const layer: Layer.Layer< if (!ctx.assistantMessage.summary) { // TODO(v2): Temporary dual-write while migrating session messages to v2 events. if (flags.experimentalEventSystem) { - yield* sync.run(SessionEvent.Text.Started.Sync, { + yield* events.publish(SessionEvent.Text.Started, { sessionID: ctx.sessionID, timestamp: DateTime.makeUnsafe(Date.now()), }) @@ -619,7 +604,7 @@ export const layer: Layer.Layer< if (!ctx.assistantMessage.summary) { // TODO(v2): Temporary dual-write while migrating session messages to v2 events. if (flags.experimentalEventSystem) { - yield* sync.run(SessionEvent.Text.Ended.Sync, { + yield* events.publish(SessionEvent.Text.Ended, { sessionID: ctx.sessionID, text: ctx.currentText.text, timestamp: DateTime.makeUnsafe(Date.now()), @@ -715,7 +700,7 @@ export const layer: Layer.Layer< if (!ctx.assistantMessage.summary) { // TODO(v2): Temporary dual-write while migrating session messages to v2 events. if (flags.experimentalEventSystem) { - yield* sync.run(SessionEvent.Step.Failed.Sync, { + yield* events.publish(SessionEvent.Step.Failed, { sessionID: ctx.sessionID, error: { type: "unknown", @@ -769,7 +754,7 @@ export const layer: Layer.Layer< set: (info) => { // TODO(v2): Temporary dual-write while migrating session messages to v2 events. const event = flags.experimentalEventSystem - ? sync.run(SessionEvent.Retried.Sync, { + ? events.publish(SessionEvent.Retried, { sessionID: ctx.sessionID, attempt: info.attempt, error: { @@ -830,8 +815,8 @@ export const defaultLayer = Layer.suspend(() => Layer.provide(Image.defaultLayer), Layer.provide(Bus.layer), Layer.provide(Config.defaultLayer), - Layer.provide(SyncEvent.defaultLayer), Layer.provide(RuntimeFlags.defaultLayer), + Layer.provide(EventV2Bridge.defaultLayer), ), ) diff --git a/packages/opencode/src/session/projectors-next.ts b/packages/opencode/src/session/projectors-next.ts index 93298170cc0..ae5b9c5d2fb 100644 --- a/packages/opencode/src/session/projectors-next.ts +++ b/packages/opencode/src/session/projectors-next.ts @@ -1,10 +1,11 @@ import { and, desc, eq } from "@/storage/db" import type { Database } from "@/storage/db" -import { SessionMessage } from "@/v2/session-message" -import { SessionMessageUpdater } from "@/v2/session-message-updater" -import { SessionEvent } from "@/v2/session-event" +import { SessionMessage } from "@opencode-ai/core/session-message" +import { SessionMessageUpdater } from "@opencode-ai/core/session-message-updater" +import { SessionEvent } from "@opencode-ai/core/session-event" import * as DateTime from "effect/DateTime" import { SyncEvent } from "@/sync" +import { EventV2Bridge } from "@/event-v2-bridge" import { SessionMessageTable, SessionTable } from "./session.sql" import type { SessionID } from "./schema" import { Schema } from "effect" @@ -119,7 +120,7 @@ function update(db: Database.TxOrDb, event: SessionEvent.Event) { } export default [ - SyncEvent.project(SessionEvent.AgentSwitched.Sync, (db, data, event) => { + SyncEvent.project(EventV2Bridge.toSyncDefinition(SessionEvent.AgentSwitched), (db, data, event) => { db.update(SessionTable) .set({ agent: data.agent, @@ -129,7 +130,7 @@ export default [ .run() update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.agent.switched", data }) }), - SyncEvent.project(SessionEvent.ModelSwitched.Sync, (db, data, event) => { + SyncEvent.project(EventV2Bridge.toSyncDefinition(SessionEvent.ModelSwitched), (db, data, event) => { db.update(SessionTable) .set({ model: data.model, @@ -139,65 +140,65 @@ export default [ .run() update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.model.switched", data }) }), - SyncEvent.project(SessionEvent.Prompted.Sync, (db, data, event) => { + SyncEvent.project(EventV2Bridge.toSyncDefinition(SessionEvent.Prompted), (db, data, event) => { update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.prompted", data }) }), - SyncEvent.project(SessionEvent.Synthetic.Sync, (db, data, event) => { + SyncEvent.project(EventV2Bridge.toSyncDefinition(SessionEvent.Synthetic), (db, data, event) => { update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.synthetic", data }) }), - SyncEvent.project(SessionEvent.Shell.Started.Sync, (db, data, event) => { + SyncEvent.project(EventV2Bridge.toSyncDefinition(SessionEvent.Shell.Started), (db, data, event) => { update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.shell.started", data }) }), - SyncEvent.project(SessionEvent.Shell.Ended.Sync, (db, data, event) => { + SyncEvent.project(EventV2Bridge.toSyncDefinition(SessionEvent.Shell.Ended), (db, data, event) => { update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.shell.ended", data }) }), - SyncEvent.project(SessionEvent.Step.Started.Sync, (db, data, event) => { + SyncEvent.project(EventV2Bridge.toSyncDefinition(SessionEvent.Step.Started), (db, data, event) => { update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.step.started", data }) }), - SyncEvent.project(SessionEvent.Step.Ended.Sync, (db, data, event) => { + SyncEvent.project(EventV2Bridge.toSyncDefinition(SessionEvent.Step.Ended), (db, data, event) => { update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.step.ended", data }) }), - SyncEvent.project(SessionEvent.Step.Failed.Sync, (db, data, event) => { + SyncEvent.project(EventV2Bridge.toSyncDefinition(SessionEvent.Step.Failed), (db, data, event) => { update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.step.failed", data }) }), - SyncEvent.project(SessionEvent.Text.Started.Sync, (db, data, event) => { + SyncEvent.project(EventV2Bridge.toSyncDefinition(SessionEvent.Text.Started), (db, data, event) => { update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.text.started", data }) }), - SyncEvent.project(SessionEvent.Text.Delta.Sync, () => {}), - SyncEvent.project(SessionEvent.Text.Ended.Sync, (db, data, event) => { + SyncEvent.project(EventV2Bridge.toSyncDefinition(SessionEvent.Text.Delta), () => {}), + SyncEvent.project(EventV2Bridge.toSyncDefinition(SessionEvent.Text.Ended), (db, data, event) => { update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.text.ended", data }) }), - SyncEvent.project(SessionEvent.Tool.Input.Started.Sync, (db, data, event) => { + SyncEvent.project(EventV2Bridge.toSyncDefinition(SessionEvent.Tool.Input.Started), (db, data, event) => { update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.tool.input.started", data }) }), - SyncEvent.project(SessionEvent.Tool.Input.Delta.Sync, () => {}), - SyncEvent.project(SessionEvent.Tool.Input.Ended.Sync, (db, data, event) => { + SyncEvent.project(EventV2Bridge.toSyncDefinition(SessionEvent.Tool.Input.Delta), () => {}), + SyncEvent.project(EventV2Bridge.toSyncDefinition(SessionEvent.Tool.Input.Ended), (db, data, event) => { update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.tool.input.ended", data }) }), - SyncEvent.project(SessionEvent.Tool.Called.Sync, (db, data, event) => { + SyncEvent.project(EventV2Bridge.toSyncDefinition(SessionEvent.Tool.Called), (db, data, event) => { update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.tool.called", data }) }), - SyncEvent.project(SessionEvent.Tool.Success.Sync, (db, data, event) => { + SyncEvent.project(EventV2Bridge.toSyncDefinition(SessionEvent.Tool.Success), (db, data, event) => { update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.tool.success", data }) }), - SyncEvent.project(SessionEvent.Tool.Failed.Sync, (db, data, event) => { + SyncEvent.project(EventV2Bridge.toSyncDefinition(SessionEvent.Tool.Failed), (db, data, event) => { update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.tool.failed", data }) }), - SyncEvent.project(SessionEvent.Reasoning.Started.Sync, (db, data, event) => { + SyncEvent.project(EventV2Bridge.toSyncDefinition(SessionEvent.Reasoning.Started), (db, data, event) => { update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.reasoning.started", data }) }), - SyncEvent.project(SessionEvent.Reasoning.Delta.Sync, () => {}), - SyncEvent.project(SessionEvent.Reasoning.Ended.Sync, (db, data, event) => { + SyncEvent.project(EventV2Bridge.toSyncDefinition(SessionEvent.Reasoning.Delta), () => {}), + SyncEvent.project(EventV2Bridge.toSyncDefinition(SessionEvent.Reasoning.Ended), (db, data, event) => { update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.reasoning.ended", data }) }), - SyncEvent.project(SessionEvent.Retried.Sync, (db, data, event) => { + SyncEvent.project(EventV2Bridge.toSyncDefinition(SessionEvent.Retried), (db, data, event) => { update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.retried", data }) }), - SyncEvent.project(SessionEvent.Compaction.Started.Sync, (db, data, event) => { + SyncEvent.project(EventV2Bridge.toSyncDefinition(SessionEvent.Compaction.Started), (db, data, event) => { update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.compaction.started", data }) }), - SyncEvent.project(SessionEvent.Compaction.Delta.Sync, () => {}), - SyncEvent.project(SessionEvent.Compaction.Ended.Sync, (db, data, event) => { + SyncEvent.project(EventV2Bridge.toSyncDefinition(SessionEvent.Compaction.Delta), () => {}), + SyncEvent.project(EventV2Bridge.toSyncDefinition(SessionEvent.Compaction.Ended), (db, data, event) => { update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.compaction.ended", data }) }), ] diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index e8b84524786..ba9a4d6f1a0 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -52,8 +52,9 @@ import { TaskTool, type TaskPromptOps } from "@/tool/task" import { SessionRunState } from "./run-state" import { EffectBridge } from "@/effect/bridge" import { RuntimeFlags } from "@/effect/runtime-flags" -import { SyncEvent } from "@/sync" -import { SessionEvent } from "@/v2/session-event" +import { EventV2 } from "@opencode-ai/core/event" +import { EventV2Bridge } from "@/event-v2-bridge" +import { SessionEvent } from "@opencode-ai/core/session-event" import { ModelV2 } from "@opencode-ai/core/model" import { ProviderV2 } from "@opencode-ai/core/provider" import { AgentAttachment, FileAttachment, ReferenceAttachment, Source } from "@opencode-ai/core/session-prompt" @@ -204,7 +205,7 @@ export const layer = Layer.effect( const sys = yield* SystemPrompt.Service const llm = yield* LLM.Service const references = yield* Reference.Service - const sync = yield* SyncEvent.Service + const events = yield* EventV2Bridge.Service const flags = yield* RuntimeFlags.Service const runner = Effect.fn("SessionPrompt.runner")(function* () { return yield* EffectBridge.make() @@ -944,7 +945,6 @@ NOTE: At any point in time through this workflow you should feel free to ask the providerID: model.providerID, } yield* sessions.updateMessage(msg) - const callID = ulid() const started = Date.now() const part: MessageV2.ToolPart = { type: "tool", @@ -961,10 +961,10 @@ NOTE: At any point in time through this workflow you should feel free to ask the } yield* sessions.updatePart(part) if (flags.experimentalEventSystem) { - yield* sync.run(SessionEvent.Shell.Started.Sync, { + yield* events.publish(SessionEvent.Shell.Started, { sessionID: input.sessionID, timestamp: DateTime.makeUnsafe(started), - callID, + callID: part.callID, command: input.command, }) } @@ -984,7 +984,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the } const completed = Date.now() if (flags.experimentalEventSystem) { - yield* sync.run(SessionEvent.Shell.Ended.Sync, { + yield* events.publish(SessionEvent.Shell.Ended, { sessionID: input.sessionID, timestamp: DateTime.makeUnsafe(completed), callID: part.callID, @@ -1134,7 +1134,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the } if (current?.agent !== info.agent) { - yield* sync.run(SessionEvent.AgentSwitched.Sync, { + yield* events.publish(SessionEvent.AgentSwitched, { sessionID: input.sessionID, timestamp: DateTime.makeUnsafe(info.time.created), agent: info.agent, @@ -1145,7 +1145,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the current.model.id !== info.model.modelID || (current.model.variant === "default" ? undefined : current.model.variant) !== info.model.variant ) { - yield* sync.run(SessionEvent.ModelSwitched.Sync, { + yield* events.publish(SessionEvent.ModelSwitched, { sessionID: input.sessionID, timestamp: DateTime.makeUnsafe(info.time.created), model: { @@ -1586,7 +1586,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the ) // TODO(v2): Temporary dual-write while migrating session messages to v2 events. if (flags.experimentalEventSystem) { - yield* sync.run(SessionEvent.Prompted.Sync, { + yield* events.publish(SessionEvent.Prompted, { sessionID: input.sessionID, timestamp: DateTime.makeUnsafe(info.time.created), prompt: { @@ -1600,7 +1600,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the for (const text of nextPrompt.synthetic) { // TODO(v2): Temporary dual-write while migrating session messages to v2 events. if (flags.experimentalEventSystem) { - yield* sync.run(SessionEvent.Synthetic.Sync, { + yield* events.publish(SessionEvent.Synthetic, { sessionID: input.sessionID, timestamp: DateTime.makeUnsafe(info.time.created), text, @@ -2038,13 +2038,13 @@ export const defaultLayer = Layer.suspend(() => Layer.provide(Image.defaultLayer), Layer.provide( Layer.mergeAll( + EventV2Bridge.defaultLayer, Agent.defaultLayer, SystemPrompt.defaultLayer, LLM.defaultLayer, Reference.defaultLayer, Bus.layer, CrossSpawnSpawner.defaultLayer, - SyncEvent.defaultLayer, RuntimeFlags.defaultLayer, ), ), diff --git a/packages/opencode/src/session/schema.ts b/packages/opencode/src/session/schema.ts index caf8f9d7833..f1622b6958c 100644 --- a/packages/opencode/src/session/schema.ts +++ b/packages/opencode/src/session/schema.ts @@ -1,15 +1,10 @@ import { Schema } from "effect" import { Identifier } from "@/id/id" +import { Session as CoreSession } from "@opencode-ai/core/session" import { withStatics } from "@opencode-ai/core/schema" -export const SessionID = Schema.String.check(Schema.isStartsWith("ses")).pipe( - Schema.brand("SessionID"), - withStatics((s) => ({ - descending: (id?: string) => s.make(Identifier.descending("session", id)), - })), -) - +export const SessionID = CoreSession.ID export type SessionID = Schema.Schema.Type export const MessageID = Schema.String.check(Schema.isStartsWith("msg")).pipe( diff --git a/packages/opencode/src/session/session.sql.ts b/packages/opencode/src/session/session.sql.ts index 18d041f458e..610ca72c469 100644 --- a/packages/opencode/src/session/session.sql.ts +++ b/packages/opencode/src/session/session.sql.ts @@ -1,7 +1,7 @@ import { sqliteTable, text, integer, index, primaryKey, real } from "drizzle-orm/sqlite-core" import { ProjectTable } from "../project/project.sql" import type { MessageV2 } from "./message-v2" -import type { SessionMessage } from "../v2/session-message" +import type { SessionMessage } from "@opencode-ai/core/session-message" import type { Snapshot } from "../snapshot" import type { Permission } from "../permission" import type { ProjectID } from "../project/schema" diff --git a/packages/opencode/src/sync/index.ts b/packages/opencode/src/sync/index.ts index 7f9b8eeef16..d2cc1a1c5f1 100644 --- a/packages/opencode/src/sync/index.ts +++ b/packages/opencode/src/sync/index.ts @@ -1,3 +1,7 @@ +// Legacy sync event system. It should stay unaware of core EventV2 execution; +// the only temporary V2 coupling here is exposing versioned core event schemas +// in effectPayloads() so existing HTTP/SDK schema generation remains stable. +// Remove that registry read when event schemas are generated from core directly. import { Database } from "@/storage/db" import { eq } from "drizzle-orm" import { GlobalBus } from "@/bus/global" @@ -9,6 +13,7 @@ import type { WorkspaceID } from "@/control-plane/schema" import { EventID } from "./schema" import { Context, Effect, Layer, Schema as EffectSchema } from "effect" import type { DeepMutable } from "@opencode-ai/core/schema" +import { EventV2 } from "@opencode-ai/core/event" import { serviceUse } from "@/effect/service-use" import { InstanceState } from "@/effect/instance-state" import { RuntimeFlags } from "@/effect/runtime-flags" @@ -221,6 +226,9 @@ export function reset() { } export function init(input: { projectors: Array<[Definition, ProjectorFunc]>; convertEvent?: ConvertEvent }) { + for (const [def] of input.projectors) { + register(def) + } projectors = new Map(input.projectors) // Install all the latest event defs to the bus. We only ever emit @@ -269,9 +277,7 @@ export function define< properties: (input.busSchema ?? input.schema) as BusSchema, } - versions.set(def.type, Math.max(def.version, versions.get(def.type) || 0)) - - registry.set(versionedType(def.type, def.version), def) + register(def) return def } @@ -280,9 +286,15 @@ export function project( def: Def, func: (db: Database.TxOrDb, data: Event["data"], event: Event) => void, ): [Definition, ProjectorFunc] { + register(def) return [def, func as ProjectorFunc] } +function register(def: Definition) { + versions.set(def.type, Math.max(def.version, versions.get(def.type) || 0)) + registry.set(versionedType(def.type, def.version), def) +} + function process( def: Def, event: Event, @@ -355,19 +367,38 @@ function process( } export function effectPayloads() { - return registry - .entries() - .map(([type, def]) => - EffectSchema.Struct({ - type: EffectSchema.Literal("sync"), - name: EffectSchema.Literal(type), - id: EffectSchema.String, - seq: EffectSchema.Finite, - aggregateID: EffectSchema.Literal(def.aggregate), - data: def.schema, - }).annotate({ identifier: `SyncEvent.${type}` }), - ) - .toArray() + return [ + ...registry + .entries() + .map(([type, def]) => + EffectSchema.Struct({ + type: EffectSchema.Literal("sync"), + name: EffectSchema.Literal(type), + id: EffectSchema.String, + seq: EffectSchema.Finite, + aggregateID: EffectSchema.Literal(def.aggregate), + data: def.schema, + }).annotate({ identifier: `SyncEvent.${type}` }), + ) + .toArray(), + ...EventV2.registry + .values() + .filter( + (definition) => + definition.version !== undefined && !registry.has(versionedType(definition.type, definition.version)), + ) + .map((definition) => + EffectSchema.Struct({ + type: EffectSchema.Literal("sync"), + name: EffectSchema.Literal(versionedType(definition.type, definition.version!)), + id: EffectSchema.String, + seq: EffectSchema.Finite, + aggregateID: EffectSchema.String, + data: definition.data, + }).annotate({ identifier: `SyncEvent.${definition.type}` }), + ) + .toArray(), + ] } export * as SyncEvent from "." diff --git a/packages/opencode/src/v2/event.ts b/packages/opencode/src/v2/event.ts deleted file mode 100644 index 14ee44dd528..00000000000 --- a/packages/opencode/src/v2/event.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { Identifier } from "@/id/id" -import { SyncEvent } from "@/sync" -import { withStatics } from "@opencode-ai/core/schema" -import * as Schema from "effect/Schema" - -export const ID = Schema.String.pipe( - Schema.brand("Event.ID"), - withStatics((s) => ({ - create: () => s.make(Identifier.create("evt", "ascending")), - })), -) -export type ID = Schema.Schema.Type - -export function define(input: { - type: Type - schema: Fields - aggregate: string - version?: number -}) { - const Payload = Schema.Struct({ - id: ID, - metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional), - type: Schema.Literal(input.type), - data: Schema.Struct(input.schema), - }).annotate({ - identifier: input.type, - }) - - const Sync = SyncEvent.define({ - type: input.type, - version: input.version ?? 1, - aggregate: input.aggregate, - schema: Payload.fields.data, - }) - - return Object.assign(Payload, { - Sync, - version: input.version, - aggregate: input.aggregate, - }) -} - -export * as EventV2 from "./event" diff --git a/packages/opencode/src/v2/session.ts b/packages/opencode/src/v2/session.ts index 97c31d39b22..a3c386f66d4 100644 --- a/packages/opencode/src/v2/session.ts +++ b/packages/opencode/src/v2/session.ts @@ -4,14 +4,14 @@ import { WorkspaceID } from "@/control-plane/schema" import { and, asc, desc, eq, gt, gte, isNull, like, lt, or, type SQL } from "@/storage/db" import * as Database from "@/storage/db" import { Context, DateTime, Effect, Layer, Option, Schema } from "effect" -import { SessionMessage } from "./session-message" +import { SessionMessage } from "@opencode-ai/core/session-message" import type { Prompt } from "@opencode-ai/core/session-prompt" -import { EventV2 } from "./event" import { ProjectID } from "@/project/schema" -import { SessionEvent } from "./session-event" +import { SessionEvent } from "@opencode-ai/core/session-event" import { V2Schema } from "@opencode-ai/core/v2-schema" import { optionalOmitUndefined } from "@opencode-ai/core/schema" -import { SyncEvent } from "@/sync" +import { EventV2 } from "@opencode-ai/core/event" +import { EventV2Bridge } from "@/event-v2-bridge" import { ModelV2 } from "@opencode-ai/core/model" import { ProviderV2 } from "@opencode-ai/core/provider" @@ -125,7 +125,7 @@ export class Service extends Context.Service()("@opencode/v2 export const layer = Layer.effect( Service, Effect.gen(function* () { - const sync = yield* SyncEvent.Service + const events = yield* EventV2Bridge.Service const decodeMessage = Schema.decodeUnknownSync(SessionMessage.Message) const decode = (row: typeof SessionMessageTable.$inferSelect) => @@ -292,14 +292,14 @@ export const layer = Layer.effect( shell: Effect.fn("V2Session.shell")(function* (_input) {}), skill: Effect.fn("V2Session.skill")(function* (_input) {}), switchAgent: Effect.fn("V2Session.switchAgent")(function* (input) { - yield* sync.run(SessionEvent.AgentSwitched.Sync, { + yield* events.publish(SessionEvent.AgentSwitched, { sessionID: input.sessionID, timestamp: DateTime.makeUnsafe(Date.now()), agent: input.agent, }) }), switchModel: Effect.fn("V2Session.switchModel")(function* (input) { - yield* sync.run(SessionEvent.ModelSwitched.Sync, { + yield* events.publish(SessionEvent.ModelSwitched, { sessionID: input.sessionID, timestamp: DateTime.makeUnsafe(Date.now()), model: input.model, @@ -334,6 +334,6 @@ export const layer = Layer.effect( }), ) -export const defaultLayer = layer.pipe(Layer.provide(SyncEvent.defaultLayer)) +export const defaultLayer = layer.pipe(Layer.provide(EventV2Bridge.defaultLayer)) export * as SessionV2 from "./session" diff --git a/packages/opencode/test/server/httpapi-session.test.ts b/packages/opencode/test/server/httpapi-session.test.ts index 3e5527761e6..bfdc42d9960 100644 --- a/packages/opencode/test/server/httpapi-session.test.ts +++ b/packages/opencode/test/server/httpapi-session.test.ts @@ -19,7 +19,7 @@ import { MessageID, PartID, SessionID, type SessionID as SessionIDType } from ". import { MessageV2 } from "../../src/session/message-v2" import { Database } from "@/storage/db" import { SessionMessageTable, SessionTable } from "@/session/session.sql" -import { SessionMessage } from "../../src/v2/session-message" +import { SessionMessage } from "@opencode-ai/core/session-message" import { ModelV2 } from "@opencode-ai/core/model" import { ProviderV2 } from "@opencode-ai/core/provider" import * as DateTime from "effect/DateTime" diff --git a/packages/opencode/test/session/compaction.test.ts b/packages/opencode/test/session/compaction.test.ts index d8a41679027..2bc9b196216 100644 --- a/packages/opencode/test/session/compaction.test.ts +++ b/packages/opencode/test/session/compaction.test.ts @@ -29,6 +29,7 @@ import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { TestConfig } from "../fixture/config" import { SyncEvent } from "@/sync" import { RuntimeFlags } from "@/effect/runtime-flags" +import { EventV2Bridge } from "@/event-v2-bridge" void Log.init({ print: false }) @@ -227,6 +228,7 @@ const deps = Layer.mergeAll( Config.defaultLayer, SyncEvent.defaultLayer, RuntimeFlags.layer({ experimentalEventSystem: true }), + EventV2Bridge.defaultLayer, ) const env = Layer.mergeAll( @@ -276,6 +278,7 @@ function compactionProcessLayer(options?: CompactionProcessOptions) { Layer.provide(options?.config ?? Config.defaultLayer), Layer.provide(SyncEvent.defaultLayer), Layer.provide(RuntimeFlags.layer({ experimentalEventSystem: true })), + Layer.provide(EventV2Bridge.defaultLayer), ) } diff --git a/packages/opencode/test/session/processor-effect.test.ts b/packages/opencode/test/session/processor-effect.test.ts index 61c566eaec2..78c7e4c6422 100644 --- a/packages/opencode/test/session/processor-effect.test.ts +++ b/packages/opencode/test/session/processor-effect.test.ts @@ -26,6 +26,7 @@ import { testEffect } from "../lib/effect" import { raw, reply, TestLLMServer } from "../lib/llm-server" import { SyncEvent } from "@/sync" import { RuntimeFlags } from "@/effect/runtime-flags" +import { EventV2Bridge } from "@/event-v2-bridge" void Log.init({ print: false }) @@ -180,6 +181,7 @@ const deps = Layer.mergeAll( Provider.defaultLayer, status, SyncEvent.defaultLayer, + EventV2Bridge.defaultLayer, ).pipe(Layer.provideMerge(infra)) const env = Layer.mergeAll( TestLLMServer.layer, diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts index f38112b099f..891efc18721 100644 --- a/packages/opencode/test/session/prompt.test.ts +++ b/packages/opencode/test/session/prompt.test.ts @@ -53,6 +53,7 @@ import { awaitWithTimeout, pollWithTimeout, testEffect } from "../lib/effect" import { reply, TestLLMServer } from "../lib/llm-server" import { SyncEvent } from "@/sync" import { RuntimeFlags } from "@/effect/runtime-flags" +import { EventV2Bridge } from "@/event-v2-bridge" void Log.init({ print: false }) @@ -180,6 +181,7 @@ function makeHttp(input?: { processor?: "blocking" }) { BackgroundJob.defaultLayer, status, SyncEvent.defaultLayer, + EventV2Bridge.defaultLayer, ).pipe(Layer.provideMerge(infra)) const question = Question.layer.pipe(Layer.provideMerge(deps)) const todo = Todo.layer.pipe(Layer.provideMerge(deps)) diff --git a/packages/opencode/test/session/snapshot-tool-race.test.ts b/packages/opencode/test/session/snapshot-tool-race.test.ts index 13ab26d91e9..664b02a6cc2 100644 --- a/packages/opencode/test/session/snapshot-tool-race.test.ts +++ b/packages/opencode/test/session/snapshot-tool-race.test.ts @@ -61,6 +61,7 @@ import { Format } from "../../src/format" import { Reference } from "../../src/reference/reference" import { SyncEvent } from "@/sync" import { RuntimeFlags } from "@/effect/runtime-flags" +import { EventV2Bridge } from "@/event-v2-bridge" void Log.init({ print: false }) @@ -129,6 +130,7 @@ function makeHttp() { BackgroundJob.defaultLayer, status, SyncEvent.defaultLayer, + EventV2Bridge.defaultLayer, ).pipe(Layer.provideMerge(infra)) const question = Question.layer.pipe(Layer.provideMerge(deps)) const todo = Todo.layer.pipe(Layer.provideMerge(deps)) diff --git a/packages/opencode/test/v2/session-message-updater.test.ts b/packages/opencode/test/v2/session-message-updater.test.ts index 180483937c5..588521281ce 100644 --- a/packages/opencode/test/v2/session-message-updater.test.ts +++ b/packages/opencode/test/v2/session-message-updater.test.ts @@ -1,11 +1,11 @@ import { expect, test } from "bun:test" import * as DateTime from "effect/DateTime" import { SessionID } from "../../src/session/schema" -import { EventV2 } from "../../src/v2/event" +import { EventV2 } from "@opencode-ai/core/event" import { ModelV2 } from "@opencode-ai/core/model" import { ProviderV2 } from "@opencode-ai/core/provider" -import { SessionEvent } from "../../src/v2/session-event" -import { SessionMessageUpdater } from "../../src/v2/session-message-updater" +import { SessionEvent } from "@opencode-ai/core/session-event" +import { SessionMessageUpdater } from "@opencode-ai/core/session-message-updater" test("step snapshots carry over to assistant messages", () => { const state: SessionMessageUpdater.MemoryState = { messages: [] } diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index 5e40cc0475a..9aed5ef9c7e 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -4387,14 +4387,14 @@ export class Model extends HeyApiClient { */ public list( parameters?: { - instance?: { + location?: { directory?: string workspace?: string } }, options?: Options, ) { - const params = buildClientParams([parameters], [{ args: [{ in: "query", key: "instance" }] }]) + const params = buildClientParams([parameters], [{ args: [{ in: "query", key: "location" }] }]) return (options?.client ?? this.client).get({ url: "/api/model", ...options, @@ -4411,14 +4411,14 @@ export class Provider2 extends HeyApiClient { */ public list( parameters?: { - instance?: { + location?: { directory?: string workspace?: string } }, options?: Options, ) { - const params = buildClientParams([parameters], [{ args: [{ in: "query", key: "instance" }] }]) + const params = buildClientParams([parameters], [{ args: [{ in: "query", key: "location" }] }]) return (options?.client ?? this.client).get({ url: "/api/provider", ...options, @@ -4434,7 +4434,7 @@ export class Provider2 extends HeyApiClient { public get( parameters: { providerID: string - instance?: { + location?: { directory?: string workspace?: string } @@ -4447,7 +4447,7 @@ export class Provider2 extends HeyApiClient { { args: [ { in: "path", key: "providerID" }, - { in: "query", key: "instance" }, + { in: "query", key: "location" }, ], }, ], diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index de2e5ff192b..b5eb301739b 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -42,8 +42,42 @@ export type Event = | EventPtyDeleted | EventInstallationUpdated | EventInstallationUpdateAvailable + | EventMessageUpdated + | EventMessageRemoved + | EventMessagePartUpdated + | EventMessagePartRemoved + | EventSessionCreated + | EventSessionUpdated + | EventSessionDeleted + | EventSessionNextAgentSwitched + | EventSessionNextModelSwitched + | EventSessionNextPrompted + | EventSessionNextSynthetic + | EventSessionNextShellStarted + | EventSessionNextShellEnded + | EventSessionNextStepStarted + | EventSessionNextStepEnded + | EventSessionNextStepFailed + | EventSessionNextTextStarted + | EventSessionNextTextDelta + | EventSessionNextTextEnded + | EventSessionNextToolInputStarted + | EventSessionNextToolInputDelta + | EventSessionNextToolInputEnded + | EventSessionNextToolCalled + | EventSessionNextToolSuccess + | EventSessionNextToolFailed + | EventSessionNextReasoningStarted + | EventSessionNextReasoningDelta + | EventSessionNextReasoningEnded + | EventSessionNextRetried + | EventSessionNextCompactionStarted + | EventSessionNextCompactionDelta + | EventSessionNextCompactionEnded | EventServerConnected | EventGlobalDisposed + | EventSessionNextToolProgress + | EventCatalogModelUpdated export type OAuth = { type: "oauth" @@ -793,8 +827,42 @@ export type GlobalEvent = { | EventPtyDeleted | EventInstallationUpdated | EventInstallationUpdateAvailable + | EventMessageUpdated + | EventMessageRemoved + | EventMessagePartUpdated + | EventMessagePartRemoved + | EventSessionCreated + | EventSessionUpdated + | EventSessionDeleted + | EventSessionNextAgentSwitched + | EventSessionNextModelSwitched + | EventSessionNextPrompted + | EventSessionNextSynthetic + | EventSessionNextShellStarted + | EventSessionNextShellEnded + | EventSessionNextStepStarted + | EventSessionNextStepEnded + | EventSessionNextStepFailed + | EventSessionNextTextStarted + | EventSessionNextTextDelta + | EventSessionNextTextEnded + | EventSessionNextToolInputStarted + | EventSessionNextToolInputDelta + | EventSessionNextToolInputEnded + | EventSessionNextToolCalled + | EventSessionNextToolSuccess + | EventSessionNextToolFailed + | EventSessionNextReasoningStarted + | EventSessionNextReasoningDelta + | EventSessionNextReasoningEnded + | EventSessionNextRetried + | EventSessionNextCompactionStarted + | EventSessionNextCompactionDelta + | EventSessionNextCompactionEnded | EventServerConnected | EventGlobalDisposed + | EventSessionNextToolProgress + | EventCatalogModelUpdated | SyncEventMessageUpdated | SyncEventMessageRemoved | SyncEventMessagePartUpdated @@ -814,20 +882,20 @@ export type GlobalEvent = { | SyncEventSessionNextTextStarted | SyncEventSessionNextTextDelta | SyncEventSessionNextTextEnded - | SyncEventSessionNextReasoningStarted - | SyncEventSessionNextReasoningDelta - | SyncEventSessionNextReasoningEnded | SyncEventSessionNextToolInputStarted | SyncEventSessionNextToolInputDelta | SyncEventSessionNextToolInputEnded | SyncEventSessionNextToolCalled - | SyncEventSessionNextToolProgress | SyncEventSessionNextToolSuccess | SyncEventSessionNextToolFailed + | SyncEventSessionNextReasoningStarted + | SyncEventSessionNextReasoningDelta + | SyncEventSessionNextReasoningEnded | SyncEventSessionNextRetried | SyncEventSessionNextCompactionStarted | SyncEventSessionNextCompactionDelta | SyncEventSessionNextCompactionEnded + | SyncEventSessionNextToolProgress } /** @@ -2115,47 +2183,6 @@ export type SyncEventSessionNextTextEnded = { } } -export type SyncEventSessionNextReasoningStarted = { - type: "sync" - name: "session.next.reasoning.started.1" - id: string - seq: number - aggregateID: "sessionID" - data: { - timestamp: number - sessionID: string - reasoningID: string - } -} - -export type SyncEventSessionNextReasoningDelta = { - type: "sync" - name: "session.next.reasoning.delta.1" - id: string - seq: number - aggregateID: "sessionID" - data: { - timestamp: number - sessionID: string - reasoningID: string - delta: string - } -} - -export type SyncEventSessionNextReasoningEnded = { - type: "sync" - name: "session.next.reasoning.ended.1" - id: string - seq: number - aggregateID: "sessionID" - data: { - timestamp: number - sessionID: string - reasoningID: string - text: string - } -} - export type SyncEventSessionNextToolInputStarted = { type: "sync" name: "session.next.tool.input.started.1" @@ -2221,23 +2248,6 @@ export type SyncEventSessionNextToolCalled = { } } -export type SyncEventSessionNextToolProgress = { - type: "sync" - name: "session.next.tool.progress.1" - id: string - seq: number - aggregateID: "sessionID" - data: { - timestamp: number - sessionID: string - callID: string - structured: { - [key: string]: unknown - } - content: Array - } -} - export type SyncEventSessionNextToolSuccess = { type: "sync" name: "session.next.tool.success.1" @@ -2281,6 +2291,47 @@ export type SyncEventSessionNextToolFailed = { } } +export type SyncEventSessionNextReasoningStarted = { + type: "sync" + name: "session.next.reasoning.started.1" + id: string + seq: number + aggregateID: "sessionID" + data: { + timestamp: number + sessionID: string + reasoningID: string + } +} + +export type SyncEventSessionNextReasoningDelta = { + type: "sync" + name: "session.next.reasoning.delta.1" + id: string + seq: number + aggregateID: "sessionID" + data: { + timestamp: number + sessionID: string + reasoningID: string + delta: string + } +} + +export type SyncEventSessionNextReasoningEnded = { + type: "sync" + name: "session.next.reasoning.ended.1" + id: string + seq: number + aggregateID: "sessionID" + data: { + timestamp: number + sessionID: string + reasoningID: string + text: string + } +} + export type SyncEventSessionNextRetried = { type: "sync" name: "session.next.retried.1" @@ -2335,6 +2386,23 @@ export type SyncEventSessionNextCompactionEnded = { } } +export type SyncEventSessionNextToolProgress = { + type: "sync" + name: "session.next.tool.progress.1" + id: string + seq: number + aggregateID: string + data: { + timestamp: number + sessionID: string + callID: string + structured: { + [key: string]: unknown + } + content: Array + } +} + export type EventServerInstanceDisposed = { id: string type: "server.instance.disposed" @@ -2615,19 +2683,92 @@ export type EventInstallationUpdateAvailable = { } } -export type EventServerConnected = { +export type EventMessageUpdated = { id: string - type: "server.connected" + type: "message.updated" properties: { - [key: string]: unknown + sessionID: string + info: Message } } -export type EventGlobalDisposed = { +export type EventMessageRemoved = { id: string - type: "global.disposed" + type: "message.removed" properties: { - [key: string]: unknown + sessionID: string + messageID: string + } +} + +export type EventMessagePartUpdated = { + id: string + type: "message.part.updated" + properties: { + sessionID: string + part: Part + time: number + } +} + +export type EventMessagePartRemoved = { + id: string + type: "message.part.removed" + properties: { + sessionID: string + messageID: string + partID: string + } +} + +export type EventSessionCreated = { + id: string + type: "session.created" + properties: { + sessionID: string + info: Session + } +} + +export type EventSessionUpdated = { + id: string + type: "session.updated" + properties: { + sessionID: string + info: Session + } +} + +export type EventSessionDeleted = { + id: string + type: "session.deleted" + properties: { + sessionID: string + info: Session + } +} + +export type EventSessionNextAgentSwitched = { + id: string + type: "session.next.agent.switched" + properties: { + timestamp: number + sessionID: string + agent: string + } +} + +export type EventSessionNextModelSwitched = { + id: string + type: "session.next.model.switched" + properties: { + timestamp: number + sessionID: string + model: { + id: string + providerID: string + variant: string + } } } @@ -2662,11 +2803,182 @@ export type PromptReferenceAttachment = { source?: PromptSource } +export type EventSessionNextPrompted = { + id: string + type: "session.next.prompted" + properties: { + timestamp: number + sessionID: string + prompt: Prompt + } +} + +export type EventSessionNextSynthetic = { + id: string + type: "session.next.synthetic" + properties: { + timestamp: number + sessionID: string + text: string + } +} + +export type EventSessionNextShellStarted = { + id: string + type: "session.next.shell.started" + properties: { + timestamp: number + sessionID: string + callID: string + command: string + } +} + +export type EventSessionNextShellEnded = { + id: string + type: "session.next.shell.ended" + properties: { + timestamp: number + sessionID: string + callID: string + output: string + } +} + +export type EventSessionNextStepStarted = { + id: string + type: "session.next.step.started" + properties: { + timestamp: number + sessionID: string + agent: string + model: { + id: string + providerID: string + variant: string + } + snapshot?: string + } +} + +export type EventSessionNextStepEnded = { + id: string + type: "session.next.step.ended" + properties: { + timestamp: number + sessionID: string + finish: string + cost: number + tokens: { + input: number + output: number + reasoning: number + cache: { + read: number + write: number + } + } + snapshot?: string + } +} + export type SessionErrorUnknown = { type: "unknown" message: string } +export type EventSessionNextStepFailed = { + id: string + type: "session.next.step.failed" + properties: { + timestamp: number + sessionID: string + error: SessionErrorUnknown + } +} + +export type EventSessionNextTextStarted = { + id: string + type: "session.next.text.started" + properties: { + timestamp: number + sessionID: string + } +} + +export type EventSessionNextTextDelta = { + id: string + type: "session.next.text.delta" + properties: { + timestamp: number + sessionID: string + delta: string + } +} + +export type EventSessionNextTextEnded = { + id: string + type: "session.next.text.ended" + properties: { + timestamp: number + sessionID: string + text: string + } +} + +export type EventSessionNextToolInputStarted = { + id: string + type: "session.next.tool.input.started" + properties: { + timestamp: number + sessionID: string + callID: string + name: string + } +} + +export type EventSessionNextToolInputDelta = { + id: string + type: "session.next.tool.input.delta" + properties: { + timestamp: number + sessionID: string + callID: string + delta: string + } +} + +export type EventSessionNextToolInputEnded = { + id: string + type: "session.next.tool.input.ended" + properties: { + timestamp: number + sessionID: string + callID: string + text: string + } +} + +export type EventSessionNextToolCalled = { + id: string + type: "session.next.tool.called" + properties: { + timestamp: number + sessionID: string + callID: string + tool: string + input: { + [key: string]: unknown + } + provider: { + executed: boolean + metadata?: { + [key: string]: unknown + } + } + } +} + export type ToolTextContent = { type: "text" text: string @@ -2679,6 +2991,75 @@ export type ToolFileContent = { name?: string } +export type EventSessionNextToolSuccess = { + id: string + type: "session.next.tool.success" + properties: { + timestamp: number + sessionID: string + callID: string + structured: { + [key: string]: unknown + } + content: Array + provider: { + executed: boolean + metadata?: { + [key: string]: unknown + } + } + } +} + +export type EventSessionNextToolFailed = { + id: string + type: "session.next.tool.failed" + properties: { + timestamp: number + sessionID: string + callID: string + error: SessionErrorUnknown + provider: { + executed: boolean + metadata?: { + [key: string]: unknown + } + } + } +} + +export type EventSessionNextReasoningStarted = { + id: string + type: "session.next.reasoning.started" + properties: { + timestamp: number + sessionID: string + reasoningID: string + } +} + +export type EventSessionNextReasoningDelta = { + id: string + type: "session.next.reasoning.delta" + properties: { + timestamp: number + sessionID: string + reasoningID: string + delta: string + } +} + +export type EventSessionNextReasoningEnded = { + id: string + type: "session.next.reasoning.ended" + properties: { + timestamp: number + sessionID: string + reasoningID: string + text: string + } +} + export type SessionNextRetryError = { message: string statusCode?: number @@ -2692,6 +3073,184 @@ export type SessionNextRetryError = { } } +export type EventSessionNextRetried = { + id: string + type: "session.next.retried" + properties: { + timestamp: number + sessionID: string + attempt: number + error: SessionNextRetryError + } +} + +export type EventSessionNextCompactionStarted = { + id: string + type: "session.next.compaction.started" + properties: { + timestamp: number + sessionID: string + reason: "auto" | "manual" + } +} + +export type EventSessionNextCompactionDelta = { + id: string + type: "session.next.compaction.delta" + properties: { + timestamp: number + sessionID: string + text: string + } +} + +export type EventSessionNextCompactionEnded = { + id: string + type: "session.next.compaction.ended" + properties: { + timestamp: number + sessionID: string + text: string + include?: string + } +} + +export type EventServerConnected = { + id: string + type: "server.connected" + properties: { + [key: string]: unknown + } +} + +export type EventGlobalDisposed = { + id: string + type: "global.disposed" + properties: { + [key: string]: unknown + } +} + +export type EventSessionNextToolProgress = { + id: string + type: "session.next.tool.progress" + properties: { + timestamp: number + sessionID: string + callID: string + structured: { + [key: string]: unknown + } + content: Array + } +} + +export type ModelV2Info = { + id: string + apiID: string + providerID: string + family?: string + name: string + endpoint: + | { + type: "unknown" + } + | { + type: "openai/responses" + url: string + websocket?: boolean + } + | { + type: "openai/completions" + url: string + reasoning?: + | { + type: "reasoning_content" + } + | { + type: "reasoning_details" + } + } + | { + type: "anthropic/messages" + url: string + } + | { + type: "aisdk" + package: string + url?: string + } + capabilities: { + tools: boolean + input: Array + output: Array + } + options: { + headers: { + [key: string]: string + } + body: { + [key: string]: unknown + } + aisdk: { + provider: { + [key: string]: unknown + } + request: { + [key: string]: unknown + } + } + variant?: string + } + variants: Array<{ + id: string + headers: { + [key: string]: string + } + body: { + [key: string]: unknown + } + aisdk: { + provider: { + [key: string]: unknown + } + request: { + [key: string]: unknown + } + } + }> + time: { + released: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + } + cost: Array<{ + tier?: { + type: "context" + size: number + } + input: number + output: number + cache: { + read: number + write: number + } + }> + status: "alpha" | "beta" | "deprecated" | "active" + enabled: boolean + limit: { + context: number + input?: number + output: number + } +} + +export type EventCatalogModelUpdated = { + id: string + type: "catalog.model.updated" + properties: { + model: ModelV2Info + } +} + export type SessionInfo = { id: string parentID?: string @@ -2927,104 +3486,6 @@ export type SessionMessage = | SessionMessageAssistant | SessionMessageCompaction -export type ModelV2Info = { - id: string - apiID: string - providerID: string - family?: string - name: string - endpoint: - | { - type: "unknown" - } - | { - type: "openai/responses" - url: string - websocket?: boolean - } - | { - type: "openai/completions" - url: string - reasoning?: - | { - type: "reasoning_content" - } - | { - type: "reasoning_details" - } - } - | { - type: "anthropic/messages" - url: string - } - | { - type: "aisdk" - package: string - url?: string - } - capabilities: { - tools: boolean - input: Array - output: Array - } - options: { - headers: { - [key: string]: string - } - body: { - [key: string]: unknown - } - aisdk: { - provider: { - [key: string]: unknown - } - request: { - [key: string]: unknown - } - } - variant?: string - } - variants: Array<{ - id: string - headers: { - [key: string]: string - } - body: { - [key: string]: unknown - } - aisdk: { - provider: { - [key: string]: unknown - } - request: { - [key: string]: unknown - } - } - }> - time: { - released: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" - } - cost: Array<{ - tier?: { - type: "context" - size: number - } - input: number - output: number - cache: { - read: number - write: number - } - }> - status: "alpha" | "beta" | "deprecated" | "active" - enabled: boolean - limit: { - context: number - input?: number - output: number - } -} - export type ProviderV2Info = { id: string name: string @@ -3103,6 +3564,104 @@ export type EventTuiToastShow1 = { } } +export type ModelV2Info1 = { + id: string + apiID: string + providerID: string + family?: string + name: string + endpoint: + | { + type: "unknown" + } + | { + type: "openai/responses" + url: string + websocket?: boolean + } + | { + type: "openai/completions" + url: string + reasoning?: + | { + type: "reasoning_content" + } + | { + type: "reasoning_details" + } + } + | { + type: "anthropic/messages" + url: string + } + | { + type: "aisdk" + package: string + url?: string + } + capabilities: { + tools: boolean + input: Array + output: Array + } + options: { + headers: { + [key: string]: string + } + body: { + [key: string]: unknown + } + aisdk: { + provider: { + [key: string]: unknown + } + request: { + [key: string]: unknown + } + } + variant?: string + } + variants: Array<{ + id: string + headers: { + [key: string]: string + } + body: { + [key: string]: unknown + } + aisdk: { + provider: { + [key: string]: unknown + } + request: { + [key: string]: unknown + } + } + }> + time: { + released: number | "NaN" | "Infinity" | "-Infinity" + } + cost: Array<{ + tier?: { + type: "context" + size: number + } + input: number + output: number + cache: { + read: number + write: number + } + }> + status: "alpha" | "beta" | "deprecated" | "active" + enabled: boolean + limit: { + context: number + input?: number + output: number + } +} + export type BadRequestError = { name: "BadRequest" data: { @@ -6223,7 +6782,7 @@ export type V2ModelListData = { body?: never path?: never query?: { - instance?: { + location?: { directory?: string workspace?: string } @@ -6244,7 +6803,7 @@ export type V2ProviderListData = { body?: never path?: never query?: { - instance?: { + location?: { directory?: string workspace?: string } @@ -6267,7 +6826,7 @@ export type V2ProviderGetData = { providerID: string } query?: { - instance?: { + location?: { directory?: string workspace?: string } From fd6a8520b8dec3e676ce8f6af145081bdf626cf7 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Fri, 15 May 2026 00:52:06 +0000 Subject: [PATCH 004/164] chore: generate --- packages/core/test/event.test.ts | 1 - packages/sdk/js/src/v2/gen/types.gen.ts | 439 ++-- packages/sdk/openapi.json | 2659 ++++++++++++++++++----- 3 files changed, 2251 insertions(+), 848 deletions(-) diff --git a/packages/core/test/event.test.ts b/packages/core/test/event.test.ts index b3969bf9ae2..b67b2897a1b 100644 --- a/packages/core/test/event.test.ts +++ b/packages/core/test/event.test.ts @@ -129,5 +129,4 @@ describe("EventV2", () => { expect(received).toEqual([Message.type, "stream"]) }), ) - }) diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index b5eb301739b..40ad4f5a6c5 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -21,7 +21,6 @@ export type Event = | EventTodoUpdated | EventSessionStatus | EventSessionIdle - | EventSessionCompacted | EventTuiPromptAppend | EventTuiCommandExecute | EventTuiToastShow1 @@ -30,6 +29,7 @@ export type Event = | EventMcpBrowserOpenFailed | EventCommandExecuted | EventProjectUpdated + | EventSessionCompacted | EventVcsBranchUpdated | EventWorkspaceReady | EventWorkspaceFailed @@ -42,13 +42,9 @@ export type Event = | EventPtyDeleted | EventInstallationUpdated | EventInstallationUpdateAvailable - | EventMessageUpdated - | EventMessageRemoved - | EventMessagePartUpdated - | EventMessagePartRemoved - | EventSessionCreated - | EventSessionUpdated - | EventSessionDeleted + | EventServerConnected + | EventGlobalDisposed + | EventCatalogModelUpdated | EventSessionNextAgentSwitched | EventSessionNextModelSwitched | EventSessionNextPrompted @@ -61,23 +57,20 @@ export type Event = | EventSessionNextTextStarted | EventSessionNextTextDelta | EventSessionNextTextEnded + | EventSessionNextReasoningStarted + | EventSessionNextReasoningDelta + | EventSessionNextReasoningEnded | EventSessionNextToolInputStarted | EventSessionNextToolInputDelta | EventSessionNextToolInputEnded | EventSessionNextToolCalled + | EventSessionNextToolProgress | EventSessionNextToolSuccess | EventSessionNextToolFailed - | EventSessionNextReasoningStarted - | EventSessionNextReasoningDelta - | EventSessionNextReasoningEnded | EventSessionNextRetried | EventSessionNextCompactionStarted | EventSessionNextCompactionDelta | EventSessionNextCompactionEnded - | EventServerConnected - | EventGlobalDisposed - | EventSessionNextToolProgress - | EventCatalogModelUpdated export type OAuth = { type: "oauth" @@ -370,6 +363,13 @@ export type Pty = { pid: number } +export type Prompt = { + text: string + files?: Array + agents?: Array + references?: Array +} + export type OutputFormatText = { type: "text" } @@ -778,13 +778,6 @@ export type Session = { } } -export type Prompt = { - text: string - files?: Array - agents?: Array - references?: Array -} - export type GlobalEvent = { directory: string project?: string @@ -806,7 +799,6 @@ export type GlobalEvent = { | EventTodoUpdated | EventSessionStatus | EventSessionIdle - | EventSessionCompacted | EventTuiPromptAppend | EventTuiCommandExecute | EventTuiToastShow @@ -815,6 +807,7 @@ export type GlobalEvent = { | EventMcpBrowserOpenFailed | EventCommandExecuted | EventProjectUpdated + | EventSessionCompacted | EventVcsBranchUpdated | EventWorkspaceReady | EventWorkspaceFailed @@ -827,13 +820,9 @@ export type GlobalEvent = { | EventPtyDeleted | EventInstallationUpdated | EventInstallationUpdateAvailable - | EventMessageUpdated - | EventMessageRemoved - | EventMessagePartUpdated - | EventMessagePartRemoved - | EventSessionCreated - | EventSessionUpdated - | EventSessionDeleted + | EventServerConnected + | EventGlobalDisposed + | EventCatalogModelUpdated | EventSessionNextAgentSwitched | EventSessionNextModelSwitched | EventSessionNextPrompted @@ -846,23 +835,20 @@ export type GlobalEvent = { | EventSessionNextTextStarted | EventSessionNextTextDelta | EventSessionNextTextEnded + | EventSessionNextReasoningStarted + | EventSessionNextReasoningDelta + | EventSessionNextReasoningEnded | EventSessionNextToolInputStarted | EventSessionNextToolInputDelta | EventSessionNextToolInputEnded | EventSessionNextToolCalled + | EventSessionNextToolProgress | EventSessionNextToolSuccess | EventSessionNextToolFailed - | EventSessionNextReasoningStarted - | EventSessionNextReasoningDelta - | EventSessionNextReasoningEnded | EventSessionNextRetried | EventSessionNextCompactionStarted | EventSessionNextCompactionDelta | EventSessionNextCompactionEnded - | EventServerConnected - | EventGlobalDisposed - | EventSessionNextToolProgress - | EventCatalogModelUpdated | SyncEventMessageUpdated | SyncEventMessageRemoved | SyncEventMessagePartUpdated @@ -2542,14 +2528,6 @@ export type EventSessionIdle = { } } -export type EventSessionCompacted = { - id: string - type: "session.compacted" - properties: { - sessionID: string - } -} - export type EventMcpToolsChanged = { id: string type: "mcp.tools.changed" @@ -2584,6 +2562,14 @@ export type EventProjectUpdated = { properties: Project } +export type EventSessionCompacted = { + id: string + type: "session.compacted" + properties: { + sessionID: string + } +} + export type EventVcsBranchUpdated = { id: string type: "vcs.branch.updated" @@ -2683,68 +2669,125 @@ export type EventInstallationUpdateAvailable = { } } -export type EventMessageUpdated = { +export type EventServerConnected = { id: string - type: "message.updated" + type: "server.connected" properties: { - sessionID: string - info: Message + [key: string]: unknown } } -export type EventMessageRemoved = { +export type EventGlobalDisposed = { id: string - type: "message.removed" + type: "global.disposed" properties: { - sessionID: string - messageID: string + [key: string]: unknown } } -export type EventMessagePartUpdated = { +export type ModelV2Info = { id: string - type: "message.part.updated" - properties: { - sessionID: string - part: Part - time: number + apiID: string + providerID: string + family?: string + name: string + endpoint: + | { + type: "unknown" + } + | { + type: "openai/responses" + url: string + websocket?: boolean + } + | { + type: "openai/completions" + url: string + reasoning?: + | { + type: "reasoning_content" + } + | { + type: "reasoning_details" + } + } + | { + type: "anthropic/messages" + url: string + } + | { + type: "aisdk" + package: string + url?: string + } + capabilities: { + tools: boolean + input: Array + output: Array + } + options: { + headers: { + [key: string]: string + } + body: { + [key: string]: unknown + } + aisdk: { + provider: { + [key: string]: unknown + } + request: { + [key: string]: unknown + } + } + variant?: string + } + variants: Array<{ + id: string + headers: { + [key: string]: string + } + body: { + [key: string]: unknown + } + aisdk: { + provider: { + [key: string]: unknown + } + request: { + [key: string]: unknown + } + } + }> + time: { + released: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + } + cost: Array<{ + tier?: { + type: "context" + size: number + } + input: number + output: number + cache: { + read: number + write: number + } + }> + status: "alpha" | "beta" | "deprecated" | "active" + enabled: boolean + limit: { + context: number + input?: number + output: number } } -export type EventMessagePartRemoved = { +export type EventCatalogModelUpdated = { id: string - type: "message.part.removed" + type: "catalog.model.updated" properties: { - sessionID: string - messageID: string - partID: string - } -} - -export type EventSessionCreated = { - id: string - type: "session.created" - properties: { - sessionID: string - info: Session - } -} - -export type EventSessionUpdated = { - id: string - type: "session.updated" - properties: { - sessionID: string - info: Session - } -} - -export type EventSessionDeleted = { - id: string - type: "session.deleted" - properties: { - sessionID: string - info: Session + model: ModelV2Info } } @@ -2926,6 +2969,38 @@ export type EventSessionNextTextEnded = { } } +export type EventSessionNextReasoningStarted = { + id: string + type: "session.next.reasoning.started" + properties: { + timestamp: number + sessionID: string + reasoningID: string + } +} + +export type EventSessionNextReasoningDelta = { + id: string + type: "session.next.reasoning.delta" + properties: { + timestamp: number + sessionID: string + reasoningID: string + delta: string + } +} + +export type EventSessionNextReasoningEnded = { + id: string + type: "session.next.reasoning.ended" + properties: { + timestamp: number + sessionID: string + reasoningID: string + text: string + } +} + export type EventSessionNextToolInputStarted = { id: string type: "session.next.tool.input.started" @@ -2991,6 +3066,20 @@ export type ToolFileContent = { name?: string } +export type EventSessionNextToolProgress = { + id: string + type: "session.next.tool.progress" + properties: { + timestamp: number + sessionID: string + callID: string + structured: { + [key: string]: unknown + } + content: Array + } +} + export type EventSessionNextToolSuccess = { id: string type: "session.next.tool.success" @@ -3028,38 +3117,6 @@ export type EventSessionNextToolFailed = { } } -export type EventSessionNextReasoningStarted = { - id: string - type: "session.next.reasoning.started" - properties: { - timestamp: number - sessionID: string - reasoningID: string - } -} - -export type EventSessionNextReasoningDelta = { - id: string - type: "session.next.reasoning.delta" - properties: { - timestamp: number - sessionID: string - reasoningID: string - delta: string - } -} - -export type EventSessionNextReasoningEnded = { - id: string - type: "session.next.reasoning.ended" - properties: { - timestamp: number - sessionID: string - reasoningID: string - text: string - } -} - export type SessionNextRetryError = { message: string statusCode?: number @@ -3115,142 +3172,6 @@ export type EventSessionNextCompactionEnded = { } } -export type EventServerConnected = { - id: string - type: "server.connected" - properties: { - [key: string]: unknown - } -} - -export type EventGlobalDisposed = { - id: string - type: "global.disposed" - properties: { - [key: string]: unknown - } -} - -export type EventSessionNextToolProgress = { - id: string - type: "session.next.tool.progress" - properties: { - timestamp: number - sessionID: string - callID: string - structured: { - [key: string]: unknown - } - content: Array - } -} - -export type ModelV2Info = { - id: string - apiID: string - providerID: string - family?: string - name: string - endpoint: - | { - type: "unknown" - } - | { - type: "openai/responses" - url: string - websocket?: boolean - } - | { - type: "openai/completions" - url: string - reasoning?: - | { - type: "reasoning_content" - } - | { - type: "reasoning_details" - } - } - | { - type: "anthropic/messages" - url: string - } - | { - type: "aisdk" - package: string - url?: string - } - capabilities: { - tools: boolean - input: Array - output: Array - } - options: { - headers: { - [key: string]: string - } - body: { - [key: string]: unknown - } - aisdk: { - provider: { - [key: string]: unknown - } - request: { - [key: string]: unknown - } - } - variant?: string - } - variants: Array<{ - id: string - headers: { - [key: string]: string - } - body: { - [key: string]: unknown - } - aisdk: { - provider: { - [key: string]: unknown - } - request: { - [key: string]: unknown - } - } - }> - time: { - released: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" - } - cost: Array<{ - tier?: { - type: "context" - size: number - } - input: number - output: number - cache: { - read: number - write: number - } - }> - status: "alpha" | "beta" | "deprecated" | "active" - enabled: boolean - limit: { - context: number - input?: number - output: number - } -} - -export type EventCatalogModelUpdated = { - id: string - type: "catalog.model.updated" - properties: { - model: ModelV2Info - } -} - export type SessionInfo = { id: string parentID?: string diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 9db32543aad..913701b8cc2 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -7612,7 +7612,7 @@ "operationId": "v2.model.list", "parameters": [ { - "name": "instance", + "name": "location", "in": "query", "schema": { "type": "object", @@ -7662,7 +7662,7 @@ "operationId": "v2.provider.list", "parameters": [ { - "name": "instance", + "name": "location", "in": "query", "schema": { "type": "object", @@ -7720,7 +7720,7 @@ "required": true }, { - "name": "instance", + "name": "location", "in": "query", "schema": { "type": "object", @@ -9091,9 +9091,6 @@ { "$ref": "#/components/schemas/EventSessionIdle" }, - { - "$ref": "#/components/schemas/EventSessionCompacted" - }, { "$ref": "#/components/schemas/Event.tui.prompt.append" }, @@ -9118,6 +9115,9 @@ { "$ref": "#/components/schemas/EventProjectUpdated" }, + { + "$ref": "#/components/schemas/EventSessionCompacted" + }, { "$ref": "#/components/schemas/EventVcsBranchUpdated" }, @@ -9159,6 +9159,87 @@ }, { "$ref": "#/components/schemas/EventGlobalDisposed" + }, + { + "$ref": "#/components/schemas/EventCatalogModelUpdated" + }, + { + "$ref": "#/components/schemas/EventSessionNextAgentSwitched" + }, + { + "$ref": "#/components/schemas/EventSessionNextModelSwitched" + }, + { + "$ref": "#/components/schemas/EventSessionNextPrompted" + }, + { + "$ref": "#/components/schemas/EventSessionNextSynthetic" + }, + { + "$ref": "#/components/schemas/EventSessionNextShellStarted" + }, + { + "$ref": "#/components/schemas/EventSessionNextShellEnded" + }, + { + "$ref": "#/components/schemas/EventSessionNextStepStarted" + }, + { + "$ref": "#/components/schemas/EventSessionNextStepEnded" + }, + { + "$ref": "#/components/schemas/EventSessionNextStepFailed" + }, + { + "$ref": "#/components/schemas/EventSessionNextTextStarted" + }, + { + "$ref": "#/components/schemas/EventSessionNextTextDelta" + }, + { + "$ref": "#/components/schemas/EventSessionNextTextEnded" + }, + { + "$ref": "#/components/schemas/EventSessionNextReasoningStarted" + }, + { + "$ref": "#/components/schemas/EventSessionNextReasoningDelta" + }, + { + "$ref": "#/components/schemas/EventSessionNextReasoningEnded" + }, + { + "$ref": "#/components/schemas/EventSessionNextToolInputStarted" + }, + { + "$ref": "#/components/schemas/EventSessionNextToolInputDelta" + }, + { + "$ref": "#/components/schemas/EventSessionNextToolInputEnded" + }, + { + "$ref": "#/components/schemas/EventSessionNextToolCalled" + }, + { + "$ref": "#/components/schemas/EventSessionNextToolProgress" + }, + { + "$ref": "#/components/schemas/EventSessionNextToolSuccess" + }, + { + "$ref": "#/components/schemas/EventSessionNextToolFailed" + }, + { + "$ref": "#/components/schemas/EventSessionNextRetried" + }, + { + "$ref": "#/components/schemas/EventSessionNextCompactionStarted" + }, + { + "$ref": "#/components/schemas/EventSessionNextCompactionDelta" + }, + { + "$ref": "#/components/schemas/EventSessionNextCompactionEnded" } ] }, @@ -9936,6 +10017,34 @@ "required": ["id", "title", "command", "args", "cwd", "status", "pid"], "additionalProperties": false }, + "Prompt": { + "type": "object", + "properties": { + "text": { + "type": "string" + }, + "files": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PromptFileAttachment" + } + }, + "agents": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PromptAgentAttachment" + } + }, + "references": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PromptReferenceAttachment" + } + } + }, + "required": ["text"], + "additionalProperties": false + }, "OutputFormatText": { "type": "object", "properties": { @@ -11201,34 +11310,6 @@ "required": ["id", "slug", "projectID", "directory", "title", "version", "time"], "additionalProperties": false }, - "Prompt": { - "type": "object", - "properties": { - "text": { - "type": "string" - }, - "files": { - "type": "array", - "items": { - "$ref": "#/components/schemas/PromptFileAttachment" - } - }, - "agents": { - "type": "array", - "items": { - "$ref": "#/components/schemas/PromptAgentAttachment" - } - }, - "references": { - "type": "array", - "items": { - "$ref": "#/components/schemas/PromptReferenceAttachment" - } - } - }, - "required": ["text"], - "additionalProperties": false - }, "GlobalEvent": { "type": "object", "properties": { @@ -11291,9 +11372,6 @@ { "$ref": "#/components/schemas/EventSessionIdle" }, - { - "$ref": "#/components/schemas/EventSessionCompacted" - }, { "$ref": "#/components/schemas/Event.tui.prompt.append" }, @@ -11318,6 +11396,9 @@ { "$ref": "#/components/schemas/EventProjectUpdated" }, + { + "$ref": "#/components/schemas/EventSessionCompacted" + }, { "$ref": "#/components/schemas/EventVcsBranchUpdated" }, @@ -11360,6 +11441,87 @@ { "$ref": "#/components/schemas/EventGlobalDisposed" }, + { + "$ref": "#/components/schemas/EventCatalogModelUpdated" + }, + { + "$ref": "#/components/schemas/EventSessionNextAgentSwitched" + }, + { + "$ref": "#/components/schemas/EventSessionNextModelSwitched" + }, + { + "$ref": "#/components/schemas/EventSessionNextPrompted" + }, + { + "$ref": "#/components/schemas/EventSessionNextSynthetic" + }, + { + "$ref": "#/components/schemas/EventSessionNextShellStarted" + }, + { + "$ref": "#/components/schemas/EventSessionNextShellEnded" + }, + { + "$ref": "#/components/schemas/EventSessionNextStepStarted" + }, + { + "$ref": "#/components/schemas/EventSessionNextStepEnded" + }, + { + "$ref": "#/components/schemas/EventSessionNextStepFailed" + }, + { + "$ref": "#/components/schemas/EventSessionNextTextStarted" + }, + { + "$ref": "#/components/schemas/EventSessionNextTextDelta" + }, + { + "$ref": "#/components/schemas/EventSessionNextTextEnded" + }, + { + "$ref": "#/components/schemas/EventSessionNextReasoningStarted" + }, + { + "$ref": "#/components/schemas/EventSessionNextReasoningDelta" + }, + { + "$ref": "#/components/schemas/EventSessionNextReasoningEnded" + }, + { + "$ref": "#/components/schemas/EventSessionNextToolInputStarted" + }, + { + "$ref": "#/components/schemas/EventSessionNextToolInputDelta" + }, + { + "$ref": "#/components/schemas/EventSessionNextToolInputEnded" + }, + { + "$ref": "#/components/schemas/EventSessionNextToolCalled" + }, + { + "$ref": "#/components/schemas/EventSessionNextToolProgress" + }, + { + "$ref": "#/components/schemas/EventSessionNextToolSuccess" + }, + { + "$ref": "#/components/schemas/EventSessionNextToolFailed" + }, + { + "$ref": "#/components/schemas/EventSessionNextRetried" + }, + { + "$ref": "#/components/schemas/EventSessionNextCompactionStarted" + }, + { + "$ref": "#/components/schemas/EventSessionNextCompactionDelta" + }, + { + "$ref": "#/components/schemas/EventSessionNextCompactionEnded" + }, { "$ref": "#/components/schemas/SyncEventMessageUpdated" }, @@ -11417,15 +11579,6 @@ { "$ref": "#/components/schemas/SyncEventSessionNextTextEnded" }, - { - "$ref": "#/components/schemas/SyncEventSessionNextReasoningStarted" - }, - { - "$ref": "#/components/schemas/SyncEventSessionNextReasoningDelta" - }, - { - "$ref": "#/components/schemas/SyncEventSessionNextReasoningEnded" - }, { "$ref": "#/components/schemas/SyncEventSessionNextToolInputStarted" }, @@ -11438,15 +11591,21 @@ { "$ref": "#/components/schemas/SyncEventSessionNextToolCalled" }, - { - "$ref": "#/components/schemas/SyncEventSessionNextToolProgress" - }, { "$ref": "#/components/schemas/SyncEventSessionNextToolSuccess" }, { "$ref": "#/components/schemas/SyncEventSessionNextToolFailed" }, + { + "$ref": "#/components/schemas/SyncEventSessionNextReasoningStarted" + }, + { + "$ref": "#/components/schemas/SyncEventSessionNextReasoningDelta" + }, + { + "$ref": "#/components/schemas/SyncEventSessionNextReasoningEnded" + }, { "$ref": "#/components/schemas/SyncEventSessionNextRetried" }, @@ -11458,6 +11617,9 @@ }, { "$ref": "#/components/schemas/SyncEventSessionNextCompactionEnded" + }, + { + "$ref": "#/components/schemas/SyncEventSessionNextToolProgress" } ] } @@ -15350,138 +15512,6 @@ "required": ["type", "name", "id", "seq", "aggregateID", "data"], "additionalProperties": false }, - "SyncEventSessionNextReasoningStarted": { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": ["sync"] - }, - "name": { - "type": "string", - "enum": ["session.next.reasoning.started.1"] - }, - "id": { - "type": "string" - }, - "seq": { - "type": "number" - }, - "aggregateID": { - "type": "string", - "enum": ["sessionID"] - }, - "data": { - "type": "object", - "properties": { - "timestamp": { - "type": "number" - }, - "sessionID": { - "type": "string", - "pattern": "^ses" - }, - "reasoningID": { - "type": "string" - } - }, - "required": ["timestamp", "sessionID", "reasoningID"], - "additionalProperties": false - } - }, - "required": ["type", "name", "id", "seq", "aggregateID", "data"], - "additionalProperties": false - }, - "SyncEventSessionNextReasoningDelta": { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": ["sync"] - }, - "name": { - "type": "string", - "enum": ["session.next.reasoning.delta.1"] - }, - "id": { - "type": "string" - }, - "seq": { - "type": "number" - }, - "aggregateID": { - "type": "string", - "enum": ["sessionID"] - }, - "data": { - "type": "object", - "properties": { - "timestamp": { - "type": "number" - }, - "sessionID": { - "type": "string", - "pattern": "^ses" - }, - "reasoningID": { - "type": "string" - }, - "delta": { - "type": "string" - } - }, - "required": ["timestamp", "sessionID", "reasoningID", "delta"], - "additionalProperties": false - } - }, - "required": ["type", "name", "id", "seq", "aggregateID", "data"], - "additionalProperties": false - }, - "SyncEventSessionNextReasoningEnded": { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": ["sync"] - }, - "name": { - "type": "string", - "enum": ["session.next.reasoning.ended.1"] - }, - "id": { - "type": "string" - }, - "seq": { - "type": "number" - }, - "aggregateID": { - "type": "string", - "enum": ["sessionID"] - }, - "data": { - "type": "object", - "properties": { - "timestamp": { - "type": "number" - }, - "sessionID": { - "type": "string", - "pattern": "^ses" - }, - "reasoningID": { - "type": "string" - }, - "text": { - "type": "string" - } - }, - "required": ["timestamp", "sessionID", "reasoningID", "text"], - "additionalProperties": false - } - }, - "required": ["type", "name", "id", "seq", "aggregateID", "data"], - "additionalProperties": false - }, "SyncEventSessionNextToolInputStarted": { "type": "object", "properties": { @@ -15678,64 +15708,6 @@ "required": ["type", "name", "id", "seq", "aggregateID", "data"], "additionalProperties": false }, - "SyncEventSessionNextToolProgress": { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": ["sync"] - }, - "name": { - "type": "string", - "enum": ["session.next.tool.progress.1"] - }, - "id": { - "type": "string" - }, - "seq": { - "type": "number" - }, - "aggregateID": { - "type": "string", - "enum": ["sessionID"] - }, - "data": { - "type": "object", - "properties": { - "timestamp": { - "type": "number" - }, - "sessionID": { - "type": "string", - "pattern": "^ses" - }, - "callID": { - "type": "string" - }, - "structured": { - "type": "object" - }, - "content": { - "type": "array", - "items": { - "anyOf": [ - { - "$ref": "#/components/schemas/ToolTextContent" - }, - { - "$ref": "#/components/schemas/ToolFileContent" - } - ] - } - } - }, - "required": ["timestamp", "sessionID", "callID", "structured", "content"], - "additionalProperties": false - } - }, - "required": ["type", "name", "id", "seq", "aggregateID", "data"], - "additionalProperties": false - }, "SyncEventSessionNextToolSuccess": { "type": "object", "properties": { @@ -15865,6 +15837,138 @@ "required": ["type", "name", "id", "seq", "aggregateID", "data"], "additionalProperties": false }, + "SyncEventSessionNextReasoningStarted": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["sync"] + }, + "name": { + "type": "string", + "enum": ["session.next.reasoning.started.1"] + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "enum": ["sessionID"] + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "reasoningID": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "reasoningID"], + "additionalProperties": false + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"], + "additionalProperties": false + }, + "SyncEventSessionNextReasoningDelta": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["sync"] + }, + "name": { + "type": "string", + "enum": ["session.next.reasoning.delta.1"] + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "enum": ["sessionID"] + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "reasoningID": { + "type": "string" + }, + "delta": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "reasoningID", "delta"], + "additionalProperties": false + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"], + "additionalProperties": false + }, + "SyncEventSessionNextReasoningEnded": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["sync"] + }, + "name": { + "type": "string", + "enum": ["session.next.reasoning.ended.1"] + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "enum": ["sessionID"] + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "reasoningID": { + "type": "string" + }, + "text": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "reasoningID", "text"], + "additionalProperties": false + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"], + "additionalProperties": false + }, "SyncEventSessionNextRetried": { "type": "object", "properties": { @@ -16040,6 +16144,63 @@ "required": ["type", "name", "id", "seq", "aggregateID", "data"], "additionalProperties": false }, + "SyncEventSessionNextToolProgress": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["sync"] + }, + "name": { + "type": "string", + "enum": ["session.next.tool.progress.1"] + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "callID": { + "type": "string" + }, + "structured": { + "type": "object" + }, + "content": { + "type": "array", + "items": { + "anyOf": [ + { + "$ref": "#/components/schemas/ToolTextContent" + }, + { + "$ref": "#/components/schemas/ToolFileContent" + } + ] + } + } + }, + "required": ["timestamp", "sessionID", "callID", "structured", "content"], + "additionalProperties": false + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"], + "additionalProperties": false + }, "EventServerInstanceDisposed": { "type": "object", "properties": { @@ -16465,31 +16626,6 @@ "required": ["id", "type", "properties"], "additionalProperties": false }, - "EventSessionCompacted": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "enum": ["session.compacted"] - }, - "properties": { - "type": "object", - "properties": { - "sessionID": { - "type": "string", - "pattern": "^ses" - } - }, - "required": ["sessionID"], - "additionalProperties": false - } - }, - "required": ["id", "type", "properties"], - "additionalProperties": false - }, "EventMcpToolsChanged": { "type": "object", "properties": { @@ -16593,6 +16729,31 @@ "required": ["id", "type", "properties"], "additionalProperties": false }, + "EventSessionCompacted": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["session.compacted"] + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses" + } + }, + "required": ["sessionID"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, "EventVcsBranchUpdated": { "type": "object", "properties": { @@ -16930,6 +17091,429 @@ "required": ["id", "type", "properties"], "additionalProperties": false }, + "ModelV2Info": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "apiID": { + "type": "string" + }, + "providerID": { + "type": "string" + }, + "family": { + "type": "string" + }, + "name": { + "type": "string" + }, + "endpoint": { + "anyOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["unknown"] + } + }, + "required": ["type"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["openai/responses"] + }, + "url": { + "type": "string" + }, + "websocket": { + "type": "boolean" + } + }, + "required": ["type", "url"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["openai/completions"] + }, + "url": { + "type": "string" + }, + "reasoning": { + "anyOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["reasoning_content"] + } + }, + "required": ["type"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["reasoning_details"] + } + }, + "required": ["type"], + "additionalProperties": false + } + ] + } + }, + "required": ["type", "url"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["anthropic/messages"] + }, + "url": { + "type": "string" + } + }, + "required": ["type", "url"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["aisdk"] + }, + "package": { + "type": "string" + }, + "url": { + "type": "string" + } + }, + "required": ["type", "package"], + "additionalProperties": false + } + ] + }, + "capabilities": { + "type": "object", + "properties": { + "tools": { + "type": "boolean" + }, + "input": { + "type": "array", + "items": { + "type": "string" + } + }, + "output": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": ["tools", "input", "output"], + "additionalProperties": false + }, + "options": { + "type": "object", + "properties": { + "headers": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "body": { + "type": "object" + }, + "aisdk": { + "type": "object", + "properties": { + "provider": { + "type": "object" + }, + "request": { + "type": "object" + } + }, + "required": ["provider", "request"], + "additionalProperties": false + }, + "variant": { + "type": "string" + } + }, + "required": ["headers", "body", "aisdk"], + "additionalProperties": false + }, + "variants": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "headers": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "body": { + "type": "object" + }, + "aisdk": { + "type": "object", + "properties": { + "provider": { + "type": "object" + }, + "request": { + "type": "object" + } + }, + "required": ["provider", "request"], + "additionalProperties": false + } + }, + "required": ["id", "headers", "body", "aisdk"], + "additionalProperties": false + } + }, + "time": { + "type": "object", + "properties": { + "released": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": ["NaN"] + }, + { + "type": "string", + "enum": ["Infinity"] + }, + { + "type": "string", + "enum": ["-Infinity"] + }, + { + "type": "string", + "enum": ["Infinity", "-Infinity", "NaN"] + } + ] + } + }, + "required": ["released"], + "additionalProperties": false + }, + "cost": { + "type": "array", + "items": { + "type": "object", + "properties": { + "tier": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["context"] + }, + "size": { + "type": "integer" + } + }, + "required": ["type", "size"], + "additionalProperties": false + }, + "input": { + "type": "number" + }, + "output": { + "type": "number" + }, + "cache": { + "type": "object", + "properties": { + "read": { + "type": "number" + }, + "write": { + "type": "number" + } + }, + "required": ["read", "write"], + "additionalProperties": false + } + }, + "required": ["input", "output", "cache"], + "additionalProperties": false + } + }, + "status": { + "type": "string", + "enum": ["alpha", "beta", "deprecated", "active"] + }, + "enabled": { + "type": "boolean" + }, + "limit": { + "type": "object", + "properties": { + "context": { + "type": "integer" + }, + "input": { + "type": "integer" + }, + "output": { + "type": "integer" + } + }, + "required": ["context", "output"], + "additionalProperties": false + } + }, + "required": [ + "id", + "apiID", + "providerID", + "name", + "endpoint", + "capabilities", + "options", + "variants", + "time", + "cost", + "status", + "enabled", + "limit" + ], + "additionalProperties": false + }, + "EventCatalogModelUpdated": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["catalog.model.updated"] + }, + "properties": { + "type": "object", + "properties": { + "model": { + "$ref": "#/components/schemas/ModelV2Info" + } + }, + "required": ["model"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventSessionNextAgentSwitched": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["session.next.agent.switched"] + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "agent": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "agent"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventSessionNextModelSwitched": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["session.next.model.switched"] + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "model": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "providerID": { + "type": "string" + }, + "variant": { + "type": "string" + } + }, + "required": ["id", "providerID", "variant"], + "additionalProperties": false + } + }, + "required": ["timestamp", "sessionID", "model"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, "PromptSource": { "type": "object", "properties": { @@ -17016,6 +17600,252 @@ "required": ["name", "kind"], "additionalProperties": false }, + "EventSessionNextPrompted": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["session.next.prompted"] + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "prompt": { + "$ref": "#/components/schemas/Prompt" + } + }, + "required": ["timestamp", "sessionID", "prompt"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventSessionNextSynthetic": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["session.next.synthetic"] + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "text": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "text"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventSessionNextShellStarted": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["session.next.shell.started"] + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "callID": { + "type": "string" + }, + "command": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "callID", "command"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventSessionNextShellEnded": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["session.next.shell.ended"] + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "callID": { + "type": "string" + }, + "output": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "callID", "output"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventSessionNextStepStarted": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["session.next.step.started"] + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "agent": { + "type": "string" + }, + "model": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "providerID": { + "type": "string" + }, + "variant": { + "type": "string" + } + }, + "required": ["id", "providerID", "variant"], + "additionalProperties": false + }, + "snapshot": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "agent", "model"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventSessionNextStepEnded": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["session.next.step.ended"] + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "finish": { + "type": "string" + }, + "cost": { + "type": "number" + }, + "tokens": { + "type": "object", + "properties": { + "input": { + "type": "number" + }, + "output": { + "type": "number" + }, + "reasoning": { + "type": "number" + }, + "cache": { + "type": "object", + "properties": { + "read": { + "type": "number" + }, + "write": { + "type": "number" + } + }, + "required": ["read", "write"], + "additionalProperties": false + } + }, + "required": ["input", "output", "reasoning", "cache"], + "additionalProperties": false + }, + "snapshot": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "finish", "cost", "tokens"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, "SessionErrorUnknown": { "type": "object", "properties": { @@ -17030,6 +17860,378 @@ "required": ["type", "message"], "additionalProperties": false }, + "EventSessionNextStepFailed": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["session.next.step.failed"] + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "error": { + "$ref": "#/components/schemas/SessionErrorUnknown" + } + }, + "required": ["timestamp", "sessionID", "error"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventSessionNextTextStarted": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["session.next.text.started"] + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + } + }, + "required": ["timestamp", "sessionID"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventSessionNextTextDelta": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["session.next.text.delta"] + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "delta": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "delta"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventSessionNextTextEnded": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["session.next.text.ended"] + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "text": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "text"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventSessionNextReasoningStarted": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["session.next.reasoning.started"] + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "reasoningID": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "reasoningID"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventSessionNextReasoningDelta": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["session.next.reasoning.delta"] + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "reasoningID": { + "type": "string" + }, + "delta": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "reasoningID", "delta"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventSessionNextReasoningEnded": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["session.next.reasoning.ended"] + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "reasoningID": { + "type": "string" + }, + "text": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "reasoningID", "text"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventSessionNextToolInputStarted": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["session.next.tool.input.started"] + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "callID": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "callID", "name"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventSessionNextToolInputDelta": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["session.next.tool.input.delta"] + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "callID": { + "type": "string" + }, + "delta": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "callID", "delta"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventSessionNextToolInputEnded": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["session.next.tool.input.ended"] + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "callID": { + "type": "string" + }, + "text": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "callID", "text"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventSessionNextToolCalled": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["session.next.tool.called"] + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "callID": { + "type": "string" + }, + "tool": { + "type": "string" + }, + "input": { + "type": "object" + }, + "provider": { + "type": "object", + "properties": { + "executed": { + "type": "boolean" + }, + "metadata": { + "type": "object" + } + }, + "required": ["executed"], + "additionalProperties": false + } + }, + "required": ["timestamp", "sessionID", "callID", "tool", "input", "provider"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, "ToolTextContent": { "type": "object", "properties": { @@ -17064,6 +18266,160 @@ "required": ["type", "uri", "mime"], "additionalProperties": false }, + "EventSessionNextToolProgress": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["session.next.tool.progress"] + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "callID": { + "type": "string" + }, + "structured": { + "type": "object" + }, + "content": { + "type": "array", + "items": { + "anyOf": [ + { + "$ref": "#/components/schemas/ToolTextContent" + }, + { + "$ref": "#/components/schemas/ToolFileContent" + } + ] + } + } + }, + "required": ["timestamp", "sessionID", "callID", "structured", "content"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventSessionNextToolSuccess": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["session.next.tool.success"] + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "callID": { + "type": "string" + }, + "structured": { + "type": "object" + }, + "content": { + "type": "array", + "items": { + "anyOf": [ + { + "$ref": "#/components/schemas/ToolTextContent" + }, + { + "$ref": "#/components/schemas/ToolFileContent" + } + ] + } + }, + "provider": { + "type": "object", + "properties": { + "executed": { + "type": "boolean" + }, + "metadata": { + "type": "object" + } + }, + "required": ["executed"], + "additionalProperties": false + } + }, + "required": ["timestamp", "sessionID", "callID", "structured", "content", "provider"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventSessionNextToolFailed": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["session.next.tool.failed"] + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "callID": { + "type": "string" + }, + "error": { + "$ref": "#/components/schemas/SessionErrorUnknown" + }, + "provider": { + "type": "object", + "properties": { + "executed": { + "type": "boolean" + }, + "metadata": { + "type": "object" + } + }, + "required": ["executed"], + "additionalProperties": false + } + }, + "required": ["timestamp", "sessionID", "callID", "error", "provider"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, "SessionNextRetry_error": { "type": "object", "properties": { @@ -17095,6 +18451,137 @@ "required": ["message", "isRetryable"], "additionalProperties": false }, + "EventSessionNextRetried": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["session.next.retried"] + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "attempt": { + "type": "number" + }, + "error": { + "$ref": "#/components/schemas/SessionNextRetry_error" + } + }, + "required": ["timestamp", "sessionID", "attempt", "error"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventSessionNextCompactionStarted": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["session.next.compaction.started"] + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "reason": { + "type": "string", + "enum": ["auto", "manual"] + } + }, + "required": ["timestamp", "sessionID", "reason"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventSessionNextCompactionDelta": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["session.next.compaction.delta"] + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "text": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "text"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventSessionNextCompactionEnded": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["session.next.compaction.ended"] + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "text": { + "type": "string" + }, + "include": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "text"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, "SessionInfo": { "type": "object", "properties": { @@ -17774,330 +19261,6 @@ } ] }, - "ModelV2Info": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "apiID": { - "type": "string" - }, - "providerID": { - "type": "string" - }, - "family": { - "type": "string" - }, - "name": { - "type": "string" - }, - "endpoint": { - "anyOf": [ - { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": ["unknown"] - } - }, - "required": ["type"], - "additionalProperties": false - }, - { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": ["openai/responses"] - }, - "url": { - "type": "string" - }, - "websocket": { - "type": "boolean" - } - }, - "required": ["type", "url"], - "additionalProperties": false - }, - { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": ["openai/completions"] - }, - "url": { - "type": "string" - }, - "reasoning": { - "anyOf": [ - { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": ["reasoning_content"] - } - }, - "required": ["type"], - "additionalProperties": false - }, - { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": ["reasoning_details"] - } - }, - "required": ["type"], - "additionalProperties": false - } - ] - } - }, - "required": ["type", "url"], - "additionalProperties": false - }, - { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": ["anthropic/messages"] - }, - "url": { - "type": "string" - } - }, - "required": ["type", "url"], - "additionalProperties": false - }, - { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": ["aisdk"] - }, - "package": { - "type": "string" - }, - "url": { - "type": "string" - } - }, - "required": ["type", "package"], - "additionalProperties": false - } - ] - }, - "capabilities": { - "type": "object", - "properties": { - "tools": { - "type": "boolean" - }, - "input": { - "type": "array", - "items": { - "type": "string" - } - }, - "output": { - "type": "array", - "items": { - "type": "string" - } - } - }, - "required": ["tools", "input", "output"], - "additionalProperties": false - }, - "options": { - "type": "object", - "properties": { - "headers": { - "type": "object", - "additionalProperties": { - "type": "string" - } - }, - "body": { - "type": "object" - }, - "aisdk": { - "type": "object", - "properties": { - "provider": { - "type": "object" - }, - "request": { - "type": "object" - } - }, - "required": ["provider", "request"], - "additionalProperties": false - }, - "variant": { - "type": "string" - } - }, - "required": ["headers", "body", "aisdk"], - "additionalProperties": false - }, - "variants": { - "type": "array", - "items": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "headers": { - "type": "object", - "additionalProperties": { - "type": "string" - } - }, - "body": { - "type": "object" - }, - "aisdk": { - "type": "object", - "properties": { - "provider": { - "type": "object" - }, - "request": { - "type": "object" - } - }, - "required": ["provider", "request"], - "additionalProperties": false - } - }, - "required": ["id", "headers", "body", "aisdk"], - "additionalProperties": false - } - }, - "time": { - "type": "object", - "properties": { - "released": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "enum": ["NaN"] - }, - { - "type": "string", - "enum": ["Infinity"] - }, - { - "type": "string", - "enum": ["-Infinity"] - }, - { - "type": "string", - "enum": ["Infinity", "-Infinity", "NaN"] - } - ] - } - }, - "required": ["released"], - "additionalProperties": false - }, - "cost": { - "type": "array", - "items": { - "type": "object", - "properties": { - "tier": { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": ["context"] - }, - "size": { - "type": "integer" - } - }, - "required": ["type", "size"], - "additionalProperties": false - }, - "input": { - "type": "number" - }, - "output": { - "type": "number" - }, - "cache": { - "type": "object", - "properties": { - "read": { - "type": "number" - }, - "write": { - "type": "number" - } - }, - "required": ["read", "write"], - "additionalProperties": false - } - }, - "required": ["input", "output", "cache"], - "additionalProperties": false - } - }, - "status": { - "type": "string", - "enum": ["alpha", "beta", "deprecated", "active"] - }, - "enabled": { - "type": "boolean" - }, - "limit": { - "type": "object", - "properties": { - "context": { - "type": "integer" - }, - "input": { - "type": "integer" - }, - "output": { - "type": "integer" - } - }, - "required": ["context", "output"], - "additionalProperties": false - } - }, - "required": [ - "id", - "apiID", - "providerID", - "name", - "endpoint", - "capabilities", - "options", - "variants", - "time", - "cost", - "status", - "enabled", - "limit" - ], - "additionalProperties": false - }, "ProviderV2Info": { "type": "object", "properties": { @@ -18334,6 +19497,326 @@ "required": ["id", "type", "properties"], "additionalProperties": false }, + "ModelV2Info1": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "apiID": { + "type": "string" + }, + "providerID": { + "type": "string" + }, + "family": { + "type": "string" + }, + "name": { + "type": "string" + }, + "endpoint": { + "anyOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["unknown"] + } + }, + "required": ["type"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["openai/responses"] + }, + "url": { + "type": "string" + }, + "websocket": { + "type": "boolean" + } + }, + "required": ["type", "url"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["openai/completions"] + }, + "url": { + "type": "string" + }, + "reasoning": { + "anyOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["reasoning_content"] + } + }, + "required": ["type"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["reasoning_details"] + } + }, + "required": ["type"], + "additionalProperties": false + } + ] + } + }, + "required": ["type", "url"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["anthropic/messages"] + }, + "url": { + "type": "string" + } + }, + "required": ["type", "url"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["aisdk"] + }, + "package": { + "type": "string" + }, + "url": { + "type": "string" + } + }, + "required": ["type", "package"], + "additionalProperties": false + } + ] + }, + "capabilities": { + "type": "object", + "properties": { + "tools": { + "type": "boolean" + }, + "input": { + "type": "array", + "items": { + "type": "string" + } + }, + "output": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": ["tools", "input", "output"], + "additionalProperties": false + }, + "options": { + "type": "object", + "properties": { + "headers": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "body": { + "type": "object" + }, + "aisdk": { + "type": "object", + "properties": { + "provider": { + "type": "object" + }, + "request": { + "type": "object" + } + }, + "required": ["provider", "request"], + "additionalProperties": false + }, + "variant": { + "type": "string" + } + }, + "required": ["headers", "body", "aisdk"], + "additionalProperties": false + }, + "variants": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "headers": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "body": { + "type": "object" + }, + "aisdk": { + "type": "object", + "properties": { + "provider": { + "type": "object" + }, + "request": { + "type": "object" + } + }, + "required": ["provider", "request"], + "additionalProperties": false + } + }, + "required": ["id", "headers", "body", "aisdk"], + "additionalProperties": false + } + }, + "time": { + "type": "object", + "properties": { + "released": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": ["NaN"] + }, + { + "type": "string", + "enum": ["Infinity"] + }, + { + "type": "string", + "enum": ["-Infinity"] + } + ] + } + }, + "required": ["released"], + "additionalProperties": false + }, + "cost": { + "type": "array", + "items": { + "type": "object", + "properties": { + "tier": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["context"] + }, + "size": { + "type": "integer" + } + }, + "required": ["type", "size"], + "additionalProperties": false + }, + "input": { + "type": "number" + }, + "output": { + "type": "number" + }, + "cache": { + "type": "object", + "properties": { + "read": { + "type": "number" + }, + "write": { + "type": "number" + } + }, + "required": ["read", "write"], + "additionalProperties": false + } + }, + "required": ["input", "output", "cache"], + "additionalProperties": false + } + }, + "status": { + "type": "string", + "enum": ["alpha", "beta", "deprecated", "active"] + }, + "enabled": { + "type": "boolean" + }, + "limit": { + "type": "object", + "properties": { + "context": { + "type": "integer" + }, + "input": { + "type": "integer" + }, + "output": { + "type": "integer" + } + }, + "required": ["context", "output"], + "additionalProperties": false + } + }, + "required": [ + "id", + "apiID", + "providerID", + "name", + "endpoint", + "capabilities", + "options", + "variants", + "time", + "cost", + "status", + "enabled", + "limit" + ], + "additionalProperties": false + }, "BadRequestError": { "type": "object", "required": ["name", "data"], From f179dcbf02fc48c7521c0e4da44ca752da2c7b30 Mon Sep 17 00:00:00 2001 From: Brendan Allan <14191578+Brendonovich@users.noreply.github.com> Date: Fri, 15 May 2026 09:42:47 +0800 Subject: [PATCH 005/164] fix(app): only run session.updated archive logic if archive state changes (#27637) --- packages/app/src/context/global-sync/event-reducer.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/app/src/context/global-sync/event-reducer.ts b/packages/app/src/context/global-sync/event-reducer.ts index 13d34ef6c5f..5b72d37f9df 100644 --- a/packages/app/src/context/global-sync/event-reducer.ts +++ b/packages/app/src/context/global-sync/event-reducer.ts @@ -125,6 +125,7 @@ export function applyDirectoryEvent(input: { const info = (event.properties as { info: Session }).info const result = Binary.search(input.store.session, info.id, (s) => s.id) if (info.time.archived) { + if (input.store.session[result.index]!.time.archived === info.time.archived) break if (result.found) { input.setStore( "session", From f39cf911d7802b4fdd77201473d6ee85a3d290e2 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Thu, 14 May 2026 21:53:36 -0400 Subject: [PATCH 006/164] fix missing event types in sdk --- packages/opencode/src/server/projectors.ts | 1 + .../src/server/routes/instance/httpapi/api.ts | 3 + .../routes/instance/httpapi/groups/global.ts | 1 - packages/opencode/src/server/server.ts | 3 - packages/opencode/src/sync/index.ts | 8 +- packages/sdk/js/src/v2/gen/types.gen.ts | 354 +++++++++++------- 6 files changed, 219 insertions(+), 151 deletions(-) diff --git a/packages/opencode/src/server/projectors.ts b/packages/opencode/src/server/projectors.ts index c5fb2420a0c..1d250d4decb 100644 --- a/packages/opencode/src/server/projectors.ts +++ b/packages/opencode/src/server/projectors.ts @@ -6,6 +6,7 @@ import { Database } from "@/storage/db" import { eq } from "drizzle-orm" export function initProjectors() { + console.log("initProjectors") SyncEvent.init({ projectors: sessionProjectors, convertEvent: (type, data) => { diff --git a/packages/opencode/src/server/routes/instance/httpapi/api.ts b/packages/opencode/src/server/routes/instance/httpapi/api.ts index eff336b3c63..c1779ab9211 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/api.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/api.ts @@ -1,3 +1,6 @@ +import { initProjectors } from "@/server/projectors" +initProjectors() + import { Schema } from "effect" import { HttpApi } from "effect/unstable/httpapi" import { BusEvent } from "@/bus/bus-event" diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/global.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/global.ts index 5b8a44d2cf1..75441b4ca4a 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/global.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/global.ts @@ -1,7 +1,6 @@ import { Config } from "@/config/config" import { BusEvent } from "@/bus/bus-event" import { SyncEvent } from "@/sync" -import "@/event-v2-bridge" import "@/server/event" import { Schema } from "effect" import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index b7a46918b24..e787006271f 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -5,7 +5,6 @@ import { HttpRouter, HttpServer } from "effect/unstable/http" import { OpenApi } from "effect/unstable/httpapi" import { createServer } from "node:http" import { MDNS } from "./mdns" -import { initProjectors } from "./projectors" import { HttpApiApp } from "./routes/instance/httpapi/server" import { disposeMiddleware } from "./routes/instance/httpapi/lifecycle" import { WebSocketTracker } from "./routes/instance/httpapi/websocket-tracker" @@ -16,8 +15,6 @@ import { lazy } from "@/util/lazy" // @ts-ignore This global is needed to prevent ai-sdk from logging warnings to stdout https://github.com/vercel/ai/blob/2dc67e0ef538307f21368db32d5a12345d98831b/packages/ai/src/logger/log-warnings.ts#L85 globalThis.AI_SDK_LOG_WARNINGS = false -initProjectors() - const log = Log.create({ service: "server" }) export type Listener = { diff --git a/packages/opencode/src/sync/index.ts b/packages/opencode/src/sync/index.ts index d2cc1a1c5f1..bdc319e282d 100644 --- a/packages/opencode/src/sync/index.ts +++ b/packages/opencode/src/sync/index.ts @@ -226,9 +226,6 @@ export function reset() { } export function init(input: { projectors: Array<[Definition, ProjectorFunc]>; convertEvent?: ConvertEvent }) { - for (const [def] of input.projectors) { - register(def) - } projectors = new Map(input.projectors) // Install all the latest event defs to the bus. We only ever emit @@ -236,8 +233,8 @@ export function init(input: { projectors: Array<[Definition, ProjectorFunc]>; co // replaying. Replaying does not go through the bus, and it // simplifies the bus to only use unversioned latest events for (let [type, version] of versions.entries()) { + console.log(type) let def = registry.get(versionedType(type, version))! - BusEvent.define(def.type, def.properties) } @@ -286,7 +283,6 @@ export function project( def: Def, func: (db: Database.TxOrDb, data: Event["data"], event: Event) => void, ): [Definition, ProjectorFunc] { - register(def) return [def, func as ProjectorFunc] } @@ -393,7 +389,7 @@ export function effectPayloads() { name: EffectSchema.Literal(versionedType(definition.type, definition.version!)), id: EffectSchema.String, seq: EffectSchema.Finite, - aggregateID: EffectSchema.String, + aggregateID: EffectSchema.Literal(definition.aggregate!), data: definition.data, }).annotate({ identifier: `SyncEvent.${definition.type}` }), ) diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 40ad4f5a6c5..a0cc527471f 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -5,6 +5,12 @@ export type ClientOptions = { } export type Event = + | EventTuiPromptAppend + | EventTuiCommandExecute + | EventTuiToastShow1 + | EventTuiSessionSelect + | EventServerConnected + | EventGlobalDisposed | EventServerInstanceDisposed | EventFileEdited | EventFileWatcherUpdated @@ -21,10 +27,6 @@ export type Event = | EventTodoUpdated | EventSessionStatus | EventSessionIdle - | EventTuiPromptAppend - | EventTuiCommandExecute - | EventTuiToastShow1 - | EventTuiSessionSelect | EventMcpToolsChanged | EventMcpBrowserOpenFailed | EventCommandExecuted @@ -42,8 +44,13 @@ export type Event = | EventPtyDeleted | EventInstallationUpdated | EventInstallationUpdateAvailable - | EventServerConnected - | EventGlobalDisposed + | EventMessageUpdated + | EventMessageRemoved + | EventMessagePartUpdated + | EventMessagePartRemoved + | EventSessionCreated + | EventSessionUpdated + | EventSessionDeleted | EventCatalogModelUpdated | EventSessionNextAgentSwitched | EventSessionNextModelSwitched @@ -97,6 +104,61 @@ export type WellKnownAuth = { export type Auth = OAuth | ApiAuth | WellKnownAuth +export type EventTuiPromptAppend = { + id: string + type: "tui.prompt.append" + properties: { + text: string + } +} + +export type EventTuiCommandExecute = { + id: string + type: "tui.command.execute" + properties: { + command: + | "session.list" + | "session.new" + | "session.share" + | "session.interrupt" + | "session.compact" + | "session.page.up" + | "session.page.down" + | "session.line.up" + | "session.line.down" + | "session.half.page.up" + | "session.half.page.down" + | "session.first" + | "session.last" + | "prompt.clear" + | "prompt.submit" + | "agent.cycle" + | string + } +} + +export type EventTuiToastShow = { + id: string + type: "tui.toast.show" + properties: { + title?: string + message: string + variant: "info" | "success" | "warning" | "error" + duration?: number + } +} + +export type EventTuiSessionSelect = { + id: string + type: "tui.session.select" + properties: { + /** + * Session ID to navigate to + */ + sessionID: string + } +} + export type PermissionRequest = { id: string sessionID: string @@ -274,61 +336,6 @@ export type SessionStatus = type: "busy" } -export type EventTuiPromptAppend = { - id: string - type: "tui.prompt.append" - properties: { - text: string - } -} - -export type EventTuiCommandExecute = { - id: string - type: "tui.command.execute" - properties: { - command: - | "session.list" - | "session.new" - | "session.share" - | "session.interrupt" - | "session.compact" - | "session.page.up" - | "session.page.down" - | "session.line.up" - | "session.line.down" - | "session.half.page.up" - | "session.half.page.down" - | "session.first" - | "session.last" - | "prompt.clear" - | "prompt.submit" - | "agent.cycle" - | string - } -} - -export type EventTuiToastShow = { - id: string - type: "tui.toast.show" - properties: { - title?: string - message: string - variant: "info" | "success" | "warning" | "error" - duration?: number - } -} - -export type EventTuiSessionSelect = { - id: string - type: "tui.session.select" - properties: { - /** - * Session ID to navigate to - */ - sessionID: string - } -} - export type Project = { id: string worktree: string @@ -783,6 +790,12 @@ export type GlobalEvent = { project?: string workspace?: string payload: + | EventTuiPromptAppend + | EventTuiCommandExecute + | EventTuiToastShow + | EventTuiSessionSelect + | EventServerConnected + | EventGlobalDisposed | EventServerInstanceDisposed | EventFileEdited | EventFileWatcherUpdated @@ -799,10 +812,6 @@ export type GlobalEvent = { | EventTodoUpdated | EventSessionStatus | EventSessionIdle - | EventTuiPromptAppend - | EventTuiCommandExecute - | EventTuiToastShow - | EventTuiSessionSelect | EventMcpToolsChanged | EventMcpBrowserOpenFailed | EventCommandExecuted @@ -820,8 +829,6 @@ export type GlobalEvent = { | EventPtyDeleted | EventInstallationUpdated | EventInstallationUpdateAvailable - | EventServerConnected - | EventGlobalDisposed | EventCatalogModelUpdated | EventSessionNextAgentSwitched | EventSessionNextModelSwitched @@ -868,20 +875,20 @@ export type GlobalEvent = { | SyncEventSessionNextTextStarted | SyncEventSessionNextTextDelta | SyncEventSessionNextTextEnded + | SyncEventSessionNextReasoningStarted + | SyncEventSessionNextReasoningDelta + | SyncEventSessionNextReasoningEnded | SyncEventSessionNextToolInputStarted | SyncEventSessionNextToolInputDelta | SyncEventSessionNextToolInputEnded | SyncEventSessionNextToolCalled + | SyncEventSessionNextToolProgress | SyncEventSessionNextToolSuccess | SyncEventSessionNextToolFailed - | SyncEventSessionNextReasoningStarted - | SyncEventSessionNextReasoningDelta - | SyncEventSessionNextReasoningEnded | SyncEventSessionNextRetried | SyncEventSessionNextCompactionStarted | SyncEventSessionNextCompactionDelta | SyncEventSessionNextCompactionEnded - | SyncEventSessionNextToolProgress } /** @@ -2169,6 +2176,47 @@ export type SyncEventSessionNextTextEnded = { } } +export type SyncEventSessionNextReasoningStarted = { + type: "sync" + name: "session.next.reasoning.started.1" + id: string + seq: number + aggregateID: "sessionID" + data: { + timestamp: number + sessionID: string + reasoningID: string + } +} + +export type SyncEventSessionNextReasoningDelta = { + type: "sync" + name: "session.next.reasoning.delta.1" + id: string + seq: number + aggregateID: "sessionID" + data: { + timestamp: number + sessionID: string + reasoningID: string + delta: string + } +} + +export type SyncEventSessionNextReasoningEnded = { + type: "sync" + name: "session.next.reasoning.ended.1" + id: string + seq: number + aggregateID: "sessionID" + data: { + timestamp: number + sessionID: string + reasoningID: string + text: string + } +} + export type SyncEventSessionNextToolInputStarted = { type: "sync" name: "session.next.tool.input.started.1" @@ -2234,6 +2282,23 @@ export type SyncEventSessionNextToolCalled = { } } +export type SyncEventSessionNextToolProgress = { + type: "sync" + name: "session.next.tool.progress.1" + id: string + seq: number + aggregateID: "sessionID" + data: { + timestamp: number + sessionID: string + callID: string + structured: { + [key: string]: unknown + } + content: Array + } +} + export type SyncEventSessionNextToolSuccess = { type: "sync" name: "session.next.tool.success.1" @@ -2277,47 +2342,6 @@ export type SyncEventSessionNextToolFailed = { } } -export type SyncEventSessionNextReasoningStarted = { - type: "sync" - name: "session.next.reasoning.started.1" - id: string - seq: number - aggregateID: "sessionID" - data: { - timestamp: number - sessionID: string - reasoningID: string - } -} - -export type SyncEventSessionNextReasoningDelta = { - type: "sync" - name: "session.next.reasoning.delta.1" - id: string - seq: number - aggregateID: "sessionID" - data: { - timestamp: number - sessionID: string - reasoningID: string - delta: string - } -} - -export type SyncEventSessionNextReasoningEnded = { - type: "sync" - name: "session.next.reasoning.ended.1" - id: string - seq: number - aggregateID: "sessionID" - data: { - timestamp: number - sessionID: string - reasoningID: string - text: string - } -} - export type SyncEventSessionNextRetried = { type: "sync" name: "session.next.retried.1" @@ -2372,20 +2396,19 @@ export type SyncEventSessionNextCompactionEnded = { } } -export type SyncEventSessionNextToolProgress = { - type: "sync" - name: "session.next.tool.progress.1" +export type EventServerConnected = { id: string - seq: number - aggregateID: string - data: { - timestamp: number - sessionID: string - callID: string - structured: { - [key: string]: unknown - } - content: Array + type: "server.connected" + properties: { + [key: string]: unknown + } +} + +export type EventGlobalDisposed = { + id: string + type: "global.disposed" + properties: { + [key: string]: unknown } } @@ -2669,22 +2692,6 @@ export type EventInstallationUpdateAvailable = { } } -export type EventServerConnected = { - id: string - type: "server.connected" - properties: { - [key: string]: unknown - } -} - -export type EventGlobalDisposed = { - id: string - type: "global.disposed" - properties: { - [key: string]: unknown - } -} - export type ModelV2Info = { id: string apiID: string @@ -3485,6 +3492,71 @@ export type EventTuiToastShow1 = { } } +export type EventMessageUpdated = { + id: string + type: "message.updated" + properties: { + sessionID: string + info: Message + } +} + +export type EventMessageRemoved = { + id: string + type: "message.removed" + properties: { + sessionID: string + messageID: string + } +} + +export type EventMessagePartUpdated = { + id: string + type: "message.part.updated" + properties: { + sessionID: string + part: Part + time: number + } +} + +export type EventMessagePartRemoved = { + id: string + type: "message.part.removed" + properties: { + sessionID: string + messageID: string + partID: string + } +} + +export type EventSessionCreated = { + id: string + type: "session.created" + properties: { + sessionID: string + info: Session + } +} + +export type EventSessionUpdated = { + id: string + type: "session.updated" + properties: { + sessionID: string + info: Session + } +} + +export type EventSessionDeleted = { + id: string + type: "session.deleted" + properties: { + sessionID: string + info: Session + } +} + export type ModelV2Info1 = { id: string apiID: string From 5f4e5e6896ef9cdc6b83e18b5ac0d4a372e1f32a Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Thu, 14 May 2026 22:05:12 -0400 Subject: [PATCH 007/164] more typecheck fixes --- .../opencode/src/server/init-projectors.ts | 3 + .../src/server/routes/instance/httpapi/api.ts | 3 - packages/opencode/src/server/server.ts | 2 + packages/sdk/js/src/v2/gen/types.gen.ts | 151 +++++++++--------- 4 files changed, 84 insertions(+), 75 deletions(-) create mode 100644 packages/opencode/src/server/init-projectors.ts diff --git a/packages/opencode/src/server/init-projectors.ts b/packages/opencode/src/server/init-projectors.ts new file mode 100644 index 00000000000..86b66b256f9 --- /dev/null +++ b/packages/opencode/src/server/init-projectors.ts @@ -0,0 +1,3 @@ +import { initProjectors } from "./projectors" + +initProjectors() diff --git a/packages/opencode/src/server/routes/instance/httpapi/api.ts b/packages/opencode/src/server/routes/instance/httpapi/api.ts index c1779ab9211..eff336b3c63 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/api.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/api.ts @@ -1,6 +1,3 @@ -import { initProjectors } from "@/server/projectors" -initProjectors() - import { Schema } from "effect" import { HttpApi } from "effect/unstable/httpapi" import { BusEvent } from "@/bus/bus-event" diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index e787006271f..9a448b78d62 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -1,3 +1,5 @@ +import "./init-projectors" + import { NodeHttpServer } from "@effect/platform-node" import * as Log from "@opencode-ai/core/util/log" import { ConfigProvider, Context, Effect, Exit, Layer, Scope } from "effect" diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index a0cc527471f..b292edb3314 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -370,13 +370,6 @@ export type Pty = { pid: number } -export type Prompt = { - text: string - files?: Array - agents?: Array - references?: Array -} - export type OutputFormatText = { type: "text" } @@ -785,6 +778,13 @@ export type Session = { } } +export type Prompt = { + text: string + files?: Array + agents?: Array + references?: Array +} + export type GlobalEvent = { directory: string project?: string @@ -829,6 +829,13 @@ export type GlobalEvent = { | EventPtyDeleted | EventInstallationUpdated | EventInstallationUpdateAvailable + | EventMessageUpdated + | EventMessageRemoved + | EventMessagePartUpdated + | EventMessagePartRemoved + | EventSessionCreated + | EventSessionUpdated + | EventSessionDeleted | EventCatalogModelUpdated | EventSessionNextAgentSwitched | EventSessionNextModelSwitched @@ -2692,6 +2699,71 @@ export type EventInstallationUpdateAvailable = { } } +export type EventMessageUpdated = { + id: string + type: "message.updated" + properties: { + sessionID: string + info: Message + } +} + +export type EventMessageRemoved = { + id: string + type: "message.removed" + properties: { + sessionID: string + messageID: string + } +} + +export type EventMessagePartUpdated = { + id: string + type: "message.part.updated" + properties: { + sessionID: string + part: Part + time: number + } +} + +export type EventMessagePartRemoved = { + id: string + type: "message.part.removed" + properties: { + sessionID: string + messageID: string + partID: string + } +} + +export type EventSessionCreated = { + id: string + type: "session.created" + properties: { + sessionID: string + info: Session + } +} + +export type EventSessionUpdated = { + id: string + type: "session.updated" + properties: { + sessionID: string + info: Session + } +} + +export type EventSessionDeleted = { + id: string + type: "session.deleted" + properties: { + sessionID: string + info: Session + } +} + export type ModelV2Info = { id: string apiID: string @@ -3492,71 +3564,6 @@ export type EventTuiToastShow1 = { } } -export type EventMessageUpdated = { - id: string - type: "message.updated" - properties: { - sessionID: string - info: Message - } -} - -export type EventMessageRemoved = { - id: string - type: "message.removed" - properties: { - sessionID: string - messageID: string - } -} - -export type EventMessagePartUpdated = { - id: string - type: "message.part.updated" - properties: { - sessionID: string - part: Part - time: number - } -} - -export type EventMessagePartRemoved = { - id: string - type: "message.part.removed" - properties: { - sessionID: string - messageID: string - partID: string - } -} - -export type EventSessionCreated = { - id: string - type: "session.created" - properties: { - sessionID: string - info: Session - } -} - -export type EventSessionUpdated = { - id: string - type: "session.updated" - properties: { - sessionID: string - info: Session - } -} - -export type EventSessionDeleted = { - id: string - type: "session.deleted" - properties: { - sessionID: string - info: Session - } -} - export type ModelV2Info1 = { id: string apiID: string From a50ff72f3f3c0d909cf617668ea5c035ef4cda73 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Thu, 14 May 2026 22:13:15 -0400 Subject: [PATCH 008/164] fix: remove debug logging --- packages/opencode/src/server/projectors.ts | 1 - packages/opencode/src/sync/index.ts | 1 - 2 files changed, 2 deletions(-) diff --git a/packages/opencode/src/server/projectors.ts b/packages/opencode/src/server/projectors.ts index 1d250d4decb..c5fb2420a0c 100644 --- a/packages/opencode/src/server/projectors.ts +++ b/packages/opencode/src/server/projectors.ts @@ -6,7 +6,6 @@ import { Database } from "@/storage/db" import { eq } from "drizzle-orm" export function initProjectors() { - console.log("initProjectors") SyncEvent.init({ projectors: sessionProjectors, convertEvent: (type, data) => { diff --git a/packages/opencode/src/sync/index.ts b/packages/opencode/src/sync/index.ts index bdc319e282d..435dfa1ec78 100644 --- a/packages/opencode/src/sync/index.ts +++ b/packages/opencode/src/sync/index.ts @@ -233,7 +233,6 @@ export function init(input: { projectors: Array<[Definition, ProjectorFunc]>; co // replaying. Replaying does not go through the bus, and it // simplifies the bus to only use unversioned latest events for (let [type, version] of versions.entries()) { - console.log(type) let def = registry.get(versionedType(type, version))! BusEvent.define(def.type, def.properties) } From 10c849394056eb7ba181985b07e96259670fc069 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Fri, 15 May 2026 02:14:56 +0000 Subject: [PATCH 009/164] chore: generate --- packages/sdk/js/src/v2/gen/types.gen.ts | 166 +++--- packages/sdk/openapi.json | 707 ++++++++++++++++-------- 2 files changed, 561 insertions(+), 312 deletions(-) diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index b292edb3314..84eff74b94d 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -5,12 +5,6 @@ export type ClientOptions = { } export type Event = - | EventTuiPromptAppend - | EventTuiCommandExecute - | EventTuiToastShow1 - | EventTuiSessionSelect - | EventServerConnected - | EventGlobalDisposed | EventServerInstanceDisposed | EventFileEdited | EventFileWatcherUpdated @@ -27,6 +21,10 @@ export type Event = | EventTodoUpdated | EventSessionStatus | EventSessionIdle + | EventTuiPromptAppend + | EventTuiCommandExecute + | EventTuiToastShow1 + | EventTuiSessionSelect | EventMcpToolsChanged | EventMcpBrowserOpenFailed | EventCommandExecuted @@ -51,6 +49,8 @@ export type Event = | EventSessionCreated | EventSessionUpdated | EventSessionDeleted + | EventServerConnected + | EventGlobalDisposed | EventCatalogModelUpdated | EventSessionNextAgentSwitched | EventSessionNextModelSwitched @@ -104,61 +104,6 @@ export type WellKnownAuth = { export type Auth = OAuth | ApiAuth | WellKnownAuth -export type EventTuiPromptAppend = { - id: string - type: "tui.prompt.append" - properties: { - text: string - } -} - -export type EventTuiCommandExecute = { - id: string - type: "tui.command.execute" - properties: { - command: - | "session.list" - | "session.new" - | "session.share" - | "session.interrupt" - | "session.compact" - | "session.page.up" - | "session.page.down" - | "session.line.up" - | "session.line.down" - | "session.half.page.up" - | "session.half.page.down" - | "session.first" - | "session.last" - | "prompt.clear" - | "prompt.submit" - | "agent.cycle" - | string - } -} - -export type EventTuiToastShow = { - id: string - type: "tui.toast.show" - properties: { - title?: string - message: string - variant: "info" | "success" | "warning" | "error" - duration?: number - } -} - -export type EventTuiSessionSelect = { - id: string - type: "tui.session.select" - properties: { - /** - * Session ID to navigate to - */ - sessionID: string - } -} - export type PermissionRequest = { id: string sessionID: string @@ -336,6 +281,61 @@ export type SessionStatus = type: "busy" } +export type EventTuiPromptAppend = { + id: string + type: "tui.prompt.append" + properties: { + text: string + } +} + +export type EventTuiCommandExecute = { + id: string + type: "tui.command.execute" + properties: { + command: + | "session.list" + | "session.new" + | "session.share" + | "session.interrupt" + | "session.compact" + | "session.page.up" + | "session.page.down" + | "session.line.up" + | "session.line.down" + | "session.half.page.up" + | "session.half.page.down" + | "session.first" + | "session.last" + | "prompt.clear" + | "prompt.submit" + | "agent.cycle" + | string + } +} + +export type EventTuiToastShow = { + id: string + type: "tui.toast.show" + properties: { + title?: string + message: string + variant: "info" | "success" | "warning" | "error" + duration?: number + } +} + +export type EventTuiSessionSelect = { + id: string + type: "tui.session.select" + properties: { + /** + * Session ID to navigate to + */ + sessionID: string + } +} + export type Project = { id: string worktree: string @@ -790,12 +790,6 @@ export type GlobalEvent = { project?: string workspace?: string payload: - | EventTuiPromptAppend - | EventTuiCommandExecute - | EventTuiToastShow - | EventTuiSessionSelect - | EventServerConnected - | EventGlobalDisposed | EventServerInstanceDisposed | EventFileEdited | EventFileWatcherUpdated @@ -812,6 +806,10 @@ export type GlobalEvent = { | EventTodoUpdated | EventSessionStatus | EventSessionIdle + | EventTuiPromptAppend + | EventTuiCommandExecute + | EventTuiToastShow + | EventTuiSessionSelect | EventMcpToolsChanged | EventMcpBrowserOpenFailed | EventCommandExecuted @@ -836,6 +834,8 @@ export type GlobalEvent = { | EventSessionCreated | EventSessionUpdated | EventSessionDeleted + | EventServerConnected + | EventGlobalDisposed | EventCatalogModelUpdated | EventSessionNextAgentSwitched | EventSessionNextModelSwitched @@ -2403,22 +2403,6 @@ export type SyncEventSessionNextCompactionEnded = { } } -export type EventServerConnected = { - id: string - type: "server.connected" - properties: { - [key: string]: unknown - } -} - -export type EventGlobalDisposed = { - id: string - type: "global.disposed" - properties: { - [key: string]: unknown - } -} - export type EventServerInstanceDisposed = { id: string type: "server.instance.disposed" @@ -2764,6 +2748,22 @@ export type EventSessionDeleted = { } } +export type EventServerConnected = { + id: string + type: "server.connected" + properties: { + [key: string]: unknown + } +} + +export type EventGlobalDisposed = { + id: string + type: "global.disposed" + properties: { + [key: string]: unknown + } +} + export type ModelV2Info = { id: string apiID: string diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 913701b8cc2..a89521befb5 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -9154,6 +9154,27 @@ { "$ref": "#/components/schemas/EventInstallationUpdate-available" }, + { + "$ref": "#/components/schemas/EventMessageUpdated" + }, + { + "$ref": "#/components/schemas/EventMessageRemoved" + }, + { + "$ref": "#/components/schemas/EventMessagePartUpdated" + }, + { + "$ref": "#/components/schemas/EventMessagePartRemoved" + }, + { + "$ref": "#/components/schemas/EventSessionCreated" + }, + { + "$ref": "#/components/schemas/EventSessionUpdated" + }, + { + "$ref": "#/components/schemas/EventSessionDeleted" + }, { "$ref": "#/components/schemas/EventServerConnected" }, @@ -10017,34 +10038,6 @@ "required": ["id", "title", "command", "args", "cwd", "status", "pid"], "additionalProperties": false }, - "Prompt": { - "type": "object", - "properties": { - "text": { - "type": "string" - }, - "files": { - "type": "array", - "items": { - "$ref": "#/components/schemas/PromptFileAttachment" - } - }, - "agents": { - "type": "array", - "items": { - "$ref": "#/components/schemas/PromptAgentAttachment" - } - }, - "references": { - "type": "array", - "items": { - "$ref": "#/components/schemas/PromptReferenceAttachment" - } - } - }, - "required": ["text"], - "additionalProperties": false - }, "OutputFormatText": { "type": "object", "properties": { @@ -11310,6 +11303,34 @@ "required": ["id", "slug", "projectID", "directory", "title", "version", "time"], "additionalProperties": false }, + "Prompt": { + "type": "object", + "properties": { + "text": { + "type": "string" + }, + "files": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PromptFileAttachment" + } + }, + "agents": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PromptAgentAttachment" + } + }, + "references": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PromptReferenceAttachment" + } + } + }, + "required": ["text"], + "additionalProperties": false + }, "GlobalEvent": { "type": "object", "properties": { @@ -11435,6 +11456,27 @@ { "$ref": "#/components/schemas/EventInstallationUpdate-available" }, + { + "$ref": "#/components/schemas/EventMessageUpdated" + }, + { + "$ref": "#/components/schemas/EventMessageRemoved" + }, + { + "$ref": "#/components/schemas/EventMessagePartUpdated" + }, + { + "$ref": "#/components/schemas/EventMessagePartRemoved" + }, + { + "$ref": "#/components/schemas/EventSessionCreated" + }, + { + "$ref": "#/components/schemas/EventSessionUpdated" + }, + { + "$ref": "#/components/schemas/EventSessionDeleted" + }, { "$ref": "#/components/schemas/EventServerConnected" }, @@ -11579,6 +11621,15 @@ { "$ref": "#/components/schemas/SyncEventSessionNextTextEnded" }, + { + "$ref": "#/components/schemas/SyncEventSessionNextReasoningStarted" + }, + { + "$ref": "#/components/schemas/SyncEventSessionNextReasoningDelta" + }, + { + "$ref": "#/components/schemas/SyncEventSessionNextReasoningEnded" + }, { "$ref": "#/components/schemas/SyncEventSessionNextToolInputStarted" }, @@ -11591,21 +11642,15 @@ { "$ref": "#/components/schemas/SyncEventSessionNextToolCalled" }, + { + "$ref": "#/components/schemas/SyncEventSessionNextToolProgress" + }, { "$ref": "#/components/schemas/SyncEventSessionNextToolSuccess" }, { "$ref": "#/components/schemas/SyncEventSessionNextToolFailed" }, - { - "$ref": "#/components/schemas/SyncEventSessionNextReasoningStarted" - }, - { - "$ref": "#/components/schemas/SyncEventSessionNextReasoningDelta" - }, - { - "$ref": "#/components/schemas/SyncEventSessionNextReasoningEnded" - }, { "$ref": "#/components/schemas/SyncEventSessionNextRetried" }, @@ -11617,9 +11662,6 @@ }, { "$ref": "#/components/schemas/SyncEventSessionNextCompactionEnded" - }, - { - "$ref": "#/components/schemas/SyncEventSessionNextToolProgress" } ] } @@ -15512,6 +15554,138 @@ "required": ["type", "name", "id", "seq", "aggregateID", "data"], "additionalProperties": false }, + "SyncEventSessionNextReasoningStarted": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["sync"] + }, + "name": { + "type": "string", + "enum": ["session.next.reasoning.started.1"] + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "enum": ["sessionID"] + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "reasoningID": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "reasoningID"], + "additionalProperties": false + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"], + "additionalProperties": false + }, + "SyncEventSessionNextReasoningDelta": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["sync"] + }, + "name": { + "type": "string", + "enum": ["session.next.reasoning.delta.1"] + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "enum": ["sessionID"] + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "reasoningID": { + "type": "string" + }, + "delta": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "reasoningID", "delta"], + "additionalProperties": false + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"], + "additionalProperties": false + }, + "SyncEventSessionNextReasoningEnded": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["sync"] + }, + "name": { + "type": "string", + "enum": ["session.next.reasoning.ended.1"] + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "enum": ["sessionID"] + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "reasoningID": { + "type": "string" + }, + "text": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "reasoningID", "text"], + "additionalProperties": false + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"], + "additionalProperties": false + }, "SyncEventSessionNextToolInputStarted": { "type": "object", "properties": { @@ -15708,6 +15882,64 @@ "required": ["type", "name", "id", "seq", "aggregateID", "data"], "additionalProperties": false }, + "SyncEventSessionNextToolProgress": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["sync"] + }, + "name": { + "type": "string", + "enum": ["session.next.tool.progress.1"] + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "enum": ["sessionID"] + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "callID": { + "type": "string" + }, + "structured": { + "type": "object" + }, + "content": { + "type": "array", + "items": { + "anyOf": [ + { + "$ref": "#/components/schemas/ToolTextContent" + }, + { + "$ref": "#/components/schemas/ToolFileContent" + } + ] + } + } + }, + "required": ["timestamp", "sessionID", "callID", "structured", "content"], + "additionalProperties": false + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"], + "additionalProperties": false + }, "SyncEventSessionNextToolSuccess": { "type": "object", "properties": { @@ -15837,138 +16069,6 @@ "required": ["type", "name", "id", "seq", "aggregateID", "data"], "additionalProperties": false }, - "SyncEventSessionNextReasoningStarted": { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": ["sync"] - }, - "name": { - "type": "string", - "enum": ["session.next.reasoning.started.1"] - }, - "id": { - "type": "string" - }, - "seq": { - "type": "number" - }, - "aggregateID": { - "type": "string", - "enum": ["sessionID"] - }, - "data": { - "type": "object", - "properties": { - "timestamp": { - "type": "number" - }, - "sessionID": { - "type": "string", - "pattern": "^ses" - }, - "reasoningID": { - "type": "string" - } - }, - "required": ["timestamp", "sessionID", "reasoningID"], - "additionalProperties": false - } - }, - "required": ["type", "name", "id", "seq", "aggregateID", "data"], - "additionalProperties": false - }, - "SyncEventSessionNextReasoningDelta": { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": ["sync"] - }, - "name": { - "type": "string", - "enum": ["session.next.reasoning.delta.1"] - }, - "id": { - "type": "string" - }, - "seq": { - "type": "number" - }, - "aggregateID": { - "type": "string", - "enum": ["sessionID"] - }, - "data": { - "type": "object", - "properties": { - "timestamp": { - "type": "number" - }, - "sessionID": { - "type": "string", - "pattern": "^ses" - }, - "reasoningID": { - "type": "string" - }, - "delta": { - "type": "string" - } - }, - "required": ["timestamp", "sessionID", "reasoningID", "delta"], - "additionalProperties": false - } - }, - "required": ["type", "name", "id", "seq", "aggregateID", "data"], - "additionalProperties": false - }, - "SyncEventSessionNextReasoningEnded": { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": ["sync"] - }, - "name": { - "type": "string", - "enum": ["session.next.reasoning.ended.1"] - }, - "id": { - "type": "string" - }, - "seq": { - "type": "number" - }, - "aggregateID": { - "type": "string", - "enum": ["sessionID"] - }, - "data": { - "type": "object", - "properties": { - "timestamp": { - "type": "number" - }, - "sessionID": { - "type": "string", - "pattern": "^ses" - }, - "reasoningID": { - "type": "string" - }, - "text": { - "type": "string" - } - }, - "required": ["timestamp", "sessionID", "reasoningID", "text"], - "additionalProperties": false - } - }, - "required": ["type", "name", "id", "seq", "aggregateID", "data"], - "additionalProperties": false - }, "SyncEventSessionNextRetried": { "type": "object", "properties": { @@ -16144,63 +16244,6 @@ "required": ["type", "name", "id", "seq", "aggregateID", "data"], "additionalProperties": false }, - "SyncEventSessionNextToolProgress": { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": ["sync"] - }, - "name": { - "type": "string", - "enum": ["session.next.tool.progress.1"] - }, - "id": { - "type": "string" - }, - "seq": { - "type": "number" - }, - "aggregateID": { - "type": "string" - }, - "data": { - "type": "object", - "properties": { - "timestamp": { - "type": "number" - }, - "sessionID": { - "type": "string", - "pattern": "^ses" - }, - "callID": { - "type": "string" - }, - "structured": { - "type": "object" - }, - "content": { - "type": "array", - "items": { - "anyOf": [ - { - "$ref": "#/components/schemas/ToolTextContent" - }, - { - "$ref": "#/components/schemas/ToolFileContent" - } - ] - } - } - }, - "required": ["timestamp", "sessionID", "callID", "structured", "content"], - "additionalProperties": false - } - }, - "required": ["type", "name", "id", "seq", "aggregateID", "data"], - "additionalProperties": false - }, "EventServerInstanceDisposed": { "type": "object", "properties": { @@ -17055,6 +17098,212 @@ "required": ["id", "type", "properties"], "additionalProperties": false }, + "EventMessageUpdated": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["message.updated"] + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "info": { + "$ref": "#/components/schemas/Message" + } + }, + "required": ["sessionID", "info"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventMessageRemoved": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["message.removed"] + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "messageID": { + "type": "string", + "pattern": "^msg" + } + }, + "required": ["sessionID", "messageID"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventMessagePartUpdated": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["message.part.updated"] + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "part": { + "$ref": "#/components/schemas/Part" + }, + "time": { + "type": "integer", + "minimum": 0 + } + }, + "required": ["sessionID", "part", "time"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventMessagePartRemoved": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["message.part.removed"] + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "messageID": { + "type": "string", + "pattern": "^msg" + }, + "partID": { + "type": "string", + "pattern": "^prt" + } + }, + "required": ["sessionID", "messageID", "partID"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventSessionCreated": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["session.created"] + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "info": { + "$ref": "#/components/schemas/Session" + } + }, + "required": ["sessionID", "info"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventSessionUpdated": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["session.updated"] + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "info": { + "$ref": "#/components/schemas/Session" + } + }, + "required": ["sessionID", "info"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventSessionDeleted": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["session.deleted"] + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "info": { + "$ref": "#/components/schemas/Session" + } + }, + "required": ["sessionID", "info"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, "EventServerConnected": { "type": "object", "properties": { From 16639eee7695243c8cc1f93345b44bece3c18904 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Thu, 14 May 2026 22:31:28 -0400 Subject: [PATCH 010/164] test fixes --- packages/opencode/src/event-v2-bridge.ts | 14 +- packages/opencode/src/sync/index.ts | 10 + packages/sdk/js/src/v2/gen/types.gen.ts | 382 +++++++++++------------ 3 files changed, 203 insertions(+), 203 deletions(-) diff --git a/packages/opencode/src/event-v2-bridge.ts b/packages/opencode/src/event-v2-bridge.ts index 7ea2e9d1bba..c8213254a5e 100644 --- a/packages/opencode/src/event-v2-bridge.ts +++ b/packages/opencode/src/event-v2-bridge.ts @@ -11,16 +11,7 @@ import "@opencode-ai/core/catalog" import "@opencode-ai/core/session-event" import { Context, Effect, Layer, Option } from "effect" -const syncDefinitions = new WeakMap() - -export function toSyncDefinition( - definition: D, -): SyncEvent.Definition { - const cached = syncDefinitions.get(definition) - if (cached) return cached as SyncEvent.Definition - if (definition.version === undefined) - throw new Error(`Event.toSyncDefinition: version required for ${definition.type}`) - if (!definition.aggregate) throw new Error(`Event.toSyncDefinition: aggregate required for ${definition.type}`) +export function toSyncDefinition(definition: D) { const result = { type: definition.type, version: definition.version, @@ -28,8 +19,7 @@ export function toSyncDefinition( schema: definition.data, properties: definition.data, } - syncDefinitions.set(definition, result) - return result + return result as SyncEvent.Definition } export class Service extends Context.Service()("@opencode/EventV2Bridge") {} diff --git a/packages/opencode/src/sync/index.ts b/packages/opencode/src/sync/index.ts index 435dfa1ec78..df9a62f314f 100644 --- a/packages/opencode/src/sync/index.ts +++ b/packages/opencode/src/sync/index.ts @@ -227,6 +227,16 @@ export function reset() { export function init(input: { projectors: Array<[Definition, ProjectorFunc]>; convertEvent?: ConvertEvent }) { projectors = new Map(input.projectors) + for (let entry of EventV2.registry.values()) { + if (!entry.version || !entry.aggregate) continue + register({ + type: entry.type, + version: entry.version, + aggregate: entry.aggregate, + properties: entry.data, + schema: entry.data, + }) + } // Install all the latest event defs to the bus. We only ever emit // latest versions from code, and keep around old versions for diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 84eff74b94d..517271ca405 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -5,6 +5,12 @@ export type ClientOptions = { } export type Event = + | EventTuiPromptAppend + | EventTuiCommandExecute + | EventTuiToastShow1 + | EventTuiSessionSelect + | EventServerConnected + | EventGlobalDisposed | EventServerInstanceDisposed | EventFileEdited | EventFileWatcherUpdated @@ -21,10 +27,6 @@ export type Event = | EventTodoUpdated | EventSessionStatus | EventSessionIdle - | EventTuiPromptAppend - | EventTuiCommandExecute - | EventTuiToastShow1 - | EventTuiSessionSelect | EventMcpToolsChanged | EventMcpBrowserOpenFailed | EventCommandExecuted @@ -49,9 +51,6 @@ export type Event = | EventSessionCreated | EventSessionUpdated | EventSessionDeleted - | EventServerConnected - | EventGlobalDisposed - | EventCatalogModelUpdated | EventSessionNextAgentSwitched | EventSessionNextModelSwitched | EventSessionNextPrompted @@ -78,6 +77,7 @@ export type Event = | EventSessionNextCompactionStarted | EventSessionNextCompactionDelta | EventSessionNextCompactionEnded + | EventCatalogModelUpdated export type OAuth = { type: "oauth" @@ -104,6 +104,61 @@ export type WellKnownAuth = { export type Auth = OAuth | ApiAuth | WellKnownAuth +export type EventTuiPromptAppend = { + id: string + type: "tui.prompt.append" + properties: { + text: string + } +} + +export type EventTuiCommandExecute = { + id: string + type: "tui.command.execute" + properties: { + command: + | "session.list" + | "session.new" + | "session.share" + | "session.interrupt" + | "session.compact" + | "session.page.up" + | "session.page.down" + | "session.line.up" + | "session.line.down" + | "session.half.page.up" + | "session.half.page.down" + | "session.first" + | "session.last" + | "prompt.clear" + | "prompt.submit" + | "agent.cycle" + | string + } +} + +export type EventTuiToastShow = { + id: string + type: "tui.toast.show" + properties: { + title?: string + message: string + variant: "info" | "success" | "warning" | "error" + duration?: number + } +} + +export type EventTuiSessionSelect = { + id: string + type: "tui.session.select" + properties: { + /** + * Session ID to navigate to + */ + sessionID: string + } +} + export type PermissionRequest = { id: string sessionID: string @@ -281,61 +336,6 @@ export type SessionStatus = type: "busy" } -export type EventTuiPromptAppend = { - id: string - type: "tui.prompt.append" - properties: { - text: string - } -} - -export type EventTuiCommandExecute = { - id: string - type: "tui.command.execute" - properties: { - command: - | "session.list" - | "session.new" - | "session.share" - | "session.interrupt" - | "session.compact" - | "session.page.up" - | "session.page.down" - | "session.line.up" - | "session.line.down" - | "session.half.page.up" - | "session.half.page.down" - | "session.first" - | "session.last" - | "prompt.clear" - | "prompt.submit" - | "agent.cycle" - | string - } -} - -export type EventTuiToastShow = { - id: string - type: "tui.toast.show" - properties: { - title?: string - message: string - variant: "info" | "success" | "warning" | "error" - duration?: number - } -} - -export type EventTuiSessionSelect = { - id: string - type: "tui.session.select" - properties: { - /** - * Session ID to navigate to - */ - sessionID: string - } -} - export type Project = { id: string worktree: string @@ -790,6 +790,12 @@ export type GlobalEvent = { project?: string workspace?: string payload: + | EventTuiPromptAppend + | EventTuiCommandExecute + | EventTuiToastShow + | EventTuiSessionSelect + | EventServerConnected + | EventGlobalDisposed | EventServerInstanceDisposed | EventFileEdited | EventFileWatcherUpdated @@ -806,10 +812,6 @@ export type GlobalEvent = { | EventTodoUpdated | EventSessionStatus | EventSessionIdle - | EventTuiPromptAppend - | EventTuiCommandExecute - | EventTuiToastShow - | EventTuiSessionSelect | EventMcpToolsChanged | EventMcpBrowserOpenFailed | EventCommandExecuted @@ -834,9 +836,6 @@ export type GlobalEvent = { | EventSessionCreated | EventSessionUpdated | EventSessionDeleted - | EventServerConnected - | EventGlobalDisposed - | EventCatalogModelUpdated | EventSessionNextAgentSwitched | EventSessionNextModelSwitched | EventSessionNextPrompted @@ -863,6 +862,7 @@ export type GlobalEvent = { | EventSessionNextCompactionStarted | EventSessionNextCompactionDelta | EventSessionNextCompactionEnded + | EventCatalogModelUpdated | SyncEventMessageUpdated | SyncEventMessageRemoved | SyncEventMessagePartUpdated @@ -2403,6 +2403,22 @@ export type SyncEventSessionNextCompactionEnded = { } } +export type EventServerConnected = { + id: string + type: "server.connected" + properties: { + [key: string]: unknown + } +} + +export type EventGlobalDisposed = { + id: string + type: "global.disposed" + properties: { + [key: string]: unknown + } +} + export type EventServerInstanceDisposed = { id: string type: "server.instance.disposed" @@ -2748,128 +2764,6 @@ export type EventSessionDeleted = { } } -export type EventServerConnected = { - id: string - type: "server.connected" - properties: { - [key: string]: unknown - } -} - -export type EventGlobalDisposed = { - id: string - type: "global.disposed" - properties: { - [key: string]: unknown - } -} - -export type ModelV2Info = { - id: string - apiID: string - providerID: string - family?: string - name: string - endpoint: - | { - type: "unknown" - } - | { - type: "openai/responses" - url: string - websocket?: boolean - } - | { - type: "openai/completions" - url: string - reasoning?: - | { - type: "reasoning_content" - } - | { - type: "reasoning_details" - } - } - | { - type: "anthropic/messages" - url: string - } - | { - type: "aisdk" - package: string - url?: string - } - capabilities: { - tools: boolean - input: Array - output: Array - } - options: { - headers: { - [key: string]: string - } - body: { - [key: string]: unknown - } - aisdk: { - provider: { - [key: string]: unknown - } - request: { - [key: string]: unknown - } - } - variant?: string - } - variants: Array<{ - id: string - headers: { - [key: string]: string - } - body: { - [key: string]: unknown - } - aisdk: { - provider: { - [key: string]: unknown - } - request: { - [key: string]: unknown - } - } - }> - time: { - released: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" - } - cost: Array<{ - tier?: { - type: "context" - size: number - } - input: number - output: number - cache: { - read: number - write: number - } - }> - status: "alpha" | "beta" | "deprecated" | "active" - enabled: boolean - limit: { - context: number - input?: number - output: number - } -} - -export type EventCatalogModelUpdated = { - id: string - type: "catalog.model.updated" - properties: { - model: ModelV2Info - } -} - export type EventSessionNextAgentSwitched = { id: string type: "session.next.agent.switched" @@ -3251,6 +3145,112 @@ export type EventSessionNextCompactionEnded = { } } +export type ModelV2Info = { + id: string + apiID: string + providerID: string + family?: string + name: string + endpoint: + | { + type: "unknown" + } + | { + type: "openai/responses" + url: string + websocket?: boolean + } + | { + type: "openai/completions" + url: string + reasoning?: + | { + type: "reasoning_content" + } + | { + type: "reasoning_details" + } + } + | { + type: "anthropic/messages" + url: string + } + | { + type: "aisdk" + package: string + url?: string + } + capabilities: { + tools: boolean + input: Array + output: Array + } + options: { + headers: { + [key: string]: string + } + body: { + [key: string]: unknown + } + aisdk: { + provider: { + [key: string]: unknown + } + request: { + [key: string]: unknown + } + } + variant?: string + } + variants: Array<{ + id: string + headers: { + [key: string]: string + } + body: { + [key: string]: unknown + } + aisdk: { + provider: { + [key: string]: unknown + } + request: { + [key: string]: unknown + } + } + }> + time: { + released: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + } + cost: Array<{ + tier?: { + type: "context" + size: number + } + input: number + output: number + cache: { + read: number + write: number + } + }> + status: "alpha" | "beta" | "deprecated" | "active" + enabled: boolean + limit: { + context: number + input?: number + output: number + } +} + +export type EventCatalogModelUpdated = { + id: string + type: "catalog.model.updated" + properties: { + model: ModelV2Info + } +} + export type SessionInfo = { id: string parentID?: string From 34b1be5bcd1224a3b4a3cd2a53cd8f56c63e575c Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Fri, 15 May 2026 02:34:06 +0000 Subject: [PATCH 011/164] chore: generate --- packages/sdk/js/src/v2/gen/types.gen.ts | 166 ++--- packages/sdk/openapi.json | 924 ++++++++++++++---------- 2 files changed, 623 insertions(+), 467 deletions(-) diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 517271ca405..eebe7f1e28c 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -5,12 +5,6 @@ export type ClientOptions = { } export type Event = - | EventTuiPromptAppend - | EventTuiCommandExecute - | EventTuiToastShow1 - | EventTuiSessionSelect - | EventServerConnected - | EventGlobalDisposed | EventServerInstanceDisposed | EventFileEdited | EventFileWatcherUpdated @@ -27,6 +21,10 @@ export type Event = | EventTodoUpdated | EventSessionStatus | EventSessionIdle + | EventTuiPromptAppend + | EventTuiCommandExecute + | EventTuiToastShow1 + | EventTuiSessionSelect | EventMcpToolsChanged | EventMcpBrowserOpenFailed | EventCommandExecuted @@ -77,6 +75,8 @@ export type Event = | EventSessionNextCompactionStarted | EventSessionNextCompactionDelta | EventSessionNextCompactionEnded + | EventServerConnected + | EventGlobalDisposed | EventCatalogModelUpdated export type OAuth = { @@ -104,61 +104,6 @@ export type WellKnownAuth = { export type Auth = OAuth | ApiAuth | WellKnownAuth -export type EventTuiPromptAppend = { - id: string - type: "tui.prompt.append" - properties: { - text: string - } -} - -export type EventTuiCommandExecute = { - id: string - type: "tui.command.execute" - properties: { - command: - | "session.list" - | "session.new" - | "session.share" - | "session.interrupt" - | "session.compact" - | "session.page.up" - | "session.page.down" - | "session.line.up" - | "session.line.down" - | "session.half.page.up" - | "session.half.page.down" - | "session.first" - | "session.last" - | "prompt.clear" - | "prompt.submit" - | "agent.cycle" - | string - } -} - -export type EventTuiToastShow = { - id: string - type: "tui.toast.show" - properties: { - title?: string - message: string - variant: "info" | "success" | "warning" | "error" - duration?: number - } -} - -export type EventTuiSessionSelect = { - id: string - type: "tui.session.select" - properties: { - /** - * Session ID to navigate to - */ - sessionID: string - } -} - export type PermissionRequest = { id: string sessionID: string @@ -336,6 +281,61 @@ export type SessionStatus = type: "busy" } +export type EventTuiPromptAppend = { + id: string + type: "tui.prompt.append" + properties: { + text: string + } +} + +export type EventTuiCommandExecute = { + id: string + type: "tui.command.execute" + properties: { + command: + | "session.list" + | "session.new" + | "session.share" + | "session.interrupt" + | "session.compact" + | "session.page.up" + | "session.page.down" + | "session.line.up" + | "session.line.down" + | "session.half.page.up" + | "session.half.page.down" + | "session.first" + | "session.last" + | "prompt.clear" + | "prompt.submit" + | "agent.cycle" + | string + } +} + +export type EventTuiToastShow = { + id: string + type: "tui.toast.show" + properties: { + title?: string + message: string + variant: "info" | "success" | "warning" | "error" + duration?: number + } +} + +export type EventTuiSessionSelect = { + id: string + type: "tui.session.select" + properties: { + /** + * Session ID to navigate to + */ + sessionID: string + } +} + export type Project = { id: string worktree: string @@ -790,12 +790,6 @@ export type GlobalEvent = { project?: string workspace?: string payload: - | EventTuiPromptAppend - | EventTuiCommandExecute - | EventTuiToastShow - | EventTuiSessionSelect - | EventServerConnected - | EventGlobalDisposed | EventServerInstanceDisposed | EventFileEdited | EventFileWatcherUpdated @@ -812,6 +806,10 @@ export type GlobalEvent = { | EventTodoUpdated | EventSessionStatus | EventSessionIdle + | EventTuiPromptAppend + | EventTuiCommandExecute + | EventTuiToastShow + | EventTuiSessionSelect | EventMcpToolsChanged | EventMcpBrowserOpenFailed | EventCommandExecuted @@ -862,6 +860,8 @@ export type GlobalEvent = { | EventSessionNextCompactionStarted | EventSessionNextCompactionDelta | EventSessionNextCompactionEnded + | EventServerConnected + | EventGlobalDisposed | EventCatalogModelUpdated | SyncEventMessageUpdated | SyncEventMessageRemoved @@ -2403,22 +2403,6 @@ export type SyncEventSessionNextCompactionEnded = { } } -export type EventServerConnected = { - id: string - type: "server.connected" - properties: { - [key: string]: unknown - } -} - -export type EventGlobalDisposed = { - id: string - type: "global.disposed" - properties: { - [key: string]: unknown - } -} - export type EventServerInstanceDisposed = { id: string type: "server.instance.disposed" @@ -3145,6 +3129,22 @@ export type EventSessionNextCompactionEnded = { } } +export type EventServerConnected = { + id: string + type: "server.connected" + properties: { + [key: string]: unknown + } +} + +export type EventGlobalDisposed = { + id: string + type: "global.disposed" + properties: { + [key: string]: unknown + } +} + export type ModelV2Info = { id: string apiID: string diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index a89521befb5..e92347c9bca 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -9175,6 +9175,84 @@ { "$ref": "#/components/schemas/EventSessionDeleted" }, + { + "$ref": "#/components/schemas/EventSessionNextAgentSwitched" + }, + { + "$ref": "#/components/schemas/EventSessionNextModelSwitched" + }, + { + "$ref": "#/components/schemas/EventSessionNextPrompted" + }, + { + "$ref": "#/components/schemas/EventSessionNextSynthetic" + }, + { + "$ref": "#/components/schemas/EventSessionNextShellStarted" + }, + { + "$ref": "#/components/schemas/EventSessionNextShellEnded" + }, + { + "$ref": "#/components/schemas/EventSessionNextStepStarted" + }, + { + "$ref": "#/components/schemas/EventSessionNextStepEnded" + }, + { + "$ref": "#/components/schemas/EventSessionNextStepFailed" + }, + { + "$ref": "#/components/schemas/EventSessionNextTextStarted" + }, + { + "$ref": "#/components/schemas/EventSessionNextTextDelta" + }, + { + "$ref": "#/components/schemas/EventSessionNextTextEnded" + }, + { + "$ref": "#/components/schemas/EventSessionNextReasoningStarted" + }, + { + "$ref": "#/components/schemas/EventSessionNextReasoningDelta" + }, + { + "$ref": "#/components/schemas/EventSessionNextReasoningEnded" + }, + { + "$ref": "#/components/schemas/EventSessionNextToolInputStarted" + }, + { + "$ref": "#/components/schemas/EventSessionNextToolInputDelta" + }, + { + "$ref": "#/components/schemas/EventSessionNextToolInputEnded" + }, + { + "$ref": "#/components/schemas/EventSessionNextToolCalled" + }, + { + "$ref": "#/components/schemas/EventSessionNextToolProgress" + }, + { + "$ref": "#/components/schemas/EventSessionNextToolSuccess" + }, + { + "$ref": "#/components/schemas/EventSessionNextToolFailed" + }, + { + "$ref": "#/components/schemas/EventSessionNextRetried" + }, + { + "$ref": "#/components/schemas/EventSessionNextCompactionStarted" + }, + { + "$ref": "#/components/schemas/EventSessionNextCompactionDelta" + }, + { + "$ref": "#/components/schemas/EventSessionNextCompactionEnded" + }, { "$ref": "#/components/schemas/EventServerConnected" }, @@ -11477,6 +11555,84 @@ { "$ref": "#/components/schemas/EventSessionDeleted" }, + { + "$ref": "#/components/schemas/EventSessionNextAgentSwitched" + }, + { + "$ref": "#/components/schemas/EventSessionNextModelSwitched" + }, + { + "$ref": "#/components/schemas/EventSessionNextPrompted" + }, + { + "$ref": "#/components/schemas/EventSessionNextSynthetic" + }, + { + "$ref": "#/components/schemas/EventSessionNextShellStarted" + }, + { + "$ref": "#/components/schemas/EventSessionNextShellEnded" + }, + { + "$ref": "#/components/schemas/EventSessionNextStepStarted" + }, + { + "$ref": "#/components/schemas/EventSessionNextStepEnded" + }, + { + "$ref": "#/components/schemas/EventSessionNextStepFailed" + }, + { + "$ref": "#/components/schemas/EventSessionNextTextStarted" + }, + { + "$ref": "#/components/schemas/EventSessionNextTextDelta" + }, + { + "$ref": "#/components/schemas/EventSessionNextTextEnded" + }, + { + "$ref": "#/components/schemas/EventSessionNextReasoningStarted" + }, + { + "$ref": "#/components/schemas/EventSessionNextReasoningDelta" + }, + { + "$ref": "#/components/schemas/EventSessionNextReasoningEnded" + }, + { + "$ref": "#/components/schemas/EventSessionNextToolInputStarted" + }, + { + "$ref": "#/components/schemas/EventSessionNextToolInputDelta" + }, + { + "$ref": "#/components/schemas/EventSessionNextToolInputEnded" + }, + { + "$ref": "#/components/schemas/EventSessionNextToolCalled" + }, + { + "$ref": "#/components/schemas/EventSessionNextToolProgress" + }, + { + "$ref": "#/components/schemas/EventSessionNextToolSuccess" + }, + { + "$ref": "#/components/schemas/EventSessionNextToolFailed" + }, + { + "$ref": "#/components/schemas/EventSessionNextRetried" + }, + { + "$ref": "#/components/schemas/EventSessionNextCompactionStarted" + }, + { + "$ref": "#/components/schemas/EventSessionNextCompactionDelta" + }, + { + "$ref": "#/components/schemas/EventSessionNextCompactionEnded" + }, { "$ref": "#/components/schemas/EventServerConnected" }, @@ -17304,390 +17460,6 @@ "required": ["id", "type", "properties"], "additionalProperties": false }, - "EventServerConnected": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "enum": ["server.connected"] - }, - "properties": { - "type": "object", - "properties": {} - } - }, - "required": ["id", "type", "properties"], - "additionalProperties": false - }, - "EventGlobalDisposed": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "enum": ["global.disposed"] - }, - "properties": { - "type": "object", - "properties": {} - } - }, - "required": ["id", "type", "properties"], - "additionalProperties": false - }, - "ModelV2Info": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "apiID": { - "type": "string" - }, - "providerID": { - "type": "string" - }, - "family": { - "type": "string" - }, - "name": { - "type": "string" - }, - "endpoint": { - "anyOf": [ - { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": ["unknown"] - } - }, - "required": ["type"], - "additionalProperties": false - }, - { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": ["openai/responses"] - }, - "url": { - "type": "string" - }, - "websocket": { - "type": "boolean" - } - }, - "required": ["type", "url"], - "additionalProperties": false - }, - { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": ["openai/completions"] - }, - "url": { - "type": "string" - }, - "reasoning": { - "anyOf": [ - { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": ["reasoning_content"] - } - }, - "required": ["type"], - "additionalProperties": false - }, - { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": ["reasoning_details"] - } - }, - "required": ["type"], - "additionalProperties": false - } - ] - } - }, - "required": ["type", "url"], - "additionalProperties": false - }, - { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": ["anthropic/messages"] - }, - "url": { - "type": "string" - } - }, - "required": ["type", "url"], - "additionalProperties": false - }, - { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": ["aisdk"] - }, - "package": { - "type": "string" - }, - "url": { - "type": "string" - } - }, - "required": ["type", "package"], - "additionalProperties": false - } - ] - }, - "capabilities": { - "type": "object", - "properties": { - "tools": { - "type": "boolean" - }, - "input": { - "type": "array", - "items": { - "type": "string" - } - }, - "output": { - "type": "array", - "items": { - "type": "string" - } - } - }, - "required": ["tools", "input", "output"], - "additionalProperties": false - }, - "options": { - "type": "object", - "properties": { - "headers": { - "type": "object", - "additionalProperties": { - "type": "string" - } - }, - "body": { - "type": "object" - }, - "aisdk": { - "type": "object", - "properties": { - "provider": { - "type": "object" - }, - "request": { - "type": "object" - } - }, - "required": ["provider", "request"], - "additionalProperties": false - }, - "variant": { - "type": "string" - } - }, - "required": ["headers", "body", "aisdk"], - "additionalProperties": false - }, - "variants": { - "type": "array", - "items": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "headers": { - "type": "object", - "additionalProperties": { - "type": "string" - } - }, - "body": { - "type": "object" - }, - "aisdk": { - "type": "object", - "properties": { - "provider": { - "type": "object" - }, - "request": { - "type": "object" - } - }, - "required": ["provider", "request"], - "additionalProperties": false - } - }, - "required": ["id", "headers", "body", "aisdk"], - "additionalProperties": false - } - }, - "time": { - "type": "object", - "properties": { - "released": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "enum": ["NaN"] - }, - { - "type": "string", - "enum": ["Infinity"] - }, - { - "type": "string", - "enum": ["-Infinity"] - }, - { - "type": "string", - "enum": ["Infinity", "-Infinity", "NaN"] - } - ] - } - }, - "required": ["released"], - "additionalProperties": false - }, - "cost": { - "type": "array", - "items": { - "type": "object", - "properties": { - "tier": { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": ["context"] - }, - "size": { - "type": "integer" - } - }, - "required": ["type", "size"], - "additionalProperties": false - }, - "input": { - "type": "number" - }, - "output": { - "type": "number" - }, - "cache": { - "type": "object", - "properties": { - "read": { - "type": "number" - }, - "write": { - "type": "number" - } - }, - "required": ["read", "write"], - "additionalProperties": false - } - }, - "required": ["input", "output", "cache"], - "additionalProperties": false - } - }, - "status": { - "type": "string", - "enum": ["alpha", "beta", "deprecated", "active"] - }, - "enabled": { - "type": "boolean" - }, - "limit": { - "type": "object", - "properties": { - "context": { - "type": "integer" - }, - "input": { - "type": "integer" - }, - "output": { - "type": "integer" - } - }, - "required": ["context", "output"], - "additionalProperties": false - } - }, - "required": [ - "id", - "apiID", - "providerID", - "name", - "endpoint", - "capabilities", - "options", - "variants", - "time", - "cost", - "status", - "enabled", - "limit" - ], - "additionalProperties": false - }, - "EventCatalogModelUpdated": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "enum": ["catalog.model.updated"] - }, - "properties": { - "type": "object", - "properties": { - "model": { - "$ref": "#/components/schemas/ModelV2Info" - } - }, - "required": ["model"], - "additionalProperties": false - } - }, - "required": ["id", "type", "properties"], - "additionalProperties": false - }, "EventSessionNextAgentSwitched": { "type": "object", "properties": { @@ -18831,6 +18603,390 @@ "required": ["id", "type", "properties"], "additionalProperties": false }, + "EventServerConnected": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["server.connected"] + }, + "properties": { + "type": "object", + "properties": {} + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventGlobalDisposed": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["global.disposed"] + }, + "properties": { + "type": "object", + "properties": {} + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "ModelV2Info": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "apiID": { + "type": "string" + }, + "providerID": { + "type": "string" + }, + "family": { + "type": "string" + }, + "name": { + "type": "string" + }, + "endpoint": { + "anyOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["unknown"] + } + }, + "required": ["type"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["openai/responses"] + }, + "url": { + "type": "string" + }, + "websocket": { + "type": "boolean" + } + }, + "required": ["type", "url"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["openai/completions"] + }, + "url": { + "type": "string" + }, + "reasoning": { + "anyOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["reasoning_content"] + } + }, + "required": ["type"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["reasoning_details"] + } + }, + "required": ["type"], + "additionalProperties": false + } + ] + } + }, + "required": ["type", "url"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["anthropic/messages"] + }, + "url": { + "type": "string" + } + }, + "required": ["type", "url"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["aisdk"] + }, + "package": { + "type": "string" + }, + "url": { + "type": "string" + } + }, + "required": ["type", "package"], + "additionalProperties": false + } + ] + }, + "capabilities": { + "type": "object", + "properties": { + "tools": { + "type": "boolean" + }, + "input": { + "type": "array", + "items": { + "type": "string" + } + }, + "output": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": ["tools", "input", "output"], + "additionalProperties": false + }, + "options": { + "type": "object", + "properties": { + "headers": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "body": { + "type": "object" + }, + "aisdk": { + "type": "object", + "properties": { + "provider": { + "type": "object" + }, + "request": { + "type": "object" + } + }, + "required": ["provider", "request"], + "additionalProperties": false + }, + "variant": { + "type": "string" + } + }, + "required": ["headers", "body", "aisdk"], + "additionalProperties": false + }, + "variants": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "headers": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "body": { + "type": "object" + }, + "aisdk": { + "type": "object", + "properties": { + "provider": { + "type": "object" + }, + "request": { + "type": "object" + } + }, + "required": ["provider", "request"], + "additionalProperties": false + } + }, + "required": ["id", "headers", "body", "aisdk"], + "additionalProperties": false + } + }, + "time": { + "type": "object", + "properties": { + "released": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": ["NaN"] + }, + { + "type": "string", + "enum": ["Infinity"] + }, + { + "type": "string", + "enum": ["-Infinity"] + }, + { + "type": "string", + "enum": ["Infinity", "-Infinity", "NaN"] + } + ] + } + }, + "required": ["released"], + "additionalProperties": false + }, + "cost": { + "type": "array", + "items": { + "type": "object", + "properties": { + "tier": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["context"] + }, + "size": { + "type": "integer" + } + }, + "required": ["type", "size"], + "additionalProperties": false + }, + "input": { + "type": "number" + }, + "output": { + "type": "number" + }, + "cache": { + "type": "object", + "properties": { + "read": { + "type": "number" + }, + "write": { + "type": "number" + } + }, + "required": ["read", "write"], + "additionalProperties": false + } + }, + "required": ["input", "output", "cache"], + "additionalProperties": false + } + }, + "status": { + "type": "string", + "enum": ["alpha", "beta", "deprecated", "active"] + }, + "enabled": { + "type": "boolean" + }, + "limit": { + "type": "object", + "properties": { + "context": { + "type": "integer" + }, + "input": { + "type": "integer" + }, + "output": { + "type": "integer" + } + }, + "required": ["context", "output"], + "additionalProperties": false + } + }, + "required": [ + "id", + "apiID", + "providerID", + "name", + "endpoint", + "capabilities", + "options", + "variants", + "time", + "cost", + "status", + "enabled", + "limit" + ], + "additionalProperties": false + }, + "EventCatalogModelUpdated": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["catalog.model.updated"] + }, + "properties": { + "type": "object", + "properties": { + "model": { + "$ref": "#/components/schemas/ModelV2Info" + } + }, + "required": ["model"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, "SessionInfo": { "type": "object", "properties": { From b0ea7a5aa79dee01ec7d786e74456792a7014b6b Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Thu, 14 May 2026 22:45:09 -0400 Subject: [PATCH 012/164] more test fixes --- packages/opencode/src/sync/index.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/sync/index.ts b/packages/opencode/src/sync/index.ts index df9a62f314f..0a143c2ebdc 100644 --- a/packages/opencode/src/sync/index.ts +++ b/packages/opencode/src/sync/index.ts @@ -311,7 +311,8 @@ function process( const projector = projectors.get(def) if (!projector) { - throw new Error(`Projector not found for event: ${def.type}`) + if (!def.type.includes("next")) throw new Error(`Projector not found for event: ${def.type}`) + return } Database.transaction((tx) => { From f8071527245c4005a30dbb8b139e382c25b3f893 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Thu, 14 May 2026 22:52:03 -0400 Subject: [PATCH 013/164] core: fix event projector lookup to use versioned type keys Fixes a bug where projectors were stored with Definition object references as Map keys but lookups were failing due to object identity mismatches. Now uses versioned type strings as keys, ensuring events are correctly matched to their projectors during sync processing. This fixes issues where certain events would not be properly projected to the read model, causing stale or missing data in views. --- packages/opencode/src/sync/index.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/opencode/src/sync/index.ts b/packages/opencode/src/sync/index.ts index 0a143c2ebdc..d5bca45b16b 100644 --- a/packages/opencode/src/sync/index.ts +++ b/packages/opencode/src/sync/index.ts @@ -214,7 +214,7 @@ export const defaultLayer = layer.pipe(Layer.provide(RuntimeFlags.defaultLayer)) export const use = serviceUse(Service) export const registry = new Map() -let projectors: Map | undefined +let projectors: Map | undefined const versions = new Map() let frozen = false let convertEvent: ConvertEvent @@ -226,7 +226,7 @@ export function reset() { } export function init(input: { projectors: Array<[Definition, ProjectorFunc]>; convertEvent?: ConvertEvent }) { - projectors = new Map(input.projectors) + projectors = new Map(input.projectors.map(([def, func]) => [versionedType(def.type, def.version), func])) for (let entry of EventV2.registry.values()) { if (!entry.version || !entry.aggregate) continue register({ @@ -309,7 +309,7 @@ function process( throw new Error("No projectors available. Call `SyncEvent.init` to install projectors") } - const projector = projectors.get(def) + const projector = projectors.get(versionedType(def.type, def.version)) if (!projector) { if (!def.type.includes("next")) throw new Error(`Projector not found for event: ${def.type}`) return From af06e5270889b6f10bc2c32210429d3723e452dd Mon Sep 17 00:00:00 2001 From: James Long Date: Thu, 14 May 2026 23:12:30 -0400 Subject: [PATCH 014/164] fix(session): ignore instruction lookup errors (#27656) --- packages/opencode/src/session/instruction.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/session/instruction.ts b/packages/opencode/src/session/instruction.ts index 6629ce67bc9..63f0c36367f 100644 --- a/packages/opencode/src/session/instruction.ts +++ b/packages/opencode/src/session/instruction.ts @@ -118,7 +118,9 @@ export const layer: Layer.Layer< // The first project-level match wins so we don't stack AGENTS.md/CLAUDE.md from every ancestor. if (!Flag.OPENCODE_DISABLE_PROJECT_CONFIG) { for (const file of FILES) { - const matches = yield* fs.findUp(file, ctx.directory, ctx.worktree) + const matches = yield* fs + .findUp(file, ctx.directory, ctx.worktree) + .pipe(Effect.catch(() => Effect.succeed([]))) if (matches.length > 0) { matches.forEach((item) => paths.add(path.resolve(item))) break From 7a012cac08c0e198bbffb48dcfd75a4f908d75e7 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Thu, 14 May 2026 23:36:28 -0400 Subject: [PATCH 015/164] fix(tool): ignore invalid custom tool exports --- packages/opencode/src/tool/registry.ts | 7 ++++- packages/opencode/test/tool/registry.test.ts | 27 ++++++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index 56df754afe0..5869b50a2a3 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -201,7 +201,8 @@ export const layer: Layer.Layer< // `match` is an absolute filesystem path from `Glob.scanSync(..., { absolute: true })`. // Import it as `file://` so Node on Windows accepts the dynamic import. const mod = yield* Effect.promise(() => import(pathToFileURL(match).href)) - for (const [id, def] of Object.entries(mod)) { + for (const [id, def] of Object.entries(mod)) { + if (!isPluginTool(def)) continue custom.push(fromPlugin(id === "default" ? namespace : `${namespace}_${id}`, def)) } } @@ -396,6 +397,10 @@ function isZodType(value: unknown): value is z.ZodType { return typeof value === "object" && value !== null && "_zod" in value } +function isPluginTool(value: unknown): value is ToolDefinition { + return typeof value === "object" && value !== null && "args" in value && "description" in value && "execute" in value +} + function isJsonSchemaDefinition(value: unknown): value is JSONSchema7Definition { return typeof value === "boolean" || (typeof value === "object" && value !== null && !Array.isArray(value)) } diff --git a/packages/opencode/test/tool/registry.test.ts b/packages/opencode/test/tool/registry.test.ts index 3239855ca56..0a96a689cde 100644 --- a/packages/opencode/test/tool/registry.test.ts +++ b/packages/opencode/test/tool/registry.test.ts @@ -158,6 +158,33 @@ describe("tool.registry", () => { }), ) + it.instance("ignores non-tool exports in .opencode/tool files", () => + Effect.gen(function* () { + const test = yield* TestInstance + const tool = path.join(test.directory, ".opencode", "tool") + yield* Effect.promise(() => fs.mkdir(tool, { recursive: true })) + yield* Effect.promise(() => + Bun.write( + path.join(tool, "mixed.ts"), + [ + "export const helper = 'not a tool'", + "export default {", + " description: 'mixed tool',", + " args: {},", + " execute: async () => 'ok',", + "}", + "", + ].join("\n"), + ), + ) + + const registry = yield* ToolRegistry.Service + const ids = yield* registry.ids() + expect(ids).toContain("mixed") + expect(ids).not.toContain("mixed_helper") + }), + ) + it.instance("loads tools from .opencode/tools (plural)", () => Effect.gen(function* () { const test = yield* TestInstance From c43edc5b71a2b1aedd8159ed4b547edc4c97606f Mon Sep 17 00:00:00 2001 From: opencode Date: Fri, 15 May 2026 04:03:54 +0000 Subject: [PATCH 016/164] sync release versions for v1.15.0 --- bun.lock | 34 +++++++++++++------------- packages/app/package.json | 2 +- packages/console/app/package.json | 2 +- packages/console/core/package.json | 2 +- packages/console/function/package.json | 2 +- packages/console/mail/package.json | 2 +- packages/core/package.json | 2 +- packages/desktop/package.json | 2 +- packages/enterprise/package.json | 2 +- packages/extensions/zed/extension.toml | 12 ++++----- packages/function/package.json | 2 +- packages/http-recorder/package.json | 2 +- packages/llm/package.json | 2 +- packages/opencode/package.json | 2 +- packages/plugin/package.json | 2 +- packages/sdk/js/package.json | 2 +- packages/slack/package.json | 2 +- packages/ui/package.json | 2 +- packages/web/package.json | 2 +- sdks/vscode/package.json | 2 +- 20 files changed, 41 insertions(+), 41 deletions(-) diff --git a/bun.lock b/bun.lock index f1ebf9d9a2f..8fa8d02544a 100644 --- a/bun.lock +++ b/bun.lock @@ -29,7 +29,7 @@ }, "packages/app": { "name": "@opencode-ai/app", - "version": "1.14.51", + "version": "1.15.0", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/core": "workspace:*", @@ -84,7 +84,7 @@ }, "packages/console/app": { "name": "@opencode-ai/console-app", - "version": "1.14.51", + "version": "1.15.0", "dependencies": { "@cloudflare/vite-plugin": "1.15.2", "@ibm/plex": "6.4.1", @@ -119,7 +119,7 @@ }, "packages/console/core": { "name": "@opencode-ai/console-core", - "version": "1.14.51", + "version": "1.15.0", "dependencies": { "@aws-sdk/client-sts": "3.782.0", "@jsx-email/render": "1.1.1", @@ -146,7 +146,7 @@ }, "packages/console/function": { "name": "@opencode-ai/console-function", - "version": "1.14.51", + "version": "1.15.0", "dependencies": { "@ai-sdk/anthropic": "3.0.64", "@ai-sdk/openai": "3.0.48", @@ -168,7 +168,7 @@ }, "packages/console/mail": { "name": "@opencode-ai/console-mail", - "version": "1.14.51", + "version": "1.15.0", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", @@ -192,7 +192,7 @@ }, "packages/core": { "name": "@opencode-ai/core", - "version": "1.14.51", + "version": "1.15.0", "bin": { "opencode": "./bin/opencode", }, @@ -253,7 +253,7 @@ }, "packages/desktop": { "name": "@opencode-ai/desktop", - "version": "1.14.51", + "version": "1.15.0", "dependencies": { "drizzle-orm": "catalog:", "effect": "catalog:", @@ -307,7 +307,7 @@ }, "packages/enterprise": { "name": "@opencode-ai/enterprise", - "version": "1.14.51", + "version": "1.15.0", "dependencies": { "@opencode-ai/core": "workspace:*", "@opencode-ai/ui": "workspace:*", @@ -337,7 +337,7 @@ }, "packages/function": { "name": "@opencode-ai/function", - "version": "1.14.51", + "version": "1.15.0", "dependencies": { "@octokit/auth-app": "8.0.1", "@octokit/rest": "catalog:", @@ -353,7 +353,7 @@ }, "packages/http-recorder": { "name": "@opencode-ai/http-recorder", - "version": "1.14.51", + "version": "1.15.0", "dependencies": { "@effect/platform-node": "catalog:", "effect": "catalog:", @@ -366,7 +366,7 @@ }, "packages/llm": { "name": "@opencode-ai/llm", - "version": "1.14.51", + "version": "1.15.0", "dependencies": { "@smithy/eventstream-codec": "4.2.14", "@smithy/util-utf8": "4.2.2", @@ -384,7 +384,7 @@ }, "packages/opencode": { "name": "opencode", - "version": "1.14.51", + "version": "1.15.0", "bin": { "opencode": "./bin/opencode", }, @@ -520,7 +520,7 @@ }, "packages/plugin": { "name": "@opencode-ai/plugin", - "version": "1.14.51", + "version": "1.15.0", "dependencies": { "@opencode-ai/sdk": "workspace:*", "effect": "catalog:", @@ -558,7 +558,7 @@ }, "packages/sdk/js": { "name": "@opencode-ai/sdk", - "version": "1.14.51", + "version": "1.15.0", "dependencies": { "cross-spawn": "catalog:", }, @@ -573,7 +573,7 @@ }, "packages/slack": { "name": "@opencode-ai/slack", - "version": "1.14.51", + "version": "1.15.0", "dependencies": { "@opencode-ai/sdk": "workspace:*", "@slack/bolt": "^3.17.1", @@ -608,7 +608,7 @@ }, "packages/ui": { "name": "@opencode-ai/ui", - "version": "1.14.51", + "version": "1.15.0", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/core": "workspace:*", @@ -657,7 +657,7 @@ }, "packages/web": { "name": "@opencode-ai/web", - "version": "1.14.51", + "version": "1.15.0", "dependencies": { "@astrojs/cloudflare": "12.6.3", "@astrojs/markdown-remark": "6.3.1", diff --git a/packages/app/package.json b/packages/app/package.json index f20bbf406f3..5e033f263c3 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/app", - "version": "1.14.51", + "version": "1.15.0", "description": "", "type": "module", "exports": { diff --git a/packages/console/app/package.json b/packages/console/app/package.json index a84909b24c3..fbba88bea4a 100644 --- a/packages/console/app/package.json +++ b/packages/console/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-app", - "version": "1.14.51", + "version": "1.15.0", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/console/core/package.json b/packages/console/core/package.json index fe648d32510..b58e595171e 100644 --- a/packages/console/core/package.json +++ b/packages/console/core/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/console-core", - "version": "1.14.51", + "version": "1.15.0", "private": true, "type": "module", "license": "MIT", diff --git a/packages/console/function/package.json b/packages/console/function/package.json index 2ed10a8c29e..faf72baae09 100644 --- a/packages/console/function/package.json +++ b/packages/console/function/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-function", - "version": "1.14.51", + "version": "1.15.0", "$schema": "https://json.schemastore.org/package.json", "private": true, "type": "module", diff --git a/packages/console/mail/package.json b/packages/console/mail/package.json index 8f46ecbc6a2..3ef49a1b90e 100644 --- a/packages/console/mail/package.json +++ b/packages/console/mail/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-mail", - "version": "1.14.51", + "version": "1.15.0", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", diff --git a/packages/core/package.json b/packages/core/package.json index 69257ed102b..7a7c0880b6a 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.14.51", + "version": "1.15.0", "name": "@opencode-ai/core", "type": "module", "license": "MIT", diff --git a/packages/desktop/package.json b/packages/desktop/package.json index 87788a83459..522dbb5d9bf 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@opencode-ai/desktop", "private": true, - "version": "1.14.51", + "version": "1.15.0", "type": "module", "license": "MIT", "homepage": "https://opencode.ai", diff --git a/packages/enterprise/package.json b/packages/enterprise/package.json index 6ff718ee2db..2a4510bc3a0 100644 --- a/packages/enterprise/package.json +++ b/packages/enterprise/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/enterprise", - "version": "1.14.51", + "version": "1.15.0", "private": true, "type": "module", "license": "MIT", diff --git a/packages/extensions/zed/extension.toml b/packages/extensions/zed/extension.toml index 2fdd111f86f..637b93dd0af 100644 --- a/packages/extensions/zed/extension.toml +++ b/packages/extensions/zed/extension.toml @@ -1,7 +1,7 @@ id = "opencode" name = "OpenCode" description = "The open source coding agent." -version = "1.14.51" +version = "1.15.0" schema_version = 1 authors = ["Anomaly"] repository = "https://github.com/anomalyco/opencode" @@ -11,26 +11,26 @@ name = "OpenCode" icon = "./icons/opencode.svg" [agent_servers.opencode.targets.darwin-aarch64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.51/opencode-darwin-arm64.zip" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.0/opencode-darwin-arm64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.darwin-x86_64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.51/opencode-darwin-x64.zip" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.0/opencode-darwin-x64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.linux-aarch64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.51/opencode-linux-arm64.tar.gz" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.0/opencode-linux-arm64.tar.gz" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.linux-x86_64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.51/opencode-linux-x64.tar.gz" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.0/opencode-linux-x64.tar.gz" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.windows-x86_64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.51/opencode-windows-x64.zip" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.0/opencode-windows-x64.zip" cmd = "./opencode.exe" args = ["acp"] diff --git a/packages/function/package.json b/packages/function/package.json index 930ac534cf5..f588e8cf2d7 100644 --- a/packages/function/package.json +++ b/packages/function/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/function", - "version": "1.14.51", + "version": "1.15.0", "$schema": "https://json.schemastore.org/package.json", "private": true, "type": "module", diff --git a/packages/http-recorder/package.json b/packages/http-recorder/package.json index ef5ae3ad307..607b4c60d94 100644 --- a/packages/http-recorder/package.json +++ b/packages/http-recorder/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.14.51", + "version": "1.15.0", "name": "@opencode-ai/http-recorder", "type": "module", "license": "MIT", diff --git a/packages/llm/package.json b/packages/llm/package.json index a51bedfd0ab..f6258a2c68e 100644 --- a/packages/llm/package.json +++ b/packages/llm/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.14.51", + "version": "1.15.0", "name": "@opencode-ai/llm", "type": "module", "license": "MIT", diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 2412911ef9c..e3e8c1774a9 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.14.51", + "version": "1.15.0", "name": "opencode", "type": "module", "license": "MIT", diff --git a/packages/plugin/package.json b/packages/plugin/package.json index 35462626459..67055fdcd92 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/plugin", - "version": "1.14.51", + "version": "1.15.0", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/sdk/js/package.json b/packages/sdk/js/package.json index c854526bab4..0dc58c876fa 100644 --- a/packages/sdk/js/package.json +++ b/packages/sdk/js/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/sdk", - "version": "1.14.51", + "version": "1.15.0", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/slack/package.json b/packages/slack/package.json index 9b9d8659c43..efe396f5fb7 100644 --- a/packages/slack/package.json +++ b/packages/slack/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/slack", - "version": "1.14.51", + "version": "1.15.0", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/ui/package.json b/packages/ui/package.json index 39515fbb2a7..94047fae1d9 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/ui", - "version": "1.14.51", + "version": "1.15.0", "type": "module", "license": "MIT", "exports": { diff --git a/packages/web/package.json b/packages/web/package.json index 4bed593062a..f554b6d1b43 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -2,7 +2,7 @@ "name": "@opencode-ai/web", "type": "module", "license": "MIT", - "version": "1.14.51", + "version": "1.15.0", "scripts": { "dev": "astro dev", "dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev", diff --git a/sdks/vscode/package.json b/sdks/vscode/package.json index 918ccc99e93..833d10836fc 100644 --- a/sdks/vscode/package.json +++ b/sdks/vscode/package.json @@ -2,7 +2,7 @@ "name": "opencode", "displayName": "opencode", "description": "opencode for VS Code", - "version": "1.14.51", + "version": "1.15.0", "publisher": "sst-dev", "repository": { "type": "git", From d59d99665b119141e506a492d03e381937a0ca40 Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Thu, 14 May 2026 23:47:59 -0500 Subject: [PATCH 017/164] ci: Automate PR cleanup (#27667) --- .github/workflows/close-prs.yml | 50 ++++ .github/workflows/close-stale-prs.yml | 235 ----------------- script/github/close-prs.ts | 347 ++++++++++++++++++++++++++ 3 files changed, 397 insertions(+), 235 deletions(-) create mode 100644 .github/workflows/close-prs.yml delete mode 100644 .github/workflows/close-stale-prs.yml create mode 100644 script/github/close-prs.ts diff --git a/.github/workflows/close-prs.yml b/.github/workflows/close-prs.yml new file mode 100644 index 00000000000..a1e603a8810 --- /dev/null +++ b/.github/workflows/close-prs.yml @@ -0,0 +1,50 @@ +name: close-prs + +on: + schedule: + - cron: "0 22 * * *" # Daily at 10:00 PM UTC + workflow_dispatch: + inputs: + dry-run: + description: "Log matching PRs without closing them" + type: boolean + default: true + max-close: + description: "Maximum matching PRs to close" + type: string + required: false + default: "50" + +jobs: + close: + runs-on: ubuntu-latest + timeout-minutes: 240 + permissions: + contents: read + issues: write + pull-requests: write + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + bun-version: latest + + - name: Close old PRs without enough positive reactions + env: + GITHUB_TOKEN: ${{ github.token }} + run: | + max_close="${{ inputs['max-close'] }}" + if [ -z "$max_close" ]; then + max_close="50" + fi + + args=("--threshold" "2" "--age-months" "1" "--sleep-ms" "20000" "--max-close" "$max_close") + + if [ "${{ github.event_name }}" = "schedule" ]; then + args+=("--execute") + elif [ "${{ inputs['dry-run'] }}" = "false" ]; then + args+=("--execute") + fi + + bun script/github/close-prs.ts "${args[@]}" diff --git a/.github/workflows/close-stale-prs.yml b/.github/workflows/close-stale-prs.yml deleted file mode 100644 index 3a0fa4b5c7a..00000000000 --- a/.github/workflows/close-stale-prs.yml +++ /dev/null @@ -1,235 +0,0 @@ -name: close-stale-prs - -on: - workflow_dispatch: - inputs: - dryRun: - description: "Log actions without closing PRs" - type: boolean - default: false - schedule: - - cron: "0 6 * * *" - -permissions: - contents: read - issues: write - pull-requests: write - -jobs: - close-stale-prs: - runs-on: ubuntu-latest - timeout-minutes: 15 - steps: - - name: Close inactive PRs - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 - with: - github-token: ${{ secrets.GITHUB_TOKEN }} - script: | - const DAYS_INACTIVE = 60 - const MAX_RETRIES = 3 - - // Adaptive delay: fast for small batches, slower for large to respect - // GitHub's 80 content-generating requests/minute limit - const SMALL_BATCH_THRESHOLD = 10 - const SMALL_BATCH_DELAY_MS = 1000 // 1s for daily operations (≤10 PRs) - const LARGE_BATCH_DELAY_MS = 2000 // 2s for backlog (>10 PRs) = ~30 ops/min, well under 80 limit - - const startTime = Date.now() - const cutoff = new Date(Date.now() - DAYS_INACTIVE * 24 * 60 * 60 * 1000) - const { owner, repo } = context.repo - const dryRun = context.payload.inputs?.dryRun === "true" - - core.info(`Dry run mode: ${dryRun}`) - core.info(`Cutoff date: ${cutoff.toISOString()}`) - - function sleep(ms) { - return new Promise(resolve => setTimeout(resolve, ms)) - } - - async function withRetry(fn, description = 'API call') { - let lastError - for (let attempt = 0; attempt < MAX_RETRIES; attempt++) { - try { - const result = await fn() - return result - } catch (error) { - lastError = error - const isRateLimited = error.status === 403 && - (error.message?.includes('rate limit') || error.message?.includes('secondary')) - - if (!isRateLimited) { - throw error - } - - // Parse retry-after header, default to 60 seconds - const retryAfter = error.response?.headers?.['retry-after'] - ? parseInt(error.response.headers['retry-after']) - : 60 - - // Exponential backoff: retryAfter * 2^attempt - const backoffMs = retryAfter * 1000 * Math.pow(2, attempt) - - core.warning(`${description}: Rate limited (attempt ${attempt + 1}/${MAX_RETRIES}). Waiting ${backoffMs / 1000}s before retry...`) - - await sleep(backoffMs) - } - } - core.error(`${description}: Max retries (${MAX_RETRIES}) exceeded`) - throw lastError - } - - const query = ` - query($owner: String!, $repo: String!, $cursor: String) { - repository(owner: $owner, name: $repo) { - pullRequests(first: 100, states: OPEN, after: $cursor) { - pageInfo { - hasNextPage - endCursor - } - nodes { - number - title - author { - login - } - createdAt - commits(last: 1) { - nodes { - commit { - committedDate - } - } - } - comments(last: 1) { - nodes { - createdAt - } - } - reviews(last: 1) { - nodes { - createdAt - } - } - } - } - } - } - ` - - const allPrs = [] - let cursor = null - let hasNextPage = true - let pageCount = 0 - - while (hasNextPage) { - pageCount++ - core.info(`Fetching page ${pageCount} of open PRs...`) - - const result = await withRetry( - () => github.graphql(query, { owner, repo, cursor }), - `GraphQL page ${pageCount}` - ) - - allPrs.push(...result.repository.pullRequests.nodes) - hasNextPage = result.repository.pullRequests.pageInfo.hasNextPage - cursor = result.repository.pullRequests.pageInfo.endCursor - - core.info(`Page ${pageCount}: fetched ${result.repository.pullRequests.nodes.length} PRs (total: ${allPrs.length})`) - - // Delay between pagination requests (use small batch delay for reads) - if (hasNextPage) { - await sleep(SMALL_BATCH_DELAY_MS) - } - } - - core.info(`Found ${allPrs.length} open pull requests`) - - const stalePrs = allPrs.filter((pr) => { - const dates = [ - new Date(pr.createdAt), - pr.commits.nodes[0] ? new Date(pr.commits.nodes[0].commit.committedDate) : null, - pr.comments.nodes[0] ? new Date(pr.comments.nodes[0].createdAt) : null, - pr.reviews.nodes[0] ? new Date(pr.reviews.nodes[0].createdAt) : null, - ].filter((d) => d !== null) - - const lastActivity = dates.sort((a, b) => b.getTime() - a.getTime())[0] - - if (!lastActivity || lastActivity > cutoff) { - core.info(`PR #${pr.number} is fresh (last activity: ${lastActivity?.toISOString() || "unknown"})`) - return false - } - - core.info(`PR #${pr.number} is STALE (last activity: ${lastActivity.toISOString()})`) - return true - }) - - if (!stalePrs.length) { - core.info("No stale pull requests found.") - return - } - - core.info(`Found ${stalePrs.length} stale pull requests`) - - // ============================================ - // Close stale PRs - // ============================================ - const requestDelayMs = stalePrs.length > SMALL_BATCH_THRESHOLD - ? LARGE_BATCH_DELAY_MS - : SMALL_BATCH_DELAY_MS - - core.info(`Using ${requestDelayMs}ms delay between operations (${stalePrs.length > SMALL_BATCH_THRESHOLD ? 'large' : 'small'} batch mode)`) - - let closedCount = 0 - let skippedCount = 0 - - for (const pr of stalePrs) { - const issue_number = pr.number - const closeComment = `Closing this pull request because it has had no updates for more than ${DAYS_INACTIVE} days. If you plan to continue working on it, feel free to reopen or open a new PR.` - - if (dryRun) { - core.info(`[dry-run] Would close PR #${issue_number} from ${pr.author?.login || 'unknown'}: ${pr.title}`) - continue - } - - try { - // Add comment - await withRetry( - () => github.rest.issues.createComment({ - owner, - repo, - issue_number, - body: closeComment, - }), - `Comment on PR #${issue_number}` - ) - - // Close PR - await withRetry( - () => github.rest.pulls.update({ - owner, - repo, - pull_number: issue_number, - state: "closed", - }), - `Close PR #${issue_number}` - ) - - closedCount++ - core.info(`Closed PR #${issue_number} from ${pr.author?.login || 'unknown'}: ${pr.title}`) - - // Delay before processing next PR - await sleep(requestDelayMs) - } catch (error) { - skippedCount++ - core.error(`Failed to close PR #${issue_number}: ${error.message}`) - } - } - - const elapsed = Math.round((Date.now() - startTime) / 1000) - core.info(`\n========== Summary ==========`) - core.info(`Total open PRs found: ${allPrs.length}`) - core.info(`Stale PRs identified: ${stalePrs.length}`) - core.info(`PRs closed: ${closedCount}`) - core.info(`PRs skipped (errors): ${skippedCount}`) - core.info(`Elapsed time: ${elapsed}s`) - core.info(`=============================`) diff --git a/script/github/close-prs.ts b/script/github/close-prs.ts new file mode 100644 index 00000000000..9acfdaed8dd --- /dev/null +++ b/script/github/close-prs.ts @@ -0,0 +1,347 @@ +#!/usr/bin/env bun + +import { parseArgs } from "util" + +const defaultRepo = "anomalyco/opencode" +const defaultAgeMonths = 1 +const defaultThreshold = 2 +const defaultSleepMs = 20_000 +const defaultPrintLimit = 50 +const positiveReactions = new Set(["THUMBS_UP", "HEART", "HOORAY", "ROCKET"]) + +const { values } = parseArgs({ + args: Bun.argv.slice(2), + options: { + execute: { type: "boolean", default: false }, + "dry-run": { type: "boolean", default: false }, + repo: { type: "string", default: defaultRepo }, + threshold: { type: "string", default: String(defaultThreshold) }, + "age-months": { type: "string", default: String(defaultAgeMonths) }, + "max-close": { type: "string" }, + "sleep-ms": { type: "string", default: String(defaultSleepMs) }, + "print-limit": { type: "string", default: String(defaultPrintLimit) }, + help: { type: "boolean", short: "h", default: false }, + }, +}) + +if (values.help) { + console.log(` +Usage: bun script/github/close-prs.ts [options] + +Dry-run is the default. The script only comments and closes PRs when --execute is passed. + +Criteria: + - PRs created within the last month are untouched + - PRs older than one month are closed when they have fewer than 2 positive reactions + - Positive reactions are THUMBS_UP, HEART, HOORAY, and ROCKET reactions on the PR + +Options: + --execute Comment and close matching PRs + --dry-run Explicitly run without changing anything + --repo Repository to clean up (default: ${defaultRepo}) + --threshold Positive reaction threshold (default: ${defaultThreshold}) + --age-months Age cutoff in months (default: ${defaultAgeMonths}) + --max-close Maximum matching PRs to process + --sleep-ms Delay between closing PRs (default: ${defaultSleepMs}) + --print-limit Number of matching PRs to print in dry-run (default: ${defaultPrintLimit}) + -h, --help Show this help message + +Examples: + bun script/github/close-prs.ts + bun script/github/close-prs.ts --threshold 2 --print-limit 100 + bun script/github/close-prs.ts --execute --threshold 2 --max-close 25 +`) + process.exit(0) +} + +if (values.execute && values["dry-run"]) { + console.error("Use either --execute or --dry-run, not both") + process.exit(1) +} + +const token = await requireToken() +const repo = requireRepo(values.repo) +const threshold = requirePositiveInteger("threshold", values.threshold) +const ageMonths = requirePositiveInteger("age-months", values["age-months"]) +const maxClose = + values["max-close"] === undefined ? undefined : requirePositiveInteger("max-close", values["max-close"]) +const sleepMs = requireNonNegativeInteger("sleep-ms", values["sleep-ms"]) +const printLimit = requireNonNegativeInteger("print-limit", values["print-limit"]) +const cutoff = subtractMonths(new Date(), ageMonths) + +const headers = { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + Accept: "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", +} + +type PullRequest = { + number: number + title: string + url: string + createdAt: string + reactionGroups: Array<{ + content: string + users: { + totalCount: number + } + }> +} + +type GraphqlResponse = { + data?: { + rateLimit: { + cost: number + remaining: number + resetAt: string + } + repository: { + pullRequests: { + pageInfo: { + hasNextPage: boolean + endCursor: string | null + } + nodes: PullRequest[] + } + } + } + errors?: Array<{ + message: string + }> +} + +type CleanupCandidate = PullRequest & { + positiveReactions: number +} + +const message = `Automated PR Cleanup + +Thank you for contributing to opencode. + +Due to the high volume of PRs from users and AI agents, we periodically close older PRs using automated criteria so maintainers can focus review time on the most active and community-supported contributions. + +This PR was closed because it matched the following cleanup criteria: + +- The PR was created more than ${ageMonths === 1 ? "1 month" : `${ageMonths} months`} ago +- The PR had fewer than ${threshold} positive reactions +- Positive reactions are counted as thumbs-up, heart, celebration, or rocket reactions on the PR + +PRs created within the last ${ageMonths === 1 ? "month are" : `${ageMonths} months are`} not affected by this cleanup. + +If you believe this PR was closed incorrectly, or if you are still actively working on it, please leave a comment explaining why it should be reopened. A maintainer can review and reopen it if appropriate. + +Thanks again for taking the time to contribute.` + +async function main() { + console.log(`${values.execute ? "EXECUTE" : "DRY RUN"}: PR cleanup for ${repo.owner}/${repo.name}`) + console.log(`Cutoff: ${cutoff.toISOString()}`) + console.log(`Threshold: fewer than ${threshold} positive reactions`) + + const prs = await fetchOpenPullRequests() + const recentCount = prs.filter((pr) => new Date(pr.createdAt) >= cutoff).length + const candidates = prs + .map((pr) => ({ ...pr, positiveReactions: positiveReactionCount(pr) })) + .filter((pr) => new Date(pr.createdAt) < cutoff && pr.positiveReactions < threshold) + const selected = maxClose === undefined ? candidates : candidates.slice(0, maxClose) + + console.log(`Fetched ${prs.length} open PRs`) + console.log(`Matching cleanup criteria: ${candidates.length}`) + console.log(`Recent PRs untouched: ${recentCount}`) + console.log( + `Older PRs with at least ${threshold} positive reactions untouched: ${prs.length - candidates.length - recentCount}`, + ) + + if (selected.length === 0) return + + if (!values.execute) { + console.log(`\nDry-run only. Re-run with --execute to comment and close matching PRs.`) + console.log(`Showing ${Math.min(printLimit, selected.length)} of ${selected.length} matching PRs:\n`) + for (const pr of selected.slice(0, printLimit)) { + console.log(`#${pr.number} ${pr.createdAt} positive=${pr.positiveReactions} ${pr.url}`) + } + if (selected.length > printLimit) console.log(`... ${selected.length - printLimit} more not shown`) + return + } + + console.log(`\nCommenting and closing ${selected.length} PRs...`) + for (const pr of selected) { + await closePullRequest(pr) + if (sleepMs > 0) await sleep(sleepMs) + } + console.log(`Closed ${selected.length} PRs`) +} + +async function fetchOpenPullRequests() { + const prs: PullRequest[] = [] + let endCursor: string | null = null + + while (true) { + const page = await graphql({ + query: `query($owner: String!, $name: String!, $endCursor: String) { + rateLimit { + cost + remaining + resetAt + } + repository(owner: $owner, name: $name) { + pullRequests(first: 100, states: OPEN, orderBy: { field: CREATED_AT, direction: ASC }, after: $endCursor) { + pageInfo { + hasNextPage + endCursor + } + nodes { + number + title + url + createdAt + reactionGroups { + content + users { + totalCount + } + } + } + } + } + }`, + variables: { + owner: repo.owner, + name: repo.name, + endCursor, + }, + }) + + prs.push(...page.repository.pullRequests.nodes) + console.log( + `Fetched ${prs.length} PRs, GraphQL rate limit remaining ${page.rateLimit.remaining} (cost ${page.rateLimit.cost})`, + ) + + if (page.rateLimit.remaining < 100) { + const delay = Math.max(0, new Date(page.rateLimit.resetAt).getTime() - Date.now()) + 1_000 + console.warn(`GraphQL rate limit low; sleeping ${Math.ceil(delay / 1000)}s until reset`) + await sleep(delay) + } + + if (!page.repository.pullRequests.pageInfo.hasNextPage) return prs + endCursor = page.repository.pullRequests.pageInfo.endCursor + } +} + +async function graphql(input: { query: string; variables: Record }) { + const response = await githubRequest("/graphql", { + method: "POST", + body: JSON.stringify(input), + }) + const body = (await response.json()) as GraphqlResponse + if (body.errors?.length) + throw new Error(`GitHub GraphQL error: ${body.errors.map((error) => error.message).join(", ")}`) + if (!body.data) throw new Error("GitHub GraphQL response did not include data") + return body.data +} + +async function closePullRequest(pr: CleanupCandidate) { + await githubRequest(`/repos/${repo.owner}/${repo.name}/issues/${pr.number}/comments`, { + method: "POST", + body: JSON.stringify({ body: message }), + }) + await githubRequest(`/repos/${repo.owner}/${repo.name}/pulls/${pr.number}`, { + method: "PATCH", + body: JSON.stringify({ state: "closed" }), + }) + console.log(`Closed #${pr.number} positive=${pr.positiveReactions} ${pr.url}`) +} + +async function githubRequest(path: string, init: RequestInit, attempt = 0): Promise { + const response = await fetch(path.startsWith("https://") ? path : `https://api.github.com${path}`, { + ...init, + headers: { + ...headers, + ...init.headers, + }, + }) + + if (response.ok) return response + + const body = await response.text() + const retryAfter = response.headers.get("retry-after") + const reset = response.headers.get("x-ratelimit-reset") + const retryMs = retryAfter + ? Number(retryAfter) * 1000 + : response.headers.get("x-ratelimit-remaining") === "0" && reset + ? Math.max(0, Number(reset) * 1000 - Date.now()) + 1_000 + : body.toLowerCase().includes("secondary rate limit") + ? 300_000 + : 0 + + if ((response.status === 403 || response.status === 429) && retryMs > 0 && attempt < 10) { + console.warn(`GitHub rate limit hit; sleeping ${Math.ceil(retryMs / 1000)}s before retry ${attempt + 1}`) + await sleep(retryMs) + return githubRequest(path, init, attempt + 1) + } + + throw new Error(`GitHub request failed: ${response.status} ${response.statusText}\n${body}`) +} + +function positiveReactionCount(pr: PullRequest) { + return pr.reactionGroups + .filter((group) => positiveReactions.has(group.content)) + .reduce((total, group) => total + group.users.totalCount, 0) +} + +function requireRepo(value: string | undefined) { + if (!value) throw new Error("repo is required") + const [owner, name] = value.split("/") + if (!owner || !name) throw new Error(`Invalid repo ${value}; expected owner/name`) + return { owner, name } +} + +async function requireToken() { + const envToken = process.env.GITHUB_TOKEN ?? process.env.GH_TOKEN + if (envToken) return envToken + + const proc = Bun.spawn(["gh", "auth", "token"], { + stdout: "pipe", + stderr: "pipe", + }) + const stdout = await new Response(proc.stdout).text() + const stderr = await new Response(proc.stderr).text() + const exitCode = await proc.exited + if (exitCode === 0 && stdout.trim()) return stdout.trim() + + throw new Error( + `GitHub authentication is required. Set GITHUB_TOKEN/GH_TOKEN or run gh auth login.\n${stderr.trim()}`, + ) +} + +function requirePositiveInteger(name: string, value: string | undefined) { + const parsed = Number(value) + if (!Number.isInteger(parsed) || parsed <= 0) throw new Error(`${name} must be a positive integer`) + return parsed +} + +function requireNonNegativeInteger(name: string, value: string | undefined) { + const parsed = Number(value) + if (!Number.isInteger(parsed) || parsed < 0) throw new Error(`${name} must be a non-negative integer`) + return parsed +} + +function subtractMonths(date: Date, months: number) { + const result = new Date(date) + const day = result.getUTCDate() + result.setUTCDate(1) + result.setUTCMonth(result.getUTCMonth() - months) + result.setUTCDate( + Math.min(day, new Date(Date.UTC(result.getUTCFullYear(), result.getUTCMonth() + 1, 0)).getUTCDate()), + ) + return result +} + +function sleep(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)) +} + +void main().catch((error) => { + console.error("Error:", error) + process.exit(1) +}) From ca8f578f2f1bb1b8b88193dbfb7efd337e0d6a1b Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Fri, 15 May 2026 00:23:09 -0500 Subject: [PATCH 018/164] ci: skip previously cleaned PRs (#27670) --- script/github/close-prs.ts | 58 ++++++++++++++++++++++++++++++++++---- 1 file changed, 52 insertions(+), 6 deletions(-) diff --git a/script/github/close-prs.ts b/script/github/close-prs.ts index 9acfdaed8dd..0dd8953d90f 100644 --- a/script/github/close-prs.ts +++ b/script/github/close-prs.ts @@ -8,6 +8,7 @@ const defaultThreshold = 2 const defaultSleepMs = 20_000 const defaultPrintLimit = 50 const positiveReactions = new Set(["THUMBS_UP", "HEART", "HOORAY", "ROCKET"]) +const cleanupLabel = "automated-pr-cleanup" const { values } = parseArgs({ args: Bun.argv.slice(2), @@ -87,6 +88,11 @@ type PullRequest = { totalCount: number } }> + labels: { + nodes: Array<{ + name: string + }> + } } type GraphqlResponse = { @@ -140,16 +146,18 @@ async function main() { const prs = await fetchOpenPullRequests() const recentCount = prs.filter((pr) => new Date(pr.createdAt) >= cutoff).length - const candidates = prs + const matching = prs .map((pr) => ({ ...pr, positiveReactions: positiveReactionCount(pr) })) .filter((pr) => new Date(pr.createdAt) < cutoff && pr.positiveReactions < threshold) + const candidates = matching.filter((pr) => !hasPriorCleanup(pr)) const selected = maxClose === undefined ? candidates : candidates.slice(0, maxClose) console.log(`Fetched ${prs.length} open PRs`) - console.log(`Matching cleanup criteria: ${candidates.length}`) + console.log(`Matching cleanup criteria: ${matching.length}`) + console.log(`Skipped previously cleaned PRs: ${matching.length - candidates.length}`) console.log(`Recent PRs untouched: ${recentCount}`) console.log( - `Older PRs with at least ${threshold} positive reactions untouched: ${prs.length - candidates.length - recentCount}`, + `Older PRs with at least ${threshold} positive reactions untouched: ${prs.length - matching.length - recentCount}`, ) if (selected.length === 0) return @@ -164,6 +172,8 @@ async function main() { return } + await ensureCleanupLabel() + console.log(`\nCommenting and closing ${selected.length} PRs...`) for (const pr of selected) { await closePullRequest(pr) @@ -201,6 +211,11 @@ async function fetchOpenPullRequests() { totalCount } } + labels(first: 100) { + nodes { + name + } + } } } } @@ -249,9 +264,34 @@ async function closePullRequest(pr: CleanupCandidate) { method: "PATCH", body: JSON.stringify({ state: "closed" }), }) + await githubRequest(`/repos/${repo.owner}/${repo.name}/issues/${pr.number}/labels`, { + method: "POST", + body: JSON.stringify({ labels: [cleanupLabel] }), + }) console.log(`Closed #${pr.number} positive=${pr.positiveReactions} ${pr.url}`) } +async function ensureCleanupLabel() { + const response = await fetch( + `https://api.github.com/repos/${repo.owner}/${repo.name}/labels/${encodeURIComponent(cleanupLabel)}`, + { + headers, + }, + ) + if (response.ok) return + if (response.status !== 404) + throw new Error(`Failed to check cleanup label: ${response.status} ${response.statusText}`) + + await githubRequest(`/repos/${repo.owner}/${repo.name}/labels`, { + method: "POST", + body: JSON.stringify({ + name: cleanupLabel, + color: "ededed", + description: "PR was closed by automated cleanup", + }), + }) +} + async function githubRequest(path: string, init: RequestInit, attempt = 0): Promise { const response = await fetch(path.startsWith("https://") ? path : `https://api.github.com${path}`, { ...init, @@ -272,10 +312,12 @@ async function githubRequest(path: string, init: RequestInit, attempt = 0): Prom ? Math.max(0, Number(reset) * 1000 - Date.now()) + 1_000 : body.toLowerCase().includes("secondary rate limit") ? 300_000 - : 0 + : response.status >= 500 + ? Math.min(300_000, 10_000 * 2 ** attempt) + : 0 - if ((response.status === 403 || response.status === 429) && retryMs > 0 && attempt < 10) { - console.warn(`GitHub rate limit hit; sleeping ${Math.ceil(retryMs / 1000)}s before retry ${attempt + 1}`) + if ((response.status === 403 || response.status === 429 || response.status >= 500) && retryMs > 0 && attempt < 10) { + console.warn(`GitHub request failed; sleeping ${Math.ceil(retryMs / 1000)}s before retry ${attempt + 1}`) await sleep(retryMs) return githubRequest(path, init, attempt + 1) } @@ -289,6 +331,10 @@ function positiveReactionCount(pr: PullRequest) { .reduce((total, group) => total + group.users.totalCount, 0) } +function hasPriorCleanup(pr: PullRequest) { + return pr.labels.nodes.some((label) => label.name === cleanupLabel) +} + function requireRepo(value: string | undefined) { if (!value) throw new Error("repo is required") const [owner, name] = value.split("/") From 1ac3f09468354f7363e2567738c2e9eb7dcf8dd9 Mon Sep 17 00:00:00 2001 From: Kagura Date: Fri, 15 May 2026 15:34:53 +0800 Subject: [PATCH 019/164] fix(watcher): resolve symlinked .git path before subscribing (#27016) Co-authored-by: Simon Klee --- packages/opencode/src/file/watcher.ts | 9 ++-- packages/opencode/test/file/watcher.test.ts | 53 +++++++++++++++++++++ 2 files changed, 59 insertions(+), 3 deletions(-) diff --git a/packages/opencode/src/file/watcher.ts b/packages/opencode/src/file/watcher.ts index ecbf76424c5..a8b6159f9d9 100644 --- a/packages/opencode/src/file/watcher.ts +++ b/packages/opencode/src/file/watcher.ts @@ -2,7 +2,7 @@ import { Cause, Effect, Layer, Context, Schema } from "effect" // @ts-ignore import { createWrapper } from "@parcel/watcher/wrapper" import type ParcelWatcher from "@parcel/watcher" -import { readdir } from "fs/promises" +import { readdir, realpath } from "fs/promises" import path from "path" import { Bus } from "@/bus" import { BusEvent } from "@/bus/bus-event" @@ -131,8 +131,11 @@ export const layer = Layer.effect( const result = yield* git.run(["rev-parse", "--git-dir"], { cwd: ctx.worktree, }) - const vcsDir = result.exitCode === 0 ? path.resolve(ctx.worktree, result.text().trim()) : undefined - if (vcsDir && !cfgIgnores.includes(".git") && !cfgIgnores.includes(vcsDir)) { + const resolved = result.exitCode === 0 ? path.resolve(ctx.worktree, result.text().trim()) : undefined + const vcsDir = resolved + ? yield* Effect.promise(() => realpath(resolved).catch(() => resolved)) + : undefined + if (vcsDir && !cfgIgnores.includes(".git") && !cfgIgnores.includes(vcsDir) && (!resolved || !cfgIgnores.includes(resolved))) { const ignore = (yield* Effect.promise(() => readdir(vcsDir).catch(() => []))).filter( (entry) => entry !== "HEAD", ) diff --git a/packages/opencode/test/file/watcher.test.ts b/packages/opencode/test/file/watcher.test.ts index 6276e58f299..3b7ae4a61b4 100644 --- a/packages/opencode/test/file/watcher.test.ts +++ b/packages/opencode/test/file/watcher.test.ts @@ -260,4 +260,57 @@ describeWatcher("FileWatcher", () => { }), { git: true }, ) + + // Symlink support varies by platform; skip where unavailable + const describeSymlink = process.platform !== "win32" ? describe : describe.skip + + describeSymlink("symlinked .git", () => { + it.instance( + "publishes .git/HEAD events through a symlinked .git directory", + () => + Effect.gen(function* () { + const test = yield* TestInstance + const fs = yield* AppFileSystem.Service + const git = yield* Git.Service + const dir = test.directory + const actualGit = path.join(dir, "..", "tmp_actual_git_" + Math.random().toString(36).slice(2)) + + // Move .git to a sibling directory and replace with a symlink + yield* Effect.promise(() => import("fs")).pipe( + Effect.flatMap((nodeFs) => + Effect.all([ + Effect.promise(() => nodeFs.promises.rename(path.join(dir, ".git"), actualGit)), + Effect.promise(() => nodeFs.promises.symlink(actualGit, path.join(dir, ".git"))), + ]), + ), + ) + + yield* Effect.acquireRelease( + Effect.succeed(actualGit), + (p) => Effect.promise(() => import("fs").then((f) => f.promises.rm(p, { recursive: true, force: true }).catch(() => undefined))), + ) + + const head = path.join(dir, ".git", "HEAD") + const branch = `watch-${Math.random().toString(36).slice(2)}` + yield* git.run(["branch", branch], { cwd: dir }) + + yield* withWatcher( + dir, + nextUpdate( + dir, + (evt) => evt.file === path.join(actualGit, "HEAD") && evt.event !== "unlink", + fs.writeFileString(head, `ref: refs/heads/${branch}\n`), + ).pipe( + Effect.tap((evt) => + Effect.sync(() => { + expect(evt.file).toBe(path.join(actualGit, "HEAD")) + expect(["add", "change"]).toContain(evt.event) + }), + ), + ), + ) + }), + { git: true }, + ) + }) }) From 2080390ca621756632cbf8712d71f784a11d9d36 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Fri, 15 May 2026 07:36:10 +0000 Subject: [PATCH 020/164] chore: generate --- packages/opencode/src/file/watcher.ts | 11 +++++++---- packages/opencode/test/file/watcher.test.ts | 7 ++++--- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/packages/opencode/src/file/watcher.ts b/packages/opencode/src/file/watcher.ts index a8b6159f9d9..d940c7c4228 100644 --- a/packages/opencode/src/file/watcher.ts +++ b/packages/opencode/src/file/watcher.ts @@ -132,10 +132,13 @@ export const layer = Layer.effect( cwd: ctx.worktree, }) const resolved = result.exitCode === 0 ? path.resolve(ctx.worktree, result.text().trim()) : undefined - const vcsDir = resolved - ? yield* Effect.promise(() => realpath(resolved).catch(() => resolved)) - : undefined - if (vcsDir && !cfgIgnores.includes(".git") && !cfgIgnores.includes(vcsDir) && (!resolved || !cfgIgnores.includes(resolved))) { + const vcsDir = resolved ? yield* Effect.promise(() => realpath(resolved).catch(() => resolved)) : undefined + if ( + vcsDir && + !cfgIgnores.includes(".git") && + !cfgIgnores.includes(vcsDir) && + (!resolved || !cfgIgnores.includes(resolved)) + ) { const ignore = (yield* Effect.promise(() => readdir(vcsDir).catch(() => []))).filter( (entry) => entry !== "HEAD", ) diff --git a/packages/opencode/test/file/watcher.test.ts b/packages/opencode/test/file/watcher.test.ts index 3b7ae4a61b4..be56ad9f23b 100644 --- a/packages/opencode/test/file/watcher.test.ts +++ b/packages/opencode/test/file/watcher.test.ts @@ -285,9 +285,10 @@ describeWatcher("FileWatcher", () => { ), ) - yield* Effect.acquireRelease( - Effect.succeed(actualGit), - (p) => Effect.promise(() => import("fs").then((f) => f.promises.rm(p, { recursive: true, force: true }).catch(() => undefined))), + yield* Effect.acquireRelease(Effect.succeed(actualGit), (p) => + Effect.promise(() => + import("fs").then((f) => f.promises.rm(p, { recursive: true, force: true }).catch(() => undefined)), + ), ) const head = path.join(dir, ".git", "HEAD") From 2d6bedecd4c1d17818e0be8764a663efd77948df Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Fri, 15 May 2026 13:07:35 +0530 Subject: [PATCH 021/164] refactor(flags): migrate output token max to runtime flags (#27680) --- packages/core/src/flag/flag.ts | 8 ----- packages/opencode/src/effect/runtime-flags.ts | 1 + packages/opencode/src/provider/transform.ts | 7 ++--- packages/opencode/src/session/compaction.ts | 7 ++++- packages/opencode/src/session/llm.ts | 2 +- packages/opencode/src/session/overflow.ts | 14 ++++++--- .../test/effect/runtime-flags.test.ts | 31 +++++++++++++++++++ 7 files changed, 52 insertions(+), 18 deletions(-) diff --git a/packages/core/src/flag/flag.ts b/packages/core/src/flag/flag.ts index d1480179a17..d08b2a19b3a 100644 --- a/packages/core/src/flag/flag.ts +++ b/packages/core/src/flag/flag.ts @@ -5,13 +5,6 @@ function truthy(key: string) { return value === "true" || value === "1" } -function number(key: string) { - const value = process.env[key] - if (!value) return undefined - const parsed = Number(value) - return Number.isInteger(parsed) && parsed > 0 ? parsed : undefined -} - const OPENCODE_EXPERIMENTAL = truthy("OPENCODE_EXPERIMENTAL") const OPENCODE_DISABLE_CLAUDE_CODE = truthy("OPENCODE_DISABLE_CLAUDE_CODE") const copy = process.env["OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT"] @@ -54,7 +47,6 @@ export const Flag = { OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT: copy === undefined ? process.platform === "win32" : truthy("OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT"), OPENCODE_ENABLE_EXA: truthy("OPENCODE_ENABLE_EXA") || OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_EXA"), - OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX: number("OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX"), OPENCODE_EXPERIMENTAL_LSP_TOOL: OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_LSP_TOOL"), OPENCODE_EXPERIMENTAL_PLAN_MODE: OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_PLAN_MODE"), OPENCODE_EXPERIMENTAL_SCOUT: OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_SCOUT"), diff --git a/packages/opencode/src/effect/runtime-flags.ts b/packages/opencode/src/effect/runtime-flags.ts index f4774d156bb..7ddc7521fb1 100644 --- a/packages/opencode/src/effect/runtime-flags.ts +++ b/packages/opencode/src/effect/runtime-flags.ts @@ -41,6 +41,7 @@ export class Service extends ConfigService.Service()("@opencode/Runtime experimentalEventSystem: enabledByExperimental("OPENCODE_EXPERIMENTAL_EVENT_SYSTEM"), experimentalWorkspaces: enabledByExperimental("OPENCODE_EXPERIMENTAL_WORKSPACES"), experimentalIconDiscovery: enabledByExperimental("OPENCODE_EXPERIMENTAL_ICON_DISCOVERY"), + outputTokenMax: positiveInteger("OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX"), bashDefaultTimeoutMs: positiveInteger("OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS"), client: Config.string("OPENCODE_CLIENT").pipe(Config.withDefault("cli")), }) {} diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index 56a35d9af1b..c8dbe611705 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -4,7 +4,6 @@ import type { JSONSchema7 } from "@ai-sdk/provider" import type * as Provider from "./provider" import type * as ModelsDev from "@opencode-ai/core/models" import { iife } from "@/util/iife" -import { Flag } from "@opencode-ai/core/flag/flag" type Modality = NonNullable["input"][number] @@ -16,7 +15,7 @@ function mimeToModality(mime: string): Modality | undefined { return undefined } -export const OUTPUT_TOKEN_MAX = Flag.OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX || 32_000 +export const OUTPUT_TOKEN_MAX = 32_000 export function sanitizeSurrogates(content: string) { return content.replace(/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(? { expect(flags.enableExa).toBe(false) expect(flags.experimentalIconDiscovery).toBe(false) expect(flags.experimentalOxfmt).toBe(false) + expect(flags.outputTokenMax).toBeUndefined() expect(flags.bashDefaultTimeoutMs).toBe(1_000) expect(flags.enableExperimentalModels).toBe(false) expect(flags.client).toBe("cli") @@ -183,6 +184,35 @@ describe("RuntimeFlags", () => { ) } + for (const input of [ + { name: "absent", config: {}, expected: undefined }, + { + name: "valid positive integer", + config: { OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX: "1234" }, + expected: 1234, + }, + { + name: "invalid string", + config: { OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX: "nope" }, + expected: undefined, + }, + { name: "zero", config: { OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX: "0" }, expected: undefined }, + { name: "negative", config: { OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX: "-1" }, expected: undefined }, + { + name: "non-integer", + config: { OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX: "1.5" }, + expected: undefined, + }, + ]) { + it.effect(`parses outputTokenMax from config: ${input.name}`, () => + Effect.gen(function* () { + const flags = yield* readFlags.pipe(Effect.provide(fromConfig(input.config))) + + expect(flags.outputTokenMax).toBe(input.expected) + }), + ) + } + it.effect("layer ignores the active ConfigProvider for omitted test overrides", () => Effect.gen(function* () { const flags = yield* readFlags.pipe( @@ -209,6 +239,7 @@ describe("RuntimeFlags", () => { expect(flags.enableExa).toBe(false) expect(flags.experimentalIconDiscovery).toBe(false) expect(flags.experimentalOxfmt).toBe(false) + expect(flags.outputTokenMax).toBeUndefined() expect(flags.bashDefaultTimeoutMs).toBeUndefined() expect(flags.client).toBe("cli") }), From 22cb0395e2f601923a44a6d2093493de7eb1cfce Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Fri, 15 May 2026 13:24:56 +0530 Subject: [PATCH 022/164] refactor(flags): migrate external skills flag (#27685) --- packages/core/src/flag/flag.ts | 1 - packages/opencode/src/effect/runtime-flags.ts | 1 + packages/opencode/src/skill/index.ts | 5 +- .../test/effect/runtime-flags.test.ts | 21 +++++++ packages/opencode/test/skill/skill.test.ts | 60 +++++++++++++++++++ 5 files changed, 85 insertions(+), 3 deletions(-) diff --git a/packages/core/src/flag/flag.ts b/packages/core/src/flag/flag.ts index d08b2a19b3a..dcb7a1528f2 100644 --- a/packages/core/src/flag/flag.ts +++ b/packages/core/src/flag/flag.ts @@ -30,7 +30,6 @@ export const Flag = { OPENCODE_DISABLE_MOUSE: truthy("OPENCODE_DISABLE_MOUSE"), OPENCODE_DISABLE_CLAUDE_CODE, OPENCODE_DISABLE_CLAUDE_CODE_PROMPT: OPENCODE_DISABLE_CLAUDE_CODE || truthy("OPENCODE_DISABLE_CLAUDE_CODE_PROMPT"), - OPENCODE_DISABLE_EXTERNAL_SKILLS: truthy("OPENCODE_DISABLE_EXTERNAL_SKILLS"), OPENCODE_FAKE_VCS: process.env["OPENCODE_FAKE_VCS"], OPENCODE_SERVER_PASSWORD: process.env["OPENCODE_SERVER_PASSWORD"], OPENCODE_SERVER_USERNAME: process.env["OPENCODE_SERVER_USERNAME"], diff --git a/packages/opencode/src/effect/runtime-flags.ts b/packages/opencode/src/effect/runtime-flags.ts index 7ddc7521fb1..50dd6d2f9a8 100644 --- a/packages/opencode/src/effect/runtime-flags.ts +++ b/packages/opencode/src/effect/runtime-flags.ts @@ -17,6 +17,7 @@ export class Service extends ConfigService.Service()("@opencode/Runtime disableDefaultPlugins: bool("OPENCODE_DISABLE_DEFAULT_PLUGINS"), disableChannelDb: bool("OPENCODE_DISABLE_CHANNEL_DB"), disableEmbeddedWebUi: bool("OPENCODE_DISABLE_EMBEDDED_WEB_UI"), + disableExternalSkills: bool("OPENCODE_DISABLE_EXTERNAL_SKILLS"), disableClaudeCodeSkills: Config.all({ broad: bool("OPENCODE_DISABLE_CLAUDE_CODE"), direct: bool("OPENCODE_DISABLE_CLAUDE_CODE_SKILLS"), diff --git a/packages/opencode/src/skill/index.ts b/packages/opencode/src/skill/index.ts index 7347707949f..c2830ee06d1 100644 --- a/packages/opencode/src/skill/index.ts +++ b/packages/opencode/src/skill/index.ts @@ -5,7 +5,6 @@ import { NamedError } from "@opencode-ai/core/util/error" import type { Agent } from "@/agent/agent" import { Bus } from "@/bus" import { InstanceState } from "@/effect/instance-state" -import { Flag } from "@opencode-ai/core/flag/flag" import { Global } from "@opencode-ai/core/global" import { Permission } from "@/permission" import { AppFileSystem } from "@opencode-ai/core/filesystem" @@ -166,6 +165,7 @@ const discoverSkills = Effect.fnUntraced(function* ( discovery: Discovery.Interface, fsys: AppFileSystem.Interface, global: Global.Interface, + disableExternalSkills: boolean, disableClaudeCodeSkills: boolean, directory: string, worktree: string, @@ -173,7 +173,7 @@ const discoverSkills = Effect.fnUntraced(function* ( const state: ScanState = { matches: new Set(), dirs: new Set() } const externalDirs: string[] = [] - if (!Flag.OPENCODE_DISABLE_EXTERNAL_SKILLS) { + if (!disableExternalSkills) { if (!disableClaudeCodeSkills) externalDirs.push(CLAUDE_EXTERNAL_DIR) externalDirs.push(AGENTS_EXTERNAL_DIR) @@ -249,6 +249,7 @@ export const layer = Layer.effect( discovery, fsys, global, + flags.disableExternalSkills, flags.disableClaudeCodeSkills, ctx.directory, ctx.worktree, diff --git a/packages/opencode/test/effect/runtime-flags.test.ts b/packages/opencode/test/effect/runtime-flags.test.ts index 3fd058b3dfd..8d265cdacad 100644 --- a/packages/opencode/test/effect/runtime-flags.test.ts +++ b/packages/opencode/test/effect/runtime-flags.test.ts @@ -27,6 +27,7 @@ describe("RuntimeFlags", () => { OPENCODE_DISABLE_CHANNEL_DB: "true", OPENCODE_AUTO_SHARE: "true", OPENCODE_DISABLE_EMBEDDED_WEB_UI: "true", + OPENCODE_DISABLE_EXTERNAL_SKILLS: "true", OPENCODE_EXPERIMENTAL: "true", OPENCODE_ENABLE_EXA: "true", OPENCODE_ENABLE_PARALLEL: "true", @@ -42,6 +43,7 @@ describe("RuntimeFlags", () => { expect(flags.disableDefaultPlugins).toBe(true) expect(flags.disableChannelDb).toBe(true) expect(flags.disableEmbeddedWebUi).toBe(true) + expect(flags.disableExternalSkills).toBe(true) expect(flags.enableExa).toBe(true) expect(flags.enableParallel).toBe(true) expect(flags.enableExperimentalModels).toBe(true) @@ -84,6 +86,7 @@ describe("RuntimeFlags", () => { expect(flags.disableDefaultPlugins).toBe(true) expect(flags.disableChannelDb).toBe(false) expect(flags.disableEmbeddedWebUi).toBe(false) + expect(flags.disableExternalSkills).toBe(false) expect(flags.disableClaudeCodeSkills).toBe(false) expect(flags.enableExa).toBe(false) expect(flags.experimentalIconDiscovery).toBe(false) @@ -103,6 +106,22 @@ describe("RuntimeFlags", () => { }), ) + it.effect("disableExternalSkills defaults to false", () => + Effect.gen(function* () { + const flags = yield* readFlags.pipe(Effect.provide(fromConfig({}))) + + expect(flags.disableExternalSkills).toBe(false) + }), + ) + + it.effect("disableExternalSkills reads OPENCODE_DISABLE_EXTERNAL_SKILLS", () => + Effect.gen(function* () { + const flags = yield* readFlags.pipe(Effect.provide(fromConfig({ OPENCODE_DISABLE_EXTERNAL_SKILLS: "true" }))) + + expect(flags.disableExternalSkills).toBe(true) + }), + ) + it.effect("experimentalIconDiscovery reads OPENCODE_EXPERIMENTAL_ICON_DISCOVERY", () => Effect.gen(function* () { const flags = yield* readFlags.pipe(Effect.provide(fromConfig({ OPENCODE_EXPERIMENTAL_ICON_DISCOVERY: "true" }))) @@ -222,6 +241,7 @@ describe("RuntimeFlags", () => { ConfigProvider.fromUnknown({ OPENCODE_PURE: "true", OPENCODE_DISABLE_DEFAULT_PLUGINS: "true", + OPENCODE_DISABLE_EXTERNAL_SKILLS: "true", OPENCODE_EXPERIMENTAL: "true", OPENCODE_ENABLE_EXA: "true", OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS: "1234", @@ -235,6 +255,7 @@ describe("RuntimeFlags", () => { expect(flags.disableDefaultPlugins).toBe(false) expect(flags.disableChannelDb).toBe(false) expect(flags.disableEmbeddedWebUi).toBe(false) + expect(flags.disableExternalSkills).toBe(false) expect(flags.disableClaudeCodeSkills).toBe(false) expect(flags.enableExa).toBe(false) expect(flags.experimentalIconDiscovery).toBe(false) diff --git a/packages/opencode/test/skill/skill.test.ts b/packages/opencode/test/skill/skill.test.ts index bc955728bb3..149cad1f2db 100644 --- a/packages/opencode/test/skill/skill.test.ts +++ b/packages/opencode/test/skill/skill.test.ts @@ -29,6 +29,19 @@ const itWithoutClaudeCodeSkills = testEffect( node, ), ) +const itWithoutExternalSkills = testEffect( + Layer.mergeAll( + Skill.layer.pipe( + Layer.provide(Discovery.defaultLayer), + Layer.provide(Config.defaultLayer), + Layer.provide(Bus.layer), + Layer.provide(AppFileSystem.defaultLayer), + Layer.provide(Global.layer), + Layer.provide(RuntimeFlags.layer({ disableExternalSkills: true })), + ), + node, + ), +) async function createGlobalSkill(homeDir: string) { const skillDir = path.join(homeDir, ".claude", "skills", "global-test-skill") @@ -420,6 +433,53 @@ description: A skill in the .agents/skills directory. ), ) + itWithoutExternalSkills.live("skips external skill directories when disabled", () => + provideTmpdirInstance( + (dir) => + Effect.gen(function* () { + yield* Effect.promise(() => + Promise.all([ + Bun.write( + path.join(dir, ".claude", "skills", "claude-skill", "SKILL.md"), + `--- +name: claude-skill +description: A skill in the .claude/skills directory. +--- + +# Claude Skill +`, + ), + Bun.write( + path.join(dir, ".agents", "skills", "agent-skill", "SKILL.md"), + `--- +name: agent-skill +description: A skill in the .agents/skills directory. +--- + +# Agent Skill +`, + ), + Bun.write( + path.join(dir, ".opencode", "skill", "opencode-skill", "SKILL.md"), + `--- +name: opencode-skill +description: A skill in the .opencode/skill directory. +--- + +# OpenCode Skill +`, + ), + ]), + ) + + const skill = yield* Skill.Service + const list = (yield* skill.all()).filter((s) => s.location !== "") + expect(list.map((s) => s.name)).toEqual(["opencode-skill"]) + }), + { git: true }, + ), + ) + it.live("properly resolves directories that skills live in", () => provideTmpdirInstance( (dir) => From 202cc863b4896d4b2050b644804f63375f2dfe5c Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Fri, 15 May 2026 14:17:04 +0530 Subject: [PATCH 023/164] refactor(flags): migrate claude code prompt flag (#27690) --- packages/core/src/flag/flag.ts | 1 - packages/opencode/src/effect/runtime-flags.ts | 4 +++ packages/opencode/src/session/instruction.ts | 16 ++++++----- .../test/effect/runtime-flags.test.ts | 27 +++++++++++++++++++ .../opencode/test/session/instruction.test.ts | 26 +++++++++++++++--- 5 files changed, 64 insertions(+), 10 deletions(-) diff --git a/packages/core/src/flag/flag.ts b/packages/core/src/flag/flag.ts index dcb7a1528f2..c7be32dd7f5 100644 --- a/packages/core/src/flag/flag.ts +++ b/packages/core/src/flag/flag.ts @@ -29,7 +29,6 @@ export const Flag = { OPENCODE_DISABLE_MODELS_FETCH: truthy("OPENCODE_DISABLE_MODELS_FETCH"), OPENCODE_DISABLE_MOUSE: truthy("OPENCODE_DISABLE_MOUSE"), OPENCODE_DISABLE_CLAUDE_CODE, - OPENCODE_DISABLE_CLAUDE_CODE_PROMPT: OPENCODE_DISABLE_CLAUDE_CODE || truthy("OPENCODE_DISABLE_CLAUDE_CODE_PROMPT"), OPENCODE_FAKE_VCS: process.env["OPENCODE_FAKE_VCS"], OPENCODE_SERVER_PASSWORD: process.env["OPENCODE_SERVER_PASSWORD"], OPENCODE_SERVER_USERNAME: process.env["OPENCODE_SERVER_USERNAME"], diff --git a/packages/opencode/src/effect/runtime-flags.ts b/packages/opencode/src/effect/runtime-flags.ts index 50dd6d2f9a8..27ea8489587 100644 --- a/packages/opencode/src/effect/runtime-flags.ts +++ b/packages/opencode/src/effect/runtime-flags.ts @@ -18,6 +18,10 @@ export class Service extends ConfigService.Service()("@opencode/Runtime disableChannelDb: bool("OPENCODE_DISABLE_CHANNEL_DB"), disableEmbeddedWebUi: bool("OPENCODE_DISABLE_EMBEDDED_WEB_UI"), disableExternalSkills: bool("OPENCODE_DISABLE_EXTERNAL_SKILLS"), + disableClaudeCodePrompt: Config.all({ + broad: bool("OPENCODE_DISABLE_CLAUDE_CODE"), + direct: bool("OPENCODE_DISABLE_CLAUDE_CODE_PROMPT"), + }).pipe(Config.map((flags) => flags.broad || flags.direct)), disableClaudeCodeSkills: Config.all({ broad: bool("OPENCODE_DISABLE_CLAUDE_CODE"), direct: bool("OPENCODE_DISABLE_CLAUDE_CODE_SKILLS"), diff --git a/packages/opencode/src/session/instruction.ts b/packages/opencode/src/session/instruction.ts index 63f0c36367f..ad9a74445b9 100644 --- a/packages/opencode/src/session/instruction.ts +++ b/packages/opencode/src/session/instruction.ts @@ -3,6 +3,7 @@ import { Effect, Layer, Context } from "effect" import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http" import { Config } from "@/config/config" import { InstanceState } from "@/effect/instance-state" +import { RuntimeFlags } from "@/effect/runtime-flags" import { Flag } from "@opencode-ai/core/flag/flag" import { AppFileSystem } from "@opencode-ai/core/filesystem" import { withTransientReadRetry } from "@/util/effect-http-client" @@ -10,9 +11,9 @@ import { Global } from "@opencode-ai/core/global" import type { MessageV2 } from "./message-v2" import type { MessageID } from "./schema" -const FILES = [ +const files = (disableClaudeCodePrompt: boolean) => [ "AGENTS.md", - ...(Flag.OPENCODE_DISABLE_CLAUDE_CODE_PROMPT ? [] : ["CLAUDE.md"]), + ...(disableClaudeCodePrompt ? [] : ["CLAUDE.md"]), "CONTEXT.md", // deprecated ] @@ -50,18 +51,20 @@ export class Service extends Context.Service()("@opencode/In export const layer: Layer.Layer< Service, never, - AppFileSystem.Service | Config.Service | Global.Service | HttpClient.HttpClient + AppFileSystem.Service | Config.Service | Global.Service | HttpClient.HttpClient | RuntimeFlags.Service > = Layer.effect( Service, Effect.gen(function* () { const cfg = yield* Config.Service const fs = yield* AppFileSystem.Service const global = yield* Global.Service + const flags = yield* RuntimeFlags.Service const http = HttpClient.filterStatusOk(withTransientReadRetry(yield* HttpClient.HttpClient)) const globalFiles = [ path.join(global.config, "AGENTS.md"), - ...(!Flag.OPENCODE_DISABLE_CLAUDE_CODE_PROMPT ? [path.join(global.home, ".claude", "CLAUDE.md")] : []), + ...(!flags.disableClaudeCodePrompt ? [path.join(global.home, ".claude", "CLAUDE.md")] : []), ] + const instructionFiles = files(flags.disableClaudeCodePrompt) const state = yield* InstanceState.make( Effect.fn("Instruction.state")(() => @@ -117,7 +120,7 @@ export const layer: Layer.Layer< // The first project-level match wins so we don't stack AGENTS.md/CLAUDE.md from every ancestor. if (!Flag.OPENCODE_DISABLE_PROJECT_CONFIG) { - for (const file of FILES) { + for (const file of instructionFiles) { const matches = yield* fs .findUp(file, ctx.directory, ctx.worktree) .pipe(Effect.catch(() => Effect.succeed([]))) @@ -165,7 +168,7 @@ export const layer: Layer.Layer< }) const find = Effect.fn("Instruction.find")(function* (dir: string) { - for (const file of FILES) { + for (const file of instructionFiles) { const filepath = path.resolve(path.join(dir, file)) if (yield* fs.existsSafe(filepath)) return filepath } @@ -225,6 +228,7 @@ export const defaultLayer = layer.pipe( Layer.provide(Global.layer), Layer.provide(AppFileSystem.defaultLayer), Layer.provide(FetchHttpClient.layer), + Layer.provide(RuntimeFlags.defaultLayer), ) export function loaded(messages: MessageV2.WithParts[]) { diff --git a/packages/opencode/test/effect/runtime-flags.test.ts b/packages/opencode/test/effect/runtime-flags.test.ts index 8d265cdacad..41d158652f6 100644 --- a/packages/opencode/test/effect/runtime-flags.test.ts +++ b/packages/opencode/test/effect/runtime-flags.test.ts @@ -44,6 +44,7 @@ describe("RuntimeFlags", () => { expect(flags.disableChannelDb).toBe(true) expect(flags.disableEmbeddedWebUi).toBe(true) expect(flags.disableExternalSkills).toBe(true) + expect(flags.disableClaudeCodePrompt).toBe(false) expect(flags.enableExa).toBe(true) expect(flags.enableParallel).toBe(true) expect(flags.enableExperimentalModels).toBe(true) @@ -87,6 +88,7 @@ describe("RuntimeFlags", () => { expect(flags.disableChannelDb).toBe(false) expect(flags.disableEmbeddedWebUi).toBe(false) expect(flags.disableExternalSkills).toBe(false) + expect(flags.disableClaudeCodePrompt).toBe(false) expect(flags.disableClaudeCodeSkills).toBe(false) expect(flags.enableExa).toBe(false) expect(flags.experimentalIconDiscovery).toBe(false) @@ -122,6 +124,30 @@ describe("RuntimeFlags", () => { }), ) + it.effect("disableClaudeCodePrompt defaults to false", () => + Effect.gen(function* () { + const flags = yield* readFlags.pipe(Effect.provide(fromConfig({}))) + + expect(flags.disableClaudeCodePrompt).toBe(false) + }), + ) + + it.effect("disableClaudeCodePrompt reads OPENCODE_DISABLE_CLAUDE_CODE_PROMPT", () => + Effect.gen(function* () { + const flags = yield* readFlags.pipe(Effect.provide(fromConfig({ OPENCODE_DISABLE_CLAUDE_CODE_PROMPT: "true" }))) + + expect(flags.disableClaudeCodePrompt).toBe(true) + }), + ) + + it.effect("disableClaudeCodePrompt inherits OPENCODE_DISABLE_CLAUDE_CODE", () => + Effect.gen(function* () { + const flags = yield* readFlags.pipe(Effect.provide(fromConfig({ OPENCODE_DISABLE_CLAUDE_CODE: "true" }))) + + expect(flags.disableClaudeCodePrompt).toBe(true) + }), + ) + it.effect("experimentalIconDiscovery reads OPENCODE_EXPERIMENTAL_ICON_DISCOVERY", () => Effect.gen(function* () { const flags = yield* readFlags.pipe(Effect.provide(fromConfig({ OPENCODE_EXPERIMENTAL_ICON_DISCOVERY: "true" }))) @@ -256,6 +282,7 @@ describe("RuntimeFlags", () => { expect(flags.disableChannelDb).toBe(false) expect(flags.disableEmbeddedWebUi).toBe(false) expect(flags.disableExternalSkills).toBe(false) + expect(flags.disableClaudeCodePrompt).toBe(false) expect(flags.disableClaudeCodeSkills).toBe(false) expect(flags.enableExa).toBe(false) expect(flags.experimentalIconDiscovery).toBe(false) diff --git a/packages/opencode/test/session/instruction.test.ts b/packages/opencode/test/session/instruction.test.ts index 5d40933954e..0f9c340dd4c 100644 --- a/packages/opencode/test/session/instruction.test.ts +++ b/packages/opencode/test/session/instruction.test.ts @@ -10,6 +10,7 @@ import { Instruction } from "../../src/session/instruction" import type { MessageV2 } from "../../src/session/message-v2" import { MessageID, PartID, SessionID } from "../../src/session/schema" import { Global } from "@opencode-ai/core/global" +import { RuntimeFlags } from "../../src/effect/runtime-flags" import { provideInstance, provideTmpdirInstance, tmpdirScoped } from "../fixture/fixture" import { testEffect } from "../lib/effect" import { TestConfig } from "../fixture/config" @@ -18,18 +19,19 @@ const it = testEffect(Layer.mergeAll(CrossSpawnSpawner.defaultLayer, NodeFileSys const configLayer = TestConfig.layer() -const instructionLayer = (global: Partial) => +const instructionLayer = (global: Partial, flags: Partial = {}) => Instruction.layer.pipe( Layer.provide(configLayer), Layer.provide(AppFileSystem.defaultLayer), Layer.provide(FetchHttpClient.layer), Layer.provide(Global.layerWith(global)), + Layer.provide(RuntimeFlags.layer(flags)), ) const provideInstruction = - (global: Partial) => + (global: Partial, flags?: Partial) => (self: Effect.Effect) => - self.pipe(Effect.provide(instructionLayer(global))) + self.pipe(Effect.provide(instructionLayer(global, flags))) const write = (filepath: string, content: string) => Effect.gen(function* () { @@ -215,6 +217,24 @@ describe("Instruction.system", () => { }).pipe(provideInstance(projectTmp), provideInstruction({ home: globalTmp, config: globalTmp })) }), ) + + it.live("skips project and global CLAUDE.md when Claude Code prompt is disabled", () => + Effect.gen(function* () { + const globalTmp = yield* tmpWithFiles({ ".claude/CLAUDE.md": "# Global Claude" }) + const projectTmp = yield* tmpWithFiles({ "CLAUDE.md": "# Project Claude" }) + + yield* Effect.gen(function* () { + const svc = yield* Instruction.Service + const paths = yield* svc.systemPaths() + expect(paths.has(path.join(globalTmp, ".claude", "CLAUDE.md"))).toBe(false) + expect(paths.has(path.join(projectTmp, "CLAUDE.md"))).toBe(false) + expect(yield* svc.system()).toEqual([]) + }).pipe( + provideInstance(projectTmp), + provideInstruction({ home: globalTmp, config: globalTmp }, { disableClaudeCodePrompt: true }), + ) + }), + ) }) describe("Instruction.systemPaths global config", () => { From 7b370406a977c5b23128babe6373848103df9742 Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Fri, 15 May 2026 14:35:31 +0530 Subject: [PATCH 024/164] refactor(flags): migrate lsp download flag (#27699) --- packages/core/src/flag/flag.ts | 1 - packages/opencode/src/effect/runtime-flags.ts | 1 + packages/opencode/src/lsp/server.ts | 101 +++++++++--------- .../test/effect/runtime-flags.test.ts | 21 ++++ packages/opencode/test/lsp/index.test.ts | 30 ++++++ 5 files changed, 102 insertions(+), 52 deletions(-) diff --git a/packages/core/src/flag/flag.ts b/packages/core/src/flag/flag.ts index c7be32dd7f5..97d9cd5dabd 100644 --- a/packages/core/src/flag/flag.ts +++ b/packages/core/src/flag/flag.ts @@ -24,7 +24,6 @@ export const Flag = { OPENCODE_SHOW_TTFD: truthy("OPENCODE_SHOW_TTFD"), OPENCODE_PERMISSION: process.env["OPENCODE_PERMISSION"], OPENCODE_DISABLE_DEFAULT_PLUGINS: truthy("OPENCODE_DISABLE_DEFAULT_PLUGINS"), - OPENCODE_DISABLE_LSP_DOWNLOAD: truthy("OPENCODE_DISABLE_LSP_DOWNLOAD"), OPENCODE_DISABLE_AUTOCOMPACT: truthy("OPENCODE_DISABLE_AUTOCOMPACT"), OPENCODE_DISABLE_MODELS_FETCH: truthy("OPENCODE_DISABLE_MODELS_FETCH"), OPENCODE_DISABLE_MOUSE: truthy("OPENCODE_DISABLE_MOUSE"), diff --git a/packages/opencode/src/effect/runtime-flags.ts b/packages/opencode/src/effect/runtime-flags.ts index 27ea8489587..20e7d249637 100644 --- a/packages/opencode/src/effect/runtime-flags.ts +++ b/packages/opencode/src/effect/runtime-flags.ts @@ -18,6 +18,7 @@ export class Service extends ConfigService.Service()("@opencode/Runtime disableChannelDb: bool("OPENCODE_DISABLE_CHANNEL_DB"), disableEmbeddedWebUi: bool("OPENCODE_DISABLE_EMBEDDED_WEB_UI"), disableExternalSkills: bool("OPENCODE_DISABLE_EXTERNAL_SKILLS"), + disableLspDownload: bool("OPENCODE_DISABLE_LSP_DOWNLOAD"), disableClaudeCodePrompt: Config.all({ broad: bool("OPENCODE_DISABLE_CLAUDE_CODE"), direct: bool("OPENCODE_DISABLE_CLAUDE_CODE_PROMPT"), diff --git a/packages/opencode/src/lsp/server.ts b/packages/opencode/src/lsp/server.ts index 454fbc1dbd2..7bda06f0df1 100644 --- a/packages/opencode/src/lsp/server.ts +++ b/packages/opencode/src/lsp/server.ts @@ -7,7 +7,6 @@ import { text } from "node:stream/consumers" import fs from "fs/promises" import { Filesystem } from "@/util/filesystem" import type { InstanceContext } from "../project/instance" -import { Flag } from "@opencode-ai/core/flag/flag" import { Archive } from "@/util/archive" import { Process } from "@/util/process" import { which } from "../util/which" @@ -126,11 +125,11 @@ export const Vue: Info = { id: "vue", extensions: [".vue"], root: NearestRoot(["package-lock.json", "bun.lockb", "bun.lock", "pnpm-lock.yaml", "yarn.lock"]), - async spawn(root) { + async spawn(root, _ctx, flags) { let binary = which("vue-language-server") const args: string[] = [] if (!binary) { - if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return + if (flags.disableLspDownload) return const resolved = await Npm.which("@vue/language-server") if (!resolved) return binary = resolved @@ -155,13 +154,13 @@ export const ESLint: Info = { id: "eslint", root: NearestRoot(["package-lock.json", "bun.lockb", "bun.lock", "pnpm-lock.yaml", "yarn.lock"]), extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts", ".vue"], - async spawn(root, ctx) { + async spawn(root, ctx, flags) { const eslint = Module.resolve("eslint", ctx.directory) if (!eslint) return log.info("spawning eslint server") const serverPath = path.join(Global.Path.bin, "vscode-eslint", "server", "out", "eslintServer.js") if (!(await Filesystem.exists(serverPath))) { - if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return + if (flags.disableLspDownload) return log.info("downloading and building VS Code ESLint server") const response = await fetch("https://github.com/microsoft/vscode-eslint/archive/refs/heads/main.zip") if (!response.ok) return @@ -351,11 +350,11 @@ export const Gopls: Info = { return NearestRoot(["go.mod", "go.sum"])(file, ctx) }, extensions: [".go"], - async spawn(root) { + async spawn(root, _ctx, flags) { let bin = which("gopls") if (!bin) { if (!which("go")) return - if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return + if (flags.disableLspDownload) return log.info("installing gopls") const proc = Process.spawn(["go", "install", "golang.org/x/tools/gopls@latest"], { @@ -386,7 +385,7 @@ export const Rubocop: Info = { id: "ruby-lsp", root: NearestRoot(["Gemfile"]), extensions: [".rb", ".rake", ".gemspec", ".ru"], - async spawn(root) { + async spawn(root, _ctx, flags) { let bin = which("rubocop") if (!bin) { const ruby = which("ruby") @@ -395,7 +394,7 @@ export const Rubocop: Info = { log.info("Ruby not found, please install Ruby first") return } - if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return + if (flags.disableLspDownload) return log.info("installing rubocop") const proc = Process.spawn(["gem", "install", "rubocop", "--bindir", Global.Path.bin], { stdout: "pipe", @@ -486,11 +485,11 @@ export const Pyright: Info = { id: "pyright", extensions: [".py", ".pyi"], root: NearestRoot(["pyproject.toml", "setup.py", "setup.cfg", "requirements.txt", "Pipfile", "pyrightconfig.json"]), - async spawn(root) { + async spawn(root, _ctx, flags) { let binary = which("pyright-langserver") const args = [] if (!binary) { - if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return + if (flags.disableLspDownload) return const resolved = await Npm.which("pyright", "pyright-langserver") if (!resolved) return binary = resolved @@ -530,7 +529,7 @@ export const ElixirLS: Info = { id: "elixir-ls", extensions: [".ex", ".exs"], root: NearestRoot(["mix.exs", "mix.lock"]), - async spawn(root) { + async spawn(root, _ctx, flags) { let binary = which("elixir-ls") if (!binary) { const elixirLsPath = path.join(Global.Path.bin, "elixir-ls") @@ -548,7 +547,7 @@ export const ElixirLS: Info = { return } - if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return + if (flags.disableLspDownload) return log.info("downloading elixir-ls from GitHub releases") const response = await fetch("https://github.com/elixir-lsp/elixir-ls/archive/refs/heads/master.zip") @@ -593,7 +592,7 @@ export const Zls: Info = { id: "zls", extensions: [".zig", ".zon"], root: NearestRoot(["build.zig"]), - async spawn(root) { + async spawn(root, _ctx, flags) { let bin = which("zls") if (!bin) { @@ -603,7 +602,7 @@ export const Zls: Info = { return } - if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return + if (flags.disableLspDownload) return log.info("downloading zls from GitHub releases") const releaseResponse = await fetch("https://api.github.com/repos/zigtools/zls/releases/latest") @@ -705,8 +704,8 @@ export const CSharp: Info = { id: "csharp", root: NearestRoot([".slnx", ".sln", ".csproj", "global.json"]), extensions: [".cs", ".csx"], - async spawn(root) { - const bin = await getRoslynLanguageServer() + async spawn(root, _ctx, flags) { + const bin = await getRoslynLanguageServer(flags.disableLspDownload) if (!bin) return return { @@ -721,8 +720,8 @@ export const Razor: Info = { id: "razor", root: NearestRoot([".slnx", ".sln", ".csproj", "global.json"]), extensions: [".razor", ".cshtml"], - async spawn(root) { - const bin = await getRoslynLanguageServer() + async spawn(root, _ctx, flags) { + const bin = await getRoslynLanguageServer(flags.disableLspDownload) if (!bin) return const razor = await findVscodeRazorExtension() @@ -753,26 +752,26 @@ export const Razor: Info = { let roslynLanguageServerInstall: Promise | undefined -async function getRoslynLanguageServer() { +async function getRoslynLanguageServer(disableLspDownload: boolean) { const existing = which("roslyn-language-server") if (existing) return existing const global = await roslynLanguageServerGlobalPath() if (global) return global - roslynLanguageServerInstall ||= installRoslynLanguageServer().finally(() => { + roslynLanguageServerInstall ||= installRoslynLanguageServer(disableLspDownload).finally(() => { roslynLanguageServerInstall = undefined }) return roslynLanguageServerInstall } -async function installRoslynLanguageServer() { +async function installRoslynLanguageServer(disableLspDownload: boolean) { if (!which("dotnet")) { log.error(".NET SDK is required to install roslyn-language-server") return } - if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return + if (disableLspDownload) return log.info("installing roslyn-language-server via dotnet tool") const proc = Process.spawn(["dotnet", "tool", "install", "--global", "roslyn-language-server", "--prerelease"], { stdout: "pipe", @@ -850,7 +849,7 @@ export const FSharp: Info = { id: "fsharp", root: NearestRoot([".slnx", ".sln", ".fsproj", "global.json"]), extensions: [".fs", ".fsi", ".fsx", ".fsscript"], - async spawn(root) { + async spawn(root, _ctx, flags) { let bin = which("fsautocomplete") if (!bin) { if (!which("dotnet")) { @@ -858,7 +857,7 @@ export const FSharp: Info = { return } - if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return + if (flags.disableLspDownload) return log.info("installing fsautocomplete via dotnet tool") const proc = Process.spawn(["dotnet", "tool", "install", "fsautocomplete", "--tool-path", Global.Path.bin], { stdout: "pipe", @@ -967,7 +966,7 @@ export const Clangd: Info = { id: "clangd", root: NearestRoot(["compile_commands.json", "compile_flags.txt", ".clangd"]), extensions: [".c", ".cpp", ".cc", ".cxx", ".c++", ".h", ".hpp", ".hh", ".hxx", ".h++"], - async spawn(root) { + async spawn(root, _ctx, flags) { const args = ["--background-index", "--clang-tidy"] const fromPath = which("clangd") if (fromPath) { @@ -1002,7 +1001,7 @@ export const Clangd: Info = { } } - if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return + if (flags.disableLspDownload) return log.info("downloading clangd from GitHub releases") const releaseResponse = await fetch("https://api.github.com/repos/clangd/clangd/releases/latest") @@ -1113,11 +1112,11 @@ export const Svelte: Info = { id: "svelte", extensions: [".svelte"], root: NearestRoot(["package-lock.json", "bun.lockb", "bun.lock", "pnpm-lock.yaml", "yarn.lock"]), - async spawn(root) { + async spawn(root, _ctx, flags) { let binary = which("svelteserver") const args: string[] = [] if (!binary) { - if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return + if (flags.disableLspDownload) return const resolved = await Npm.which("svelte-language-server") if (!resolved) return binary = resolved @@ -1140,7 +1139,7 @@ export const Astro: Info = { id: "astro", extensions: [".astro"], root: NearestRoot(["package-lock.json", "bun.lockb", "bun.lock", "pnpm-lock.yaml", "yarn.lock"]), - async spawn(root, ctx) { + async spawn(root, ctx, flags) { const tsserver = Module.resolve("typescript/lib/tsserver.js", ctx.directory) if (!tsserver) { log.info("typescript not found, required for Astro language server") @@ -1151,7 +1150,7 @@ export const Astro: Info = { let binary = which("astro-ls") const args: string[] = [] if (!binary) { - if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return + if (flags.disableLspDownload) return const resolved = await Npm.which("@astrojs/language-server") if (!resolved) return binary = resolved @@ -1201,7 +1200,7 @@ export const JDTLS: Info = { if (settingsRoot) return settingsRoot }, extensions: [".java"], - async spawn(root) { + async spawn(root, _ctx, flags) { const java = which("java") if (!java) { log.error("Java 21 or newer is required to run the JDTLS. Please install it first.") @@ -1219,7 +1218,7 @@ export const JDTLS: Info = { const launcherDir = path.join(distPath, "plugins") const installed = await pathExists(launcherDir) if (!installed) { - if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return + if (flags.disableLspDownload) return log.info("Downloading JDTLS LSP server.") await fs.mkdir(distPath, { recursive: true }) const releaseURL = @@ -1311,13 +1310,13 @@ export const KotlinLS: Info = { // 4) Maven fallback return NearestRoot(["pom.xml"])(file, ctx) }, - async spawn(root) { + async spawn(root, _ctx, flags) { const distPath = path.join(Global.Path.bin, "kotlin-ls") const launcherScript = process.platform === "win32" ? path.join(distPath, "kotlin-lsp.cmd") : path.join(distPath, "kotlin-lsp.sh") const installed = await Filesystem.exists(launcherScript) if (!installed) { - if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return + if (flags.disableLspDownload) return log.info("Downloading Kotlin Language Server from GitHub.") const releaseResponse = await fetch("https://api.github.com/repos/Kotlin/kotlin-lsp/releases/latest") @@ -1398,11 +1397,11 @@ export const YamlLS: Info = { id: "yaml-ls", extensions: [".yaml", ".yml"], root: NearestRoot(["package-lock.json", "bun.lockb", "bun.lock", "pnpm-lock.yaml", "yarn.lock"]), - async spawn(root) { + async spawn(root, _ctx, flags) { let binary = which("yaml-language-server") const args: string[] = [] if (!binary) { - if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return + if (flags.disableLspDownload) return const resolved = await Npm.which("yaml-language-server") if (!resolved) return binary = resolved @@ -1432,11 +1431,11 @@ export const LuaLS: Info = { "selene.yml", ]), extensions: [".lua"], - async spawn(root) { + async spawn(root, _ctx, flags) { let bin = which("lua-language-server") if (!bin) { - if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return + if (flags.disableLspDownload) return log.info("downloading lua-language-server from GitHub releases") const releaseResponse = await fetch("https://api.github.com/repos/LuaLS/lua-language-server/releases/latest") @@ -1565,11 +1564,11 @@ export const PHPIntelephense: Info = { id: "php intelephense", extensions: [".php"], root: NearestRoot(["composer.json", "composer.lock", ".php-version"]), - async spawn(root) { + async spawn(root, _ctx, flags) { let binary = which("intelephense") const args: string[] = [] if (!binary) { - if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return + if (flags.disableLspDownload) return const resolved = await Npm.which("intelephense") if (!resolved) return binary = resolved @@ -1649,11 +1648,11 @@ export const BashLS: Info = { id: "bash", extensions: [".sh", ".bash", ".zsh", ".ksh"], root: async (_file, ctx) => ctx.directory, - async spawn(root) { + async spawn(root, _ctx, flags) { let binary = which("bash-language-server") const args: string[] = [] if (!binary) { - if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return + if (flags.disableLspDownload) return const resolved = await Npm.which("bash-language-server") if (!resolved) return binary = resolved @@ -1675,11 +1674,11 @@ export const TerraformLS: Info = { id: "terraform", extensions: [".tf", ".tfvars"], root: NearestRoot([".terraform.lock.hcl", "terraform.tfstate", "*.tf"]), - async spawn(root) { + async spawn(root, _ctx, flags) { let bin = which("terraform-ls") if (!bin) { - if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return + if (flags.disableLspDownload) return log.info("downloading terraform-ls from HashiCorp releases") const releaseResponse = await fetch("https://api.releases.hashicorp.com/v1/releases/terraform-ls/latest") @@ -1756,11 +1755,11 @@ export const TexLab: Info = { id: "texlab", extensions: [".tex", ".bib"], root: NearestRoot([".latexmkrc", "latexmkrc", ".texlabroot", "texlabroot"]), - async spawn(root) { + async spawn(root, _ctx, flags) { let bin = which("texlab") if (!bin) { - if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return + if (flags.disableLspDownload) return log.info("downloading texlab from GitHub releases") const response = await fetch("https://api.github.com/repos/latex-lsp/texlab/releases/latest") @@ -1844,11 +1843,11 @@ export const DockerfileLS: Info = { id: "dockerfile", extensions: [".dockerfile", "Dockerfile"], root: async (_file, ctx) => ctx.directory, - async spawn(root) { + async spawn(root, _ctx, flags) { let binary = which("docker-langserver") const args: string[] = [] if (!binary) { - if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return + if (flags.disableLspDownload) return const resolved = await Npm.which("dockerfile-language-server-nodejs") if (!resolved) return binary = resolved @@ -1940,11 +1939,11 @@ export const Tinymist: Info = { id: "tinymist", extensions: [".typ", ".typc"], root: NearestRoot(["typst.toml"]), - async spawn(root) { + async spawn(root, _ctx, flags) { let bin = which("tinymist") if (!bin) { - if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return + if (flags.disableLspDownload) return log.info("downloading tinymist from GitHub releases") const response = await fetch("https://api.github.com/repos/Myriad-Dreamin/tinymist/releases/latest") diff --git a/packages/opencode/test/effect/runtime-flags.test.ts b/packages/opencode/test/effect/runtime-flags.test.ts index 41d158652f6..2ac53a3d548 100644 --- a/packages/opencode/test/effect/runtime-flags.test.ts +++ b/packages/opencode/test/effect/runtime-flags.test.ts @@ -28,6 +28,7 @@ describe("RuntimeFlags", () => { OPENCODE_AUTO_SHARE: "true", OPENCODE_DISABLE_EMBEDDED_WEB_UI: "true", OPENCODE_DISABLE_EXTERNAL_SKILLS: "true", + OPENCODE_DISABLE_LSP_DOWNLOAD: "true", OPENCODE_EXPERIMENTAL: "true", OPENCODE_ENABLE_EXA: "true", OPENCODE_ENABLE_PARALLEL: "true", @@ -44,6 +45,7 @@ describe("RuntimeFlags", () => { expect(flags.disableChannelDb).toBe(true) expect(flags.disableEmbeddedWebUi).toBe(true) expect(flags.disableExternalSkills).toBe(true) + expect(flags.disableLspDownload).toBe(true) expect(flags.disableClaudeCodePrompt).toBe(false) expect(flags.enableExa).toBe(true) expect(flags.enableParallel).toBe(true) @@ -88,6 +90,7 @@ describe("RuntimeFlags", () => { expect(flags.disableChannelDb).toBe(false) expect(flags.disableEmbeddedWebUi).toBe(false) expect(flags.disableExternalSkills).toBe(false) + expect(flags.disableLspDownload).toBe(false) expect(flags.disableClaudeCodePrompt).toBe(false) expect(flags.disableClaudeCodeSkills).toBe(false) expect(flags.enableExa).toBe(false) @@ -124,6 +127,22 @@ describe("RuntimeFlags", () => { }), ) + it.effect("disableLspDownload defaults to false", () => + Effect.gen(function* () { + const flags = yield* readFlags.pipe(Effect.provide(fromConfig({}))) + + expect(flags.disableLspDownload).toBe(false) + }), + ) + + it.effect("disableLspDownload reads OPENCODE_DISABLE_LSP_DOWNLOAD", () => + Effect.gen(function* () { + const flags = yield* readFlags.pipe(Effect.provide(fromConfig({ OPENCODE_DISABLE_LSP_DOWNLOAD: "true" }))) + + expect(flags.disableLspDownload).toBe(true) + }), + ) + it.effect("disableClaudeCodePrompt defaults to false", () => Effect.gen(function* () { const flags = yield* readFlags.pipe(Effect.provide(fromConfig({}))) @@ -268,6 +287,7 @@ describe("RuntimeFlags", () => { OPENCODE_PURE: "true", OPENCODE_DISABLE_DEFAULT_PLUGINS: "true", OPENCODE_DISABLE_EXTERNAL_SKILLS: "true", + OPENCODE_DISABLE_LSP_DOWNLOAD: "true", OPENCODE_EXPERIMENTAL: "true", OPENCODE_ENABLE_EXA: "true", OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS: "1234", @@ -282,6 +302,7 @@ describe("RuntimeFlags", () => { expect(flags.disableChannelDb).toBe(false) expect(flags.disableEmbeddedWebUi).toBe(false) expect(flags.disableExternalSkills).toBe(false) + expect(flags.disableLspDownload).toBe(false) expect(flags.disableClaudeCodePrompt).toBe(false) expect(flags.disableClaudeCodeSkills).toBe(false) expect(flags.enableExa).toBe(false) diff --git a/packages/opencode/test/lsp/index.test.ts b/packages/opencode/test/lsp/index.test.ts index eacd0df03ca..b69963b3019 100644 --- a/packages/opencode/test/lsp/index.test.ts +++ b/packages/opencode/test/lsp/index.test.ts @@ -16,6 +16,12 @@ const experimentalTyIt = testEffect( CrossSpawnSpawner.defaultLayer, ), ) +const disabledDownloadIt = testEffect( + Layer.mergeAll( + LSP.layer.pipe(Layer.provide(Config.defaultLayer), Layer.provide(RuntimeFlags.layer({ disableLspDownload: true }))), + CrossSpawnSpawner.defaultLayer, + ), +) describe("lsp.spawn", () => { it.live("does not spawn builtin LSP for files outside instance", () => @@ -166,4 +172,28 @@ describe("lsp.spawn", () => { { config: { lsp: true } }, ), ) + + disabledDownloadIt.live("passes disableLspDownload to builtin LSP spawn", () => + provideTmpdirInstance( + (dir) => + LSP.Service.use((lsp) => + Effect.gen(function* () { + const pyright = spyOn(LSPServer.Pyright, "spawn").mockResolvedValue(undefined) + + try { + yield* lsp.hover({ + file: path.join(dir, "src", "inside.py"), + line: 0, + character: 0, + }) + expect(pyright).toHaveBeenCalledTimes(1) + expect(pyright.mock.calls[0]?.[2]).toMatchObject({ disableLspDownload: true }) + } finally { + pyright.mockRestore() + } + }), + ), + { config: { lsp: true } }, + ), + ) }) From 356f6841865d68adf6d0123c37357ad50814497a Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Fri, 15 May 2026 14:54:29 +0530 Subject: [PATCH 025/164] refactor(flags): migrate skip migrations flag (#27705) --- packages/core/src/flag/flag.ts | 1 - packages/opencode/src/effect/runtime-flags.ts | 1 + packages/opencode/src/storage/db.ts | 10 ++++----- .../test/effect/runtime-flags.test.ts | 21 +++++++++++++++++++ packages/opencode/test/storage/db.test.ts | 9 ++++++++ 5 files changed, 36 insertions(+), 6 deletions(-) diff --git a/packages/core/src/flag/flag.ts b/packages/core/src/flag/flag.ts index 97d9cd5dabd..ee5274461a7 100644 --- a/packages/core/src/flag/flag.ts +++ b/packages/core/src/flag/flag.ts @@ -51,7 +51,6 @@ export const Flag = { OPENCODE_MODELS_URL: process.env["OPENCODE_MODELS_URL"], OPENCODE_MODELS_PATH: process.env["OPENCODE_MODELS_PATH"], OPENCODE_DB: process.env["OPENCODE_DB"], - OPENCODE_SKIP_MIGRATIONS: truthy("OPENCODE_SKIP_MIGRATIONS"), OPENCODE_STRICT_CONFIG_DEPS: truthy("OPENCODE_STRICT_CONFIG_DEPS"), OPENCODE_WORKSPACE_ID: process.env["OPENCODE_WORKSPACE_ID"], diff --git a/packages/opencode/src/effect/runtime-flags.ts b/packages/opencode/src/effect/runtime-flags.ts index 20e7d249637..0b5939cd7d5 100644 --- a/packages/opencode/src/effect/runtime-flags.ts +++ b/packages/opencode/src/effect/runtime-flags.ts @@ -19,6 +19,7 @@ export class Service extends ConfigService.Service()("@opencode/Runtime disableEmbeddedWebUi: bool("OPENCODE_DISABLE_EMBEDDED_WEB_UI"), disableExternalSkills: bool("OPENCODE_DISABLE_EXTERNAL_SKILLS"), disableLspDownload: bool("OPENCODE_DISABLE_LSP_DOWNLOAD"), + skipMigrations: bool("OPENCODE_SKIP_MIGRATIONS"), disableClaudeCodePrompt: Config.all({ broad: bool("OPENCODE_DISABLE_CLAUDE_CODE"), direct: bool("OPENCODE_DISABLE_CLAUDE_CODE_PROMPT"), diff --git a/packages/opencode/src/storage/db.ts b/packages/opencode/src/storage/db.ts index 3c936391368..6cb819a6fd0 100644 --- a/packages/opencode/src/storage/db.ts +++ b/packages/opencode/src/storage/db.ts @@ -23,19 +23,19 @@ export const NotFoundError = NamedError.create("NotFoundError", { const log = Log.create({ service: "db" }) -type ChannelDbFlags = Pick +type DatabaseFlags = Pick const readRuntimeFlags = () => Effect.runSync(RuntimeFlags.Service.useSync((flags) => flags).pipe(Effect.provide(RuntimeFlags.defaultLayer))) -export function getChannelPath(flags: ChannelDbFlags = readRuntimeFlags()) { +export function getChannelPath(flags: Pick = readRuntimeFlags()) { if (["latest", "beta", "prod"].includes(InstallationChannel) || flags.disableChannelDb) return path.join(Global.Path.data, "opencode.db") const safe = InstallationChannel.replace(/[^a-zA-Z0-9._-]/g, "-") return path.join(Global.Path.data, `opencode-${safe}.db`) } -export const getPath = (flags?: ChannelDbFlags) => { +export const getPath = (flags?: Pick) => { if (Flag.OPENCODE_DB) { if (Flag.OPENCODE_DB === ":memory:" || path.isAbsolute(Flag.OPENCODE_DB)) return Flag.OPENCODE_DB return path.join(Global.Path.data, Flag.OPENCODE_DB) @@ -93,7 +93,7 @@ let client: Client | undefined let loaded = false export const Client = Object.assign( - (flags?: ChannelDbFlags): Client => { + (flags: DatabaseFlags = readRuntimeFlags()): Client => { if (loaded) return client as Client const dbPath = getPath(flags) @@ -118,7 +118,7 @@ export const Client = Object.assign( count: entries.length, mode: typeof OPENCODE_MIGRATIONS !== "undefined" ? "bundled" : "dev", }) - if (Flag.OPENCODE_SKIP_MIGRATIONS) { + if (flags.skipMigrations) { for (const item of entries) { item.sql = "select 1;" } diff --git a/packages/opencode/test/effect/runtime-flags.test.ts b/packages/opencode/test/effect/runtime-flags.test.ts index 2ac53a3d548..665b546f3d1 100644 --- a/packages/opencode/test/effect/runtime-flags.test.ts +++ b/packages/opencode/test/effect/runtime-flags.test.ts @@ -29,6 +29,7 @@ describe("RuntimeFlags", () => { OPENCODE_DISABLE_EMBEDDED_WEB_UI: "true", OPENCODE_DISABLE_EXTERNAL_SKILLS: "true", OPENCODE_DISABLE_LSP_DOWNLOAD: "true", + OPENCODE_SKIP_MIGRATIONS: "true", OPENCODE_EXPERIMENTAL: "true", OPENCODE_ENABLE_EXA: "true", OPENCODE_ENABLE_PARALLEL: "true", @@ -46,6 +47,7 @@ describe("RuntimeFlags", () => { expect(flags.disableEmbeddedWebUi).toBe(true) expect(flags.disableExternalSkills).toBe(true) expect(flags.disableLspDownload).toBe(true) + expect(flags.skipMigrations).toBe(true) expect(flags.disableClaudeCodePrompt).toBe(false) expect(flags.enableExa).toBe(true) expect(flags.enableParallel).toBe(true) @@ -91,6 +93,7 @@ describe("RuntimeFlags", () => { expect(flags.disableEmbeddedWebUi).toBe(false) expect(flags.disableExternalSkills).toBe(false) expect(flags.disableLspDownload).toBe(false) + expect(flags.skipMigrations).toBe(false) expect(flags.disableClaudeCodePrompt).toBe(false) expect(flags.disableClaudeCodeSkills).toBe(false) expect(flags.enableExa).toBe(false) @@ -143,6 +146,22 @@ describe("RuntimeFlags", () => { }), ) + it.effect("skipMigrations defaults to false", () => + Effect.gen(function* () { + const flags = yield* readFlags.pipe(Effect.provide(fromConfig({}))) + + expect(flags.skipMigrations).toBe(false) + }), + ) + + it.effect("skipMigrations reads OPENCODE_SKIP_MIGRATIONS", () => + Effect.gen(function* () { + const flags = yield* readFlags.pipe(Effect.provide(fromConfig({ OPENCODE_SKIP_MIGRATIONS: "true" }))) + + expect(flags.skipMigrations).toBe(true) + }), + ) + it.effect("disableClaudeCodePrompt defaults to false", () => Effect.gen(function* () { const flags = yield* readFlags.pipe(Effect.provide(fromConfig({}))) @@ -288,6 +307,7 @@ describe("RuntimeFlags", () => { OPENCODE_DISABLE_DEFAULT_PLUGINS: "true", OPENCODE_DISABLE_EXTERNAL_SKILLS: "true", OPENCODE_DISABLE_LSP_DOWNLOAD: "true", + OPENCODE_SKIP_MIGRATIONS: "true", OPENCODE_EXPERIMENTAL: "true", OPENCODE_ENABLE_EXA: "true", OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS: "1234", @@ -303,6 +323,7 @@ describe("RuntimeFlags", () => { expect(flags.disableEmbeddedWebUi).toBe(false) expect(flags.disableExternalSkills).toBe(false) expect(flags.disableLspDownload).toBe(false) + expect(flags.skipMigrations).toBe(false) expect(flags.disableClaudeCodePrompt).toBe(false) expect(flags.disableClaudeCodeSkills).toBe(false) expect(flags.enableExa).toBe(false) diff --git a/packages/opencode/test/storage/db.test.ts b/packages/opencode/test/storage/db.test.ts index ec351fdd76e..ba7f0912aa9 100644 --- a/packages/opencode/test/storage/db.test.ts +++ b/packages/opencode/test/storage/db.test.ts @@ -26,4 +26,13 @@ describe("Database.getChannelPath", () => { expect(Database.getChannelPath(flags)).toBe(path.join(Global.Path.data, "opencode.db")) }).pipe(Effect.provide(RuntimeFlags.layer({ disableChannelDb: true }))), ) + + it.effect("accepts RuntimeFlags with skipMigrations for database callers", () => + Effect.gen(function* () { + const flags = yield* RuntimeFlags.Service + + expect(flags.skipMigrations).toBe(true) + expect(Database.getChannelPath(flags)).toBe(Database.getChannelPath({ disableChannelDb: flags.disableChannelDb })) + }).pipe(Effect.provide(RuntimeFlags.layer({ skipMigrations: true }))), + ) }) From eb5ef1c073b88d83a0631291aa635d0b53887a92 Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Fri, 15 May 2026 15:35:24 +0530 Subject: [PATCH 026/164] refactor(flags): remove unused flag exports (#27709) --- packages/core/src/flag/flag.ts | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/packages/core/src/flag/flag.ts b/packages/core/src/flag/flag.ts index ee5274461a7..4b1d3d20aec 100644 --- a/packages/core/src/flag/flag.ts +++ b/packages/core/src/flag/flag.ts @@ -6,7 +6,6 @@ function truthy(key: string) { } const OPENCODE_EXPERIMENTAL = truthy("OPENCODE_EXPERIMENTAL") -const OPENCODE_DISABLE_CLAUDE_CODE = truthy("OPENCODE_DISABLE_CLAUDE_CODE") const copy = process.env["OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT"] export const Flag = { @@ -23,18 +22,14 @@ export const Flag = { OPENCODE_DISABLE_TERMINAL_TITLE: truthy("OPENCODE_DISABLE_TERMINAL_TITLE"), OPENCODE_SHOW_TTFD: truthy("OPENCODE_SHOW_TTFD"), OPENCODE_PERMISSION: process.env["OPENCODE_PERMISSION"], - OPENCODE_DISABLE_DEFAULT_PLUGINS: truthy("OPENCODE_DISABLE_DEFAULT_PLUGINS"), OPENCODE_DISABLE_AUTOCOMPACT: truthy("OPENCODE_DISABLE_AUTOCOMPACT"), OPENCODE_DISABLE_MODELS_FETCH: truthy("OPENCODE_DISABLE_MODELS_FETCH"), OPENCODE_DISABLE_MOUSE: truthy("OPENCODE_DISABLE_MOUSE"), - OPENCODE_DISABLE_CLAUDE_CODE, OPENCODE_FAKE_VCS: process.env["OPENCODE_FAKE_VCS"], OPENCODE_SERVER_PASSWORD: process.env["OPENCODE_SERVER_PASSWORD"], OPENCODE_SERVER_USERNAME: process.env["OPENCODE_SERVER_USERNAME"], - OPENCODE_ENABLE_QUESTION_TOOL: truthy("OPENCODE_ENABLE_QUESTION_TOOL"), // Experimental - OPENCODE_EXPERIMENTAL, OPENCODE_EXPERIMENTAL_FILEWATCHER: Config.boolean("OPENCODE_EXPERIMENTAL_FILEWATCHER").pipe( Config.withDefault(false), ), @@ -43,19 +38,12 @@ export const Flag = { ), OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT: copy === undefined ? process.platform === "win32" : truthy("OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT"), - OPENCODE_ENABLE_EXA: truthy("OPENCODE_ENABLE_EXA") || OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_EXA"), - OPENCODE_EXPERIMENTAL_LSP_TOOL: OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_LSP_TOOL"), - OPENCODE_EXPERIMENTAL_PLAN_MODE: OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_PLAN_MODE"), - OPENCODE_EXPERIMENTAL_SCOUT: OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_SCOUT"), - OPENCODE_ENABLE_PARALLEL: truthy("OPENCODE_ENABLE_PARALLEL") || truthy("OPENCODE_EXPERIMENTAL_PARALLEL"), OPENCODE_MODELS_URL: process.env["OPENCODE_MODELS_URL"], OPENCODE_MODELS_PATH: process.env["OPENCODE_MODELS_PATH"], OPENCODE_DB: process.env["OPENCODE_DB"], - OPENCODE_STRICT_CONFIG_DEPS: truthy("OPENCODE_STRICT_CONFIG_DEPS"), OPENCODE_WORKSPACE_ID: process.env["OPENCODE_WORKSPACE_ID"], OPENCODE_EXPERIMENTAL_WORKSPACES: OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_WORKSPACES"), - OPENCODE_EXPERIMENTAL_EVENT_SYSTEM: OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_EVENT_SYSTEM"), OPENCODE_EXPERIMENTAL_SESSION_SWITCHING: OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_SESSION_SWITCHING"), // Evaluated at access time (not module load) because tests, the CLI, and From 12b666e2c941f38578acdc046743838626e1b3f8 Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Fri, 15 May 2026 15:59:56 +0530 Subject: [PATCH 027/164] refactor(project): import instance context directly (#27714) --- packages/opencode/src/cli/cmd/debug/agent.ts | 2 +- packages/opencode/src/command/index.ts | 2 +- packages/opencode/src/config/config.ts | 3 +-- packages/opencode/src/effect/bridge.ts | 3 ++- packages/opencode/src/effect/instance-ref.ts | 2 +- packages/opencode/src/effect/instance-state.ts | 3 ++- packages/opencode/src/effect/run-service.ts | 2 +- packages/opencode/src/format/formatter.ts | 2 +- packages/opencode/src/lsp/server.ts | 2 +- .../opencode/src/server/routes/instance/httpapi/lifecycle.ts | 2 +- packages/opencode/src/session/session.ts | 2 +- packages/opencode/src/sync/index.ts | 2 +- 12 files changed, 14 insertions(+), 13 deletions(-) diff --git a/packages/opencode/src/cli/cmd/debug/agent.ts b/packages/opencode/src/cli/cmd/debug/agent.ts index 1a3f79396c0..ac9879ff891 100644 --- a/packages/opencode/src/cli/cmd/debug/agent.ts +++ b/packages/opencode/src/cli/cmd/debug/agent.ts @@ -11,7 +11,7 @@ import { Permission } from "../../../permission" import { iife } from "../../../util/iife" import { effectCmd, fail } from "../../effect-cmd" import { InstanceRef } from "@/effect/instance-ref" -import type { InstanceContext } from "@/project/instance" +import type { InstanceContext } from "@/project/instance-context" export const AgentCommand = effectCmd({ command: "agent ", diff --git a/packages/opencode/src/command/index.ts b/packages/opencode/src/command/index.ts index 3da260ea645..96e171733df 100644 --- a/packages/opencode/src/command/index.ts +++ b/packages/opencode/src/command/index.ts @@ -1,7 +1,7 @@ import { BusEvent } from "@/bus/bus-event" import { InstanceState } from "@/effect/instance-state" import { EffectBridge } from "@/effect/bridge" -import type { InstanceContext } from "@/project/instance" +import type { InstanceContext } from "@/project/instance-context" import { SessionID, MessageID } from "@/session/schema" import { Effect, Layer, Context, Schema } from "effect" import { Config } from "@/config/config" diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 545e48e64d9..b13d3a8c813 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -10,7 +10,6 @@ import { Flag } from "@opencode-ai/core/flag/flag" import { Auth } from "../auth" import { Env } from "../env" import { applyEdits, modify } from "jsonc-parser" -import { type InstanceContext } from "../project/instance" import { InstallationLocal, InstallationVersion } from "@opencode-ai/core/installation/version" import { existsSync } from "fs" import { Account } from "@/account/account" @@ -20,7 +19,7 @@ import { AppFileSystem } from "@opencode-ai/core/filesystem" import { InstanceState } from "@/effect/instance-state" import { Context, Duration, Effect, Exit, Fiber, Layer, Option, Schema } from "effect" import { EffectFlock } from "@opencode-ai/core/util/effect-flock" -import { containsPath } from "../project/instance-context" +import { containsPath, type InstanceContext } from "../project/instance-context" import { NonNegativeInt, PositiveInt, type DeepMutable } from "@opencode-ai/core/schema" import { ConfigAgent } from "./agent" import { ConfigAttachment } from "./attachment" diff --git a/packages/opencode/src/effect/bridge.ts b/packages/opencode/src/effect/bridge.ts index 16d8f93669c..e987c901316 100644 --- a/packages/opencode/src/effect/bridge.ts +++ b/packages/opencode/src/effect/bridge.ts @@ -1,6 +1,7 @@ import { Effect, Exit, Fiber } from "effect" import { WorkspaceContext } from "@/control-plane/workspace-context" -import { Instance, type InstanceContext } from "@/project/instance" +import { Instance } from "@/project/instance" +import type { InstanceContext } from "@/project/instance-context" import type { WorkspaceID } from "@/control-plane/schema" import { LocalContext } from "@/util/local-context" import { InstanceRef, WorkspaceRef } from "./instance-ref" diff --git a/packages/opencode/src/effect/instance-ref.ts b/packages/opencode/src/effect/instance-ref.ts index effc560c580..d95932c2de6 100644 --- a/packages/opencode/src/effect/instance-ref.ts +++ b/packages/opencode/src/effect/instance-ref.ts @@ -1,5 +1,5 @@ import { Context } from "effect" -import type { InstanceContext } from "@/project/instance" +import type { InstanceContext } from "@/project/instance-context" import type { WorkspaceID } from "@/control-plane/schema" export const InstanceRef = Context.Reference("~opencode/InstanceRef", { diff --git a/packages/opencode/src/effect/instance-state.ts b/packages/opencode/src/effect/instance-state.ts index e467b6ef28b..5c95e012821 100644 --- a/packages/opencode/src/effect/instance-state.ts +++ b/packages/opencode/src/effect/instance-state.ts @@ -1,6 +1,7 @@ import { Effect, Fiber, ScopedCache, Scope, Context } from "effect" import * as EffectLogger from "@opencode-ai/core/effect/logger" -import { Instance, type InstanceContext } from "@/project/instance" +import { Instance } from "@/project/instance" +import type { InstanceContext } from "@/project/instance-context" import { LocalContext } from "@/util/local-context" import { InstanceRef, WorkspaceRef } from "./instance-ref" import { registerDisposer } from "./instance-registry" diff --git a/packages/opencode/src/effect/run-service.ts b/packages/opencode/src/effect/run-service.ts index 1f3802e80c4..75cc0d58b7c 100644 --- a/packages/opencode/src/effect/run-service.ts +++ b/packages/opencode/src/effect/run-service.ts @@ -5,7 +5,7 @@ import { LocalContext } from "@/util/local-context" import { InstanceRef, WorkspaceRef } from "./instance-ref" import * as Observability from "@opencode-ai/core/effect/observability" import { WorkspaceContext } from "@/control-plane/workspace-context" -import type { InstanceContext } from "@/project/instance" +import type { InstanceContext } from "@/project/instance-context" import { memoMap } from "@opencode-ai/core/effect/memo-map" type Refs = { diff --git a/packages/opencode/src/format/formatter.ts b/packages/opencode/src/format/formatter.ts index 4c559631f29..27b28c37bcf 100644 --- a/packages/opencode/src/format/formatter.ts +++ b/packages/opencode/src/format/formatter.ts @@ -1,5 +1,5 @@ import { Npm } from "@opencode-ai/core/npm" -import type { InstanceContext } from "../project/instance" +import type { InstanceContext } from "../project/instance-context" import { Filesystem } from "@/util/filesystem" import { Process } from "@/util/process" import { which } from "../util/which" diff --git a/packages/opencode/src/lsp/server.ts b/packages/opencode/src/lsp/server.ts index 7bda06f0df1..ad90ef5c786 100644 --- a/packages/opencode/src/lsp/server.ts +++ b/packages/opencode/src/lsp/server.ts @@ -6,7 +6,7 @@ import * as Log from "@opencode-ai/core/util/log" import { text } from "node:stream/consumers" import fs from "fs/promises" import { Filesystem } from "@/util/filesystem" -import type { InstanceContext } from "../project/instance" +import type { InstanceContext } from "../project/instance-context" import { Archive } from "@/util/archive" import { Process } from "@/util/process" import { which } from "../util/which" diff --git a/packages/opencode/src/server/routes/instance/httpapi/lifecycle.ts b/packages/opencode/src/server/routes/instance/httpapi/lifecycle.ts index 4edfa807873..30347d85ffd 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/lifecycle.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/lifecycle.ts @@ -1,5 +1,5 @@ import { EffectBridge } from "@/effect/bridge" -import type { InstanceContext } from "@/project/instance" +import type { InstanceContext } from "@/project/instance-context" import { InstanceStore } from "@/project/instance-store" import * as Log from "@opencode-ai/core/util/log" import { Effect } from "effect" diff --git a/packages/opencode/src/session/session.ts b/packages/opencode/src/session/session.ts index d5e175c7c06..797a635eeef 100644 --- a/packages/opencode/src/session/session.ts +++ b/packages/opencode/src/session/session.ts @@ -25,7 +25,7 @@ import { ProjectTable } from "../project/project.sql" import { Storage } from "@/storage/storage" import * as Log from "@opencode-ai/core/util/log" import { MessageV2 } from "./message-v2" -import type { InstanceContext } from "../project/instance" +import type { InstanceContext } from "../project/instance-context" import { InstanceState } from "@/effect/instance-state" import { Snapshot } from "@/snapshot" import { ProjectID } from "../project/schema" diff --git a/packages/opencode/src/sync/index.ts b/packages/opencode/src/sync/index.ts index d5bca45b16b..c1bd8d647fa 100644 --- a/packages/opencode/src/sync/index.ts +++ b/packages/opencode/src/sync/index.ts @@ -7,7 +7,7 @@ import { eq } from "drizzle-orm" import { GlobalBus } from "@/bus/global" import { Bus as ProjectBus } from "@/bus" import { BusEvent } from "@/bus/bus-event" -import type { InstanceContext } from "@/project/instance" +import type { InstanceContext } from "@/project/instance-context" import { EventSequenceTable, EventTable } from "./event.sql" import type { WorkspaceID } from "@/control-plane/schema" import { EventID } from "./schema" From e65383810a3734024895e41944f6ae261366fe38 Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Fri, 15 May 2026 16:14:49 +0530 Subject: [PATCH 028/164] refactor(tool): read repo overview directory from instance state (#27717) --- packages/opencode/src/tool/repo_overview.ts | 4 ++-- packages/opencode/test/tool/repo_overview.test.ts | 15 +++++++++++++++ 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/tool/repo_overview.ts b/packages/opencode/src/tool/repo_overview.ts index b08516d2c60..d6ff0c184d0 100644 --- a/packages/opencode/src/tool/repo_overview.ts +++ b/packages/opencode/src/tool/repo_overview.ts @@ -6,7 +6,7 @@ import { assertExternalDirectoryEffect } from "./external-directory" import DESCRIPTION from "./repo_overview.txt" import * as Tool from "./tool" import { parseRepositoryReference, repositoryCachePath } from "@/util/repository" -import { Instance } from "@/project/instance" +import { InstanceState } from "@/effect/instance-state" export const Parameters = Schema.Struct({ repository: Schema.optional(Schema.String).annotate({ @@ -108,7 +108,7 @@ export const RepoOverviewTool = Tool.define, ) { if (params.path) { - const full = path.isAbsolute(params.path) ? params.path : path.resolve(Instance.directory, params.path) + const full = path.isAbsolute(params.path) ? params.path : path.resolve(yield* InstanceState.directory, params.path) return { path: full, repository: params.repository } } diff --git a/packages/opencode/test/tool/repo_overview.test.ts b/packages/opencode/test/tool/repo_overview.test.ts index 556fa05d1fb..c854e51a3fd 100644 --- a/packages/opencode/test/tool/repo_overview.test.ts +++ b/packages/opencode/test/tool/repo_overview.test.ts @@ -97,6 +97,21 @@ describe("tool.repo_overview", () => { ), ) + it.live("resolves relative paths from the instance directory", () => + provideTmpdirInstance((dir) => + Effect.gen(function* () { + const fs = yield* AppFileSystem.Service + yield* fs.writeWithDirs(path.join(dir, "nested", "README.md"), "# Nested\n") + + const tool = yield* init() + const result = yield* tool.execute({ path: "nested" }, ctx) + + expect(result.metadata.path).toBe(path.join(dir, "nested")) + expect(result.output).toContain("README.md") + }), + ), + ) + it.live("resolves a cached repository from repository shorthand", () => provideTmpdirInstance((_dir) => Effect.gen(function* () { From 727a83aa7a55897f5fe591fd97697e4277423872 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Fri, 15 May 2026 10:46:06 +0000 Subject: [PATCH 029/164] chore: generate --- packages/opencode/src/tool/repo_overview.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/tool/repo_overview.ts b/packages/opencode/src/tool/repo_overview.ts index d6ff0c184d0..e8fd0b81eb5 100644 --- a/packages/opencode/src/tool/repo_overview.ts +++ b/packages/opencode/src/tool/repo_overview.ts @@ -108,7 +108,9 @@ export const RepoOverviewTool = Tool.define, ) { if (params.path) { - const full = path.isAbsolute(params.path) ? params.path : path.resolve(yield* InstanceState.directory, params.path) + const full = path.isAbsolute(params.path) + ? params.path + : path.resolve(yield* InstanceState.directory, params.path) return { path: full, repository: params.repository } } From bf64f8cbb54477b365a08deefa5836c44c908a52 Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Fri, 15 May 2026 16:30:54 +0530 Subject: [PATCH 030/164] refactor(cli): dispose bootstrap instance explicitly (#27721) --- packages/opencode/src/cli/bootstrap.ts | 20 ++++++--------- .../test/project/instance-bootstrap.test.ts | 25 ++++++++++++++++++- 2 files changed, 31 insertions(+), 14 deletions(-) diff --git a/packages/opencode/src/cli/bootstrap.ts b/packages/opencode/src/cli/bootstrap.ts index fa39ecb177b..2308c29199b 100644 --- a/packages/opencode/src/cli/bootstrap.ts +++ b/packages/opencode/src/cli/bootstrap.ts @@ -1,17 +1,11 @@ -import { Instance } from "../project/instance" import { InstanceRuntime } from "../project/instance-runtime" -import { WithInstance } from "../project/with-instance" +import { context } from "../project/instance-context" export async function bootstrap(directory: string, cb: () => Promise) { - return WithInstance.provide({ - directory, - fn: async () => { - try { - const result = await cb() - return result - } finally { - await InstanceRuntime.disposeInstance(Instance.current) - } - }, - }) + const ctx = await InstanceRuntime.load({ directory }) + try { + return await context.provide(ctx, cb) + } finally { + await InstanceRuntime.disposeInstance(ctx) + } } diff --git a/packages/opencode/test/project/instance-bootstrap.test.ts b/packages/opencode/test/project/instance-bootstrap.test.ts index 4be2a76113d..df6b76aa024 100644 --- a/packages/opencode/test/project/instance-bootstrap.test.ts +++ b/packages/opencode/test/project/instance-bootstrap.test.ts @@ -3,12 +3,13 @@ import { existsSync } from "node:fs" import path from "node:path" import { pathToFileURL } from "node:url" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" -import { Effect, Layer } from "effect" +import { Cause, Effect, Exit, Fiber, Layer } from "effect" import { bootstrap as cliBootstrap } from "../../src/cli/bootstrap" import { InstanceLayer } from "../../src/project/instance-layer" import { InstanceStore } from "../../src/project/instance-store" import { disposeAllInstances, tmpdirScoped } from "../fixture/fixture" import { testEffect } from "../lib/effect" +import { waitGlobalBusEvent } from "../server/global-bus" const it = testEffect(Layer.mergeAll(InstanceLayer.layer, CrossSpawnSpawner.defaultLayer)) @@ -54,6 +55,13 @@ const bootstrapFixture = Effect.gen(function* () { return { directory: dir, marker } }) +function waitDisposed(directory: string) { + return waitGlobalBusEvent({ + message: "timed out waiting for CLI bootstrap instance disposal", + predicate: (event) => event.payload.type === "server.instance.disposed" && event.directory === directory, + }) +} + it.live("InstanceStore.provide runs InstanceBootstrap before effect", () => Effect.gen(function* () { const tmp = yield* bootstrapFixture @@ -75,6 +83,21 @@ it.live("CLI bootstrap runs InstanceBootstrap before callback", () => }), ) +it.live("CLI bootstrap disposes the instance when the callback rejects", () => + Effect.gen(function* () { + const tmp = yield* bootstrapFixture + const disposed = yield* waitDisposed(tmp.directory).pipe(Effect.forkScoped) + + const exit = yield* Effect.promise(() => cliBootstrap(tmp.directory, async () => Promise.reject(new Error("boom")))).pipe( + Effect.exit, + ) + + expect(Exit.isFailure(exit)).toBe(true) + if (Exit.isFailure(exit)) expect(Cause.squash(exit.cause)).toMatchObject({ message: "boom" }) + yield* Fiber.join(disposed) + }), +) + it.live("InstanceStore.reload runs InstanceBootstrap", () => Effect.gen(function* () { const tmp = yield* bootstrapFixture From 984eefa6f89c04311480dc759b5d3651c56509ae Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Fri, 15 May 2026 11:02:14 +0000 Subject: [PATCH 031/164] chore: generate --- packages/opencode/test/project/instance-bootstrap.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/opencode/test/project/instance-bootstrap.test.ts b/packages/opencode/test/project/instance-bootstrap.test.ts index df6b76aa024..c5b18cc5b8d 100644 --- a/packages/opencode/test/project/instance-bootstrap.test.ts +++ b/packages/opencode/test/project/instance-bootstrap.test.ts @@ -88,9 +88,9 @@ it.live("CLI bootstrap disposes the instance when the callback rejects", () => const tmp = yield* bootstrapFixture const disposed = yield* waitDisposed(tmp.directory).pipe(Effect.forkScoped) - const exit = yield* Effect.promise(() => cliBootstrap(tmp.directory, async () => Promise.reject(new Error("boom")))).pipe( - Effect.exit, - ) + const exit = yield* Effect.promise(() => + cliBootstrap(tmp.directory, async () => Promise.reject(new Error("boom"))), + ).pipe(Effect.exit) expect(Exit.isFailure(exit)).toBe(true) if (Exit.isFailure(exit)) expect(Cause.squash(exit.cause)).toMatchObject({ message: "boom" }) From 1c7c03332e9de93c4da90704bf4bdde9353154fa Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Fri, 15 May 2026 17:07:21 +0530 Subject: [PATCH 032/164] test(workspace): avoid legacy instance reads (#27727) --- .../test/control-plane/workspace.test.ts | 199 ++++++++++-------- 1 file changed, 106 insertions(+), 93 deletions(-) diff --git a/packages/opencode/test/control-plane/workspace.test.ts b/packages/opencode/test/control-plane/workspace.test.ts index 6535428ce24..00cd32a3f77 100644 --- a/packages/opencode/test/control-plane/workspace.test.ts +++ b/packages/opencode/test/control-plane/workspace.test.ts @@ -14,8 +14,7 @@ import { GlobalBus, type GlobalEvent } from "@/bus/global" import { Database } from "@/storage/db" import { ProjectID } from "@/project/schema" import { ProjectTable } from "@/project/project.sql" -import { Instance } from "@/project/instance" -import { WithInstance } from "../../src/project/with-instance" +import { context, type InstanceContext } from "@/project/instance-context" import { InstanceRef } from "@/effect/instance-ref" import { Session as SessionNs } from "@/session/session" import { SessionID } from "@/session/schema" @@ -122,12 +121,10 @@ afterEach(async () => { await resetDatabase() }) -async function withInstance(fn: (dir: string) => T | Promise) { +async function withInstance(fn: (ctx: InstanceContext) => T | Promise) { await using tmp = await tmpdir({ git: true }) - return await WithInstance.provide({ - directory: tmp.path, - fn: () => fn(tmp.path), - }) + const ctx = await AppRuntime.runPromise(InstanceStore.Service.use((store) => store.load({ directory: tmp.path }))) + return await context.provide(ctx, () => fn(ctx)) } async function initGitRepo(dir: string) { @@ -416,16 +413,16 @@ describe("workspace CRUD", () => { }) test("list maps database rows, filters by project, and sorts by id", async () => { - await withInstance(async () => { + await withInstance(async (instance) => { const otherProjectID = ProjectID.make("project-other") insertProject(otherProjectID, "/tmp/other") - const a = workspaceInfo(Instance.project.id, "manual", { + const a = workspaceInfo(instance.project.id, "manual", { id: WorkspaceID.ascending("wrk_a_list"), branch: "a", directory: "/a", extra: { a: true }, }) - const b = workspaceInfo(Instance.project.id, "manual", { + const b = workspaceInfo(instance.project.id, "manual", { id: WorkspaceID.ascending("wrk_b_list"), branch: "b", directory: "/b", @@ -436,12 +433,12 @@ describe("workspace CRUD", () => { insertWorkspace(other) insertWorkspace(a) - expect(await listWorkspaces(Instance.project)).toEqual([a, b]) + expect(await listWorkspaces(instance.project)).toEqual([a, b]) }) }) test("create configures, persists, creates, starts local sync, and passes environment", async () => { - await withInstance(async (dir) => { + await withInstance(async (instance) => { process.env.OPENCODE_AUTH_CONTENT = JSON.stringify({ test: { type: "api", key: "secret" } }) process.env.OTEL_EXPORTER_OTLP_HEADERS = "authorization=otel" process.env.OTEL_EXPORTER_OTLP_ENDPOINT = "https://otel.test" @@ -449,7 +446,7 @@ describe("workspace CRUD", () => { const workspaceID = WorkspaceID.ascending("wrk_create_local") const type = unique("create-local") - const targetDir = path.join(dir, "created-local") + const targetDir = path.join(instance.directory, "created-local") const recorded = recordedAdapter({ configure(info) { return { @@ -467,13 +464,13 @@ describe("workspace CRUD", () => { return { type: "local", directory: targetDir } }, }) - registerAdapter(Instance.project.id, type, recorded.adapter) + registerAdapter(instance.project.id, type, recorded.adapter) const info = await createWorkspace({ id: workspaceID, type, branch: null, - projectID: Instance.project.id, + projectID: instance.project.id, extra: null, }) @@ -484,11 +481,11 @@ describe("workspace CRUD", () => { name: "Configured Name", directory: targetDir, extra: { configured: true }, - projectID: Instance.project.id, + projectID: instance.project.id, timeUsed: info.timeUsed, }) expect(await getWorkspace(workspaceID)).toEqual(info) - expect(await listWorkspaces(Instance.project)).toEqual([info]) + expect(await listWorkspaces(instance.project)).toEqual([info]) expect(recorded.calls.configure).toHaveLength(1) expect(recorded.calls.configure[0]).toMatchObject({ id: workspaceID, type, directory: null }) expect(recorded.calls.create).toHaveLength(1) @@ -499,7 +496,7 @@ describe("workspace CRUD", () => { name: "Configured Name", directory: targetDir, extra: { configured: true }, - projectID: Instance.project.id, + projectID: instance.project.id, }) expect(JSON.parse(recorded.calls.create[0].env.OPENCODE_AUTH_CONTENT ?? "{}")).toEqual({ test: { type: "api", key: "secret" }, @@ -517,10 +514,10 @@ describe("workspace CRUD", () => { }) test("create propagates configure failures and does not insert a workspace", async () => { - await withInstance(async () => { + await withInstance(async (instance) => { const type = unique("configure-failure") registerAdapter( - Instance.project.id, + instance.project.id, type, recordedAdapter({ configure() { @@ -533,14 +530,14 @@ describe("workspace CRUD", () => { ) await expect( - createWorkspace({ type, branch: null, projectID: Instance.project.id, extra: null }), + createWorkspace({ type, branch: null, projectID: instance.project.id, extra: null }), ).rejects.toThrow("configure exploded") - expect(await listWorkspaces(Instance.project)).toEqual([]) + expect(await listWorkspaces(instance.project)).toEqual([]) }) }) test("create leaves the inserted row when adapter create fails", async () => { - await withInstance(async () => { + await withInstance(async (instance) => { const type = unique("create-failure") const recorded = recordedAdapter({ async create() { @@ -550,13 +547,13 @@ describe("workspace CRUD", () => { return { type: "local", directory: "/unused" } }, }) - registerAdapter(Instance.project.id, type, recorded.adapter) + registerAdapter(instance.project.id, type, recorded.adapter) await expect( - createWorkspace({ type, branch: "branch", projectID: Instance.project.id, extra: { x: 1 } }), + createWorkspace({ type, branch: "branch", projectID: instance.project.id, extra: { x: 1 } }), ).rejects.toThrow("create exploded") - const rows = await listWorkspaces(Instance.project) + const rows = await listWorkspaces(instance.project) expect(rows).toHaveLength(1) expect(rows[0]).toMatchObject({ type, branch: "branch", extra: { x: 1 } }) expect(recorded.calls.target).toHaveLength(0) @@ -565,13 +562,13 @@ describe("workspace CRUD", () => { }) test("create returns after a local workspace reports error", async () => { - await withInstance(async (dir) => { + await withInstance(async (instance) => { const type = unique("local-error") - const missing = path.join(dir, "missing-local-target") + const missing = path.join(instance.directory, "missing-local-target") const recorded = localAdapter(missing, { createDir: false }) - registerAdapter(Instance.project.id, type, recorded.adapter) + registerAdapter(instance.project.id, type, recorded.adapter) - const info = await createWorkspace({ type, branch: null, projectID: Instance.project.id, extra: null }) + const info = await createWorkspace({ type, branch: null, projectID: instance.project.id, extra: null }) expect(info.directory).toBe(missing) expect((await workspaceStatus()).find((item) => item.workspaceID === info.id)?.status).toBe("error") @@ -580,12 +577,12 @@ describe("workspace CRUD", () => { }) test("syncList registers adapter-listed workspaces that are missing by name", async () => { - await withInstance(async (dir) => { + await withInstance(async (instance) => { const type = unique("list-sync") - const existing = workspaceInfo(Instance.project.id, type, { + const existing = workspaceInfo(instance.project.id, type, { id: WorkspaceID.ascending("wrk_list_sync_existing"), name: "existing", - directory: path.join(dir, "existing"), + directory: path.join(instance.directory, "existing"), }) insertWorkspace(existing) @@ -593,9 +590,9 @@ describe("workspace CRUD", () => { type, name: "discovered", branch: "feature/discovered", - directory: path.join(dir, "discovered"), + directory: path.join(instance.directory, "discovered"), extra: { source: "adapter" }, - projectID: Instance.project.id, + projectID: instance.project.id, } const recorded = recordedAdapter({ list() { @@ -604,26 +601,26 @@ describe("workspace CRUD", () => { type, name: existing.name, branch: "ignored", - directory: path.join(dir, "ignored"), + directory: path.join(instance.directory, "ignored"), extra: null, - projectID: Instance.project.id, + projectID: instance.project.id, }, discovered, ] }, target(info) { - return { type: "local", directory: info.directory ?? dir } + return { type: "local", directory: info.directory ?? instance.directory } }, }) - registerAdapter(Instance.project.id, type, recorded.adapter) + registerAdapter(instance.project.id, type, recorded.adapter) - await syncListWorkspaces(Instance.project) - const synced = (await listWorkspaces(Instance.project)).filter((item) => item.name === discovered.name) + await syncListWorkspaces(instance.project) + const synced = (await listWorkspaces(instance.project)).filter((item) => item.name === discovered.name) expect(synced).toHaveLength(1) expect(synced[0]).toMatchObject(discovered) expect(synced[0]?.id).toStartWith("wrk_") - expect(await listWorkspaces(Instance.project)).toEqual(expect.arrayContaining([existing, synced[0]])) + expect(await listWorkspaces(instance.project)).toEqual(expect.arrayContaining([existing, synced[0]])) expect(recorded.calls.list).toBe(1) expect(recorded.calls.configure).toHaveLength(0) expect(recorded.calls.create).toHaveLength(0) @@ -632,7 +629,7 @@ describe("workspace CRUD", () => { }) test("syncList calls every registered adapter with a list method", async () => { - await withInstance(async (dir) => { + await withInstance(async (instance) => { const typeA = unique("list-sync-a") const typeB = unique("list-sync-b") const adapterA = recordedAdapter({ @@ -642,14 +639,14 @@ describe("workspace CRUD", () => { type: typeA, name: "adapter-a", branch: null, - directory: path.join(dir, "adapter-a"), + directory: path.join(instance.directory, "adapter-a"), extra: null, - projectID: Instance.project.id, + projectID: instance.project.id, }, ] }, target(info) { - return { type: "local", directory: info.directory ?? dir } + return { type: "local", directory: info.directory ?? instance.directory } }, }) const adapterB = recordedAdapter({ @@ -659,27 +656,27 @@ describe("workspace CRUD", () => { type: typeB, name: "adapter-b", branch: null, - directory: path.join(dir, "adapter-b"), + directory: path.join(instance.directory, "adapter-b"), extra: null, - projectID: Instance.project.id, + projectID: instance.project.id, }, ] }, target(info) { - return { type: "local", directory: info.directory ?? dir } + return { type: "local", directory: info.directory ?? instance.directory } }, }) const noList = recordedAdapter({ target() { - return { type: "local", directory: dir } + return { type: "local", directory: instance.directory } }, }) - registerAdapter(Instance.project.id, typeA, adapterA.adapter) - registerAdapter(Instance.project.id, typeB, adapterB.adapter) - registerAdapter(Instance.project.id, unique("list-sync-none"), noList.adapter) + registerAdapter(instance.project.id, typeA, adapterA.adapter) + registerAdapter(instance.project.id, typeB, adapterB.adapter) + registerAdapter(instance.project.id, unique("list-sync-none"), noList.adapter) - await syncListWorkspaces(Instance.project) - const synced = await listWorkspaces(Instance.project) + await syncListWorkspaces(instance.project) + const synced = await listWorkspaces(instance.project) expect( synced @@ -719,11 +716,13 @@ describe("workspace CRUD", () => { (dir) => Effect.gen(function* () { const workspace = yield* Workspace.Service + const instance = yield* InstanceRef + if (!instance) return yield* Effect.die(new Error("missing test instance")) const type = unique("remote-create") const recorded = remoteAdapter(`${url}/base/?ignored=1#hash`, { directory: dir }) - registerAdapter(Instance.project.id, type, recorded.adapter) + registerAdapter(instance.project.id, type, recorded.adapter) - const info = yield* workspace.create({ type, branch: null, projectID: Instance.project.id, extra: null }) + const info = yield* workspace.create({ type, branch: null, projectID: instance.project.id, extra: null }) expect( calls.map((call) => `${call.method} ${call.url.pathname}${call.url.search}${call.url.hash}`), @@ -782,11 +781,11 @@ describe("workspace CRUD", () => { ) test("remove still deletes the row when the adapter cannot remove resources", async () => { - await withInstance(async () => { + await withInstance(async (instance) => { const type = unique("remove-throws") - const info = workspaceInfo(Instance.project.id, type, { id: WorkspaceID.ascending("wrk_remove_throws") }) + const info = workspaceInfo(instance.project.id, type, { id: WorkspaceID.ascending("wrk_remove_throws") }) registerAdapter( - Instance.project.id, + instance.project.id, type, recordedAdapter({ async remove() { @@ -911,8 +910,8 @@ describe("workspace CRUD", () => { ) test("sessionWarp detaches to the source project when invoked from a workspace instance", async () => { - await withInstance(async () => { - const projectID = Instance.project.id + await withInstance(async (instance) => { + const projectID = instance.project.id await using workspaceTmp = await tmpdir({ git: true }) const previousType = unique("warp-detach-workspace-instance") const previous = workspaceInfo(projectID, previousType) @@ -921,14 +920,14 @@ describe("workspace CRUD", () => { const session = await AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.create({}))) attachSessionToWorkspace(session.id, previous.id) - const workspaceProjectID = await WithInstance.provide({ - directory: workspaceTmp.path, - fn: async () => { - const id = Instance.project.id - expect(id).not.toBe(projectID) - await warpWorkspaceSession({ workspaceID: null, sessionID: session.id }) - return id - }, + const workspaceCtx = await AppRuntime.runPromise( + InstanceStore.Service.use((store) => store.load({ directory: workspaceTmp.path })), + ) + const workspaceProjectID = await context.provide(workspaceCtx, async () => { + const id = workspaceCtx.project.id + expect(id).not.toBe(projectID) + await warpWorkspaceSession({ workspaceID: null, sessionID: session.id }) + return id }) expect( @@ -988,14 +987,16 @@ describe("workspace CRUD", () => { Effect.gen(function* () { const workspace = yield* Workspace.Service const sessionSvc = yield* SessionNs.Service + const instance = yield* InstanceRef + if (!instance) return yield* Effect.die(new Error("missing test instance")) const previousType = unique("warp-remote-source") const targetType = unique("warp-remote-target") - const previous = workspaceInfo(Instance.project.id, previousType) - const target = workspaceInfo(Instance.project.id, targetType, { directory: "remote-target-dir" }) + const previous = workspaceInfo(instance.project.id, previousType) + const target = workspaceInfo(instance.project.id, targetType, { directory: "remote-target-dir" }) insertWorkspace(previous) insertWorkspace(target) - registerAdapter(Instance.project.id, previousType, remoteAdapter(`${url}/warp-source`).adapter) - registerAdapter(Instance.project.id, targetType, remoteAdapter(`${url}/warp-target`).adapter) + registerAdapter(instance.project.id, previousType, remoteAdapter(`${url}/warp-source`).adapter) + registerAdapter(instance.project.id, targetType, remoteAdapter(`${url}/warp-target`).adapter) const session = yield* sessionSvc.create({}) attachSessionToWorkspace(session.id, previous.id) historySessionID = session.id @@ -1197,15 +1198,17 @@ describe("workspace sync state", () => { Effect.gen(function* () { const workspace = yield* Workspace.Service const sessionSvc = yield* SessionNs.Service + const instance = yield* InstanceRef + if (!instance) return yield* Effect.die(new Error("missing test instance")) const captured = captureGlobalEvents() try { const type = unique("remote-start") - const info = workspaceInfo(Instance.project.id, type) + const info = workspaceInfo(instance.project.id, type) insertWorkspace(info) - registerAdapter(Instance.project.id, type, remoteAdapter(`${url}/sync`).adapter) + registerAdapter(instance.project.id, type, remoteAdapter(`${url}/sync`).adapter) attachSessionToWorkspace((yield* sessionSvc.create({})).id, info.id) - yield* workspace.startWorkspaceSyncing(Instance.project.id) + yield* workspace.startWorkspaceSyncing(instance.project.id) yield* eventuallyEffect( Effect.gen(function* () { expect((yield* workspace.status()).find((item) => item.workspaceID === info.id)?.status).toBe( @@ -1213,7 +1216,7 @@ describe("workspace sync state", () => { ) }), ) - yield* workspace.startWorkspaceSyncing(Instance.project.id) + yield* workspace.startWorkspaceSyncing(instance.project.id) yield* Effect.sleep("25 millis") expect( @@ -1252,13 +1255,15 @@ describe("workspace sync state", () => { Effect.gen(function* () { const workspace = yield* Workspace.Service const sessionSvc = yield* SessionNs.Service + const instance = yield* InstanceRef + if (!instance) return yield* Effect.die(new Error("missing test instance")) const type = unique("remote-connect-fail") - const info = workspaceInfo(Instance.project.id, type) + const info = workspaceInfo(instance.project.id, type) insertWorkspace(info) - registerAdapter(Instance.project.id, type, remoteAdapter(`${url}/failed`).adapter) + registerAdapter(instance.project.id, type, remoteAdapter(`${url}/failed`).adapter) attachSessionToWorkspace((yield* sessionSvc.create({})).id, info.id) - yield* workspace.startWorkspaceSyncing(Instance.project.id) + yield* workspace.startWorkspaceSyncing(instance.project.id) yield* eventuallyEffect( Effect.gen(function* () { @@ -1292,13 +1297,15 @@ describe("workspace sync state", () => { Effect.gen(function* () { const workspace = yield* Workspace.Service const sessionSvc = yield* SessionNs.Service + const instance = yield* InstanceRef + if (!instance) return yield* Effect.die(new Error("missing test instance")) const type = unique("remote-history-fail") - const info = workspaceInfo(Instance.project.id, type) + const info = workspaceInfo(instance.project.id, type) insertWorkspace(info) - registerAdapter(Instance.project.id, type, remoteAdapter(`${url}/history-failed`).adapter) + registerAdapter(instance.project.id, type, remoteAdapter(`${url}/history-failed`).adapter) attachSessionToWorkspace((yield* sessionSvc.create({})).id, info.id) - yield* workspace.startWorkspaceSyncing(Instance.project.id) + yield* workspace.startWorkspaceSyncing(instance.project.id) yield* eventuallyEffect( Effect.gen(function* () { @@ -1347,18 +1354,20 @@ describe("workspace sync state", () => { Effect.gen(function* () { const workspace = yield* Workspace.Service const sessionSvc = yield* SessionNs.Service + const instance = yield* InstanceRef + if (!instance) return yield* Effect.die(new Error("missing test instance")) const captured = captureGlobalEvents() try { const type = unique("history-replay") - const info = workspaceInfo(Instance.project.id, type) + const info = workspaceInfo(instance.project.id, type) insertWorkspace(info) - registerAdapter(Instance.project.id, type, remoteAdapter(`${url}/history`).adapter) + registerAdapter(instance.project.id, type, remoteAdapter(`${url}/history`).adapter) const session = yield* sessionSvc.create({ title: "before history" }) attachSessionToWorkspace(session.id, info.id) historySessionID = session.id historyNextSeq = (sessionSequence(session.id) ?? -1) + 1 - yield* workspace.startWorkspaceSyncing(Instance.project.id) + yield* workspace.startWorkspaceSyncing(instance.project.id) yield* eventuallyEffect( Effect.gen(function* () { @@ -1414,15 +1423,17 @@ describe("workspace sync state", () => { Effect.gen(function* () { const workspace = yield* Workspace.Service const sessionSvc = yield* SessionNs.Service + const instance = yield* InstanceRef + if (!instance) return yield* Effect.die(new Error("missing test instance")) const captured = captureGlobalEvents() try { const type = unique("sse-forward") - const info = workspaceInfo(Instance.project.id, type) + const info = workspaceInfo(instance.project.id, type) insertWorkspace(info) - registerAdapter(Instance.project.id, type, remoteAdapter(`${url}/sse-forward`).adapter) + registerAdapter(instance.project.id, type, remoteAdapter(`${url}/sse-forward`).adapter) attachSessionToWorkspace((yield* sessionSvc.create({})).id, info.id) - yield* workspace.startWorkspaceSyncing(Instance.project.id) + yield* workspace.startWorkspaceSyncing(instance.project.id) yield* eventuallyEffect( Effect.sync(() => @@ -1495,18 +1506,20 @@ describe("workspace sync state", () => { Effect.gen(function* () { const workspace = yield* Workspace.Service const sessionSvc = yield* SessionNs.Service + const instance = yield* InstanceRef + if (!instance) return yield* Effect.die(new Error("missing test instance")) const captured = captureGlobalEvents() try { const type = unique("sse-sync") - const info = workspaceInfo(Instance.project.id, type) + const info = workspaceInfo(instance.project.id, type) insertWorkspace(info) - registerAdapter(Instance.project.id, type, remoteAdapter(`${url}/sse-sync`).adapter) + registerAdapter(instance.project.id, type, remoteAdapter(`${url}/sse-sync`).adapter) const session = yield* sessionSvc.create({ title: "before sse" }) attachSessionToWorkspace(session.id, info.id) sseSessionID = session.id sseNextSeq = (sessionSequence(session.id) ?? -1) + 1 - yield* workspace.startWorkspaceSyncing(Instance.project.id) + yield* workspace.startWorkspaceSyncing(instance.project.id) yield* eventuallyEffect( Effect.gen(function* () { From 104f5d5a14330db039d934bb6e63802ac698a3de Mon Sep 17 00:00:00 2001 From: vimtor Date: Fri, 15 May 2026 15:41:51 +0200 Subject: [PATCH 033/164] chore: exclude provider from triggers --- infra/monitoring.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/infra/monitoring.ts b/infra/monitoring.ts index 240e6c97ee1..bc93e7d3e2e 100644 --- a/infra/monitoring.ts +++ b/infra/monitoring.ts @@ -73,6 +73,7 @@ const modelHttpErrorsQuery = (product: "go" | "zen") => { const providerHttpErrorsQuery = (product: "go" | "zen") => { const filters = [ { column: "provider", op: "exists" }, + { column: "provider", op: "!=", value: "fireworks-go-glm-5.1" }, { column: "user_agent", op: "contains", value: "opencode" }, { column: "isGoTier", op: "=", value: product === "go" ? "true" : "false" }, ] From c2ffd7cf149448675f554fdec61201615ed3315a Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Fri, 15 May 2026 09:22:24 -0500 Subject: [PATCH 034/164] fix: markdown table rendering (#27747) --- packages/opencode/src/cli/cmd/tui/routes/session/index.tsx | 1 + script/github/close-prs.ts | 0 2 files changed, 1 insertion(+) mode change 100644 => 100755 script/github/close-prs.ts diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index 70b5570ad55..e1922bfed54 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -1533,6 +1533,7 @@ function TextPart(props: { last: boolean; part: TextPart; message: AssistantMess streaming={true} internalBlockMode="top-level" content={props.part.text.trim()} + tableOptions={{ style: "grid" }} conceal={ctx.conceal()} fg={theme.markdownText} bg={theme.background} diff --git a/script/github/close-prs.ts b/script/github/close-prs.ts old mode 100644 new mode 100755 From 2d90f325fc3bc60b60ff4f0c21b278f365625a83 Mon Sep 17 00:00:00 2001 From: Victor Navarro Date: Fri, 15 May 2026 16:31:59 +0200 Subject: [PATCH 035/164] ci: catch provider errors across all opencode tiers (#27495) --- infra/monitoring.ts | 28 ++++------------------------ 1 file changed, 4 insertions(+), 24 deletions(-) diff --git a/infra/monitoring.ts b/infra/monitoring.ts index bc93e7d3e2e..a2b48109938 100644 --- a/infra/monitoring.ts +++ b/infra/monitoring.ts @@ -70,12 +70,11 @@ const modelHttpErrorsQuery = (product: "go" | "zen") => { }).json } -const providerHttpErrorsQuery = (product: "go" | "zen") => { +const providerHttpErrorsQuery = () => { const filters = [ { column: "provider", op: "exists" }, { column: "provider", op: "!=", value: "fireworks-go-glm-5.1" }, { column: "user_agent", op: "contains", value: "opencode" }, - { column: "isGoTier", op: "=", value: product === "go" ? "true" : "false" }, ] const successHttpStatus = calculatedField({ name: "is_success_http_status", @@ -216,29 +215,10 @@ new honeycomb.Trigger("LowModelTpsZen", { ], }) -new honeycomb.Trigger("IncreasedProviderHttpErrorsGo", { - name: "Increased Provider HTTP Errors [Go]", +new honeycomb.Trigger("IncreasedProviderHttpErrors", { + name: "Increased Provider HTTP Errors", description, - queryJson: providerHttpErrorsQuery("go"), - alertType: "on_change", - frequency: 300, - thresholds: [{ op: ">=", value: 0.7, exceededLimit: 1 }], - recipients: [ - { - id: webhookRecipient.id, - notificationDetails: [ - { - variables: [{ name: "type", value: "provider_http_errors" }], - }, - ], - }, - ], -}) - -new honeycomb.Trigger("IncreasedProviderHttpErrorsZen", { - name: "Increased Provider HTTP Errors [Zen]", - description, - queryJson: providerHttpErrorsQuery("zen"), + queryJson: providerHttpErrorsQuery(), alertType: "on_change", frequency: 300, thresholds: [{ op: ">=", value: 0.7, exceededLimit: 1 }], From fa9a2cb24d9759d1830a5e87e9bc781d33120362 Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Fri, 15 May 2026 20:04:42 +0530 Subject: [PATCH 036/164] refactor(instance): remove remaining bind call sites (#27731) --- packages/opencode/src/effect/bridge.ts | 42 +++++++++++++++++++------- packages/opencode/src/file/watcher.ts | 5 +-- packages/opencode/src/session/llm.ts | 2 +- packages/opencode/src/storage/db.ts | 6 ++-- 4 files changed, 38 insertions(+), 17 deletions(-) diff --git a/packages/opencode/src/effect/bridge.ts b/packages/opencode/src/effect/bridge.ts index e987c901316..820a7009b8c 100644 --- a/packages/opencode/src/effect/bridge.ts +++ b/packages/opencode/src/effect/bridge.ts @@ -1,4 +1,4 @@ -import { Effect, Exit, Fiber } from "effect" +import { Context, Effect, Exit, Fiber } from "effect" import { WorkspaceContext } from "@/control-plane/workspace-context" import { Instance } from "@/project/instance" import type { InstanceContext } from "@/project/instance-context" @@ -11,6 +11,7 @@ export interface Shape { readonly promise: (effect: Effect.Effect) => Promise readonly fork: (effect: Effect.Effect) => Fiber.Fiber readonly run: (effect: Effect.Effect) => Effect.Effect + readonly bind: (fn: (...args: Args) => Result) => (...args: Args) => Result } function restore(instance: InstanceContext | undefined, workspace: WorkspaceID | undefined, fn: () => R): R { @@ -22,6 +23,28 @@ function restore(instance: InstanceContext | undefined, workspace: WorkspaceI return fn() } +function captureSync() { + const fiber = Fiber.getCurrent() + const value = fiber ? Context.getReferenceUnsafe(fiber.context, InstanceRef) : undefined + const instance = + value ?? + (() => { + try { + return Instance.current + } catch (err) { + if (!(err instanceof LocalContext.NotFound)) throw err + } + })() + const workspace = (fiber ? Context.getReferenceUnsafe(fiber.context, WorkspaceRef) : undefined) ?? + WorkspaceContext.workspaceID + return { instance, workspace } +} + +export const bind = (fn: (...args: Args) => Result) => { + const captured = captureSync() + return (...args: Args) => restore(captured.instance, captured.workspace, () => fn(...args)) +} + /** * Bridge from Effect into a Promise-returning JS callback while installing * legacy `Instance.context` and `WorkspaceContext` AsyncLocalStorage for @@ -45,16 +68,9 @@ export function make(): Effect.Effect { return Effect.gen(function* () { const ctx = yield* Effect.context() const value = yield* InstanceRef - const instance = - value ?? - (() => { - try { - return Instance.current - } catch (err) { - if (!(err instanceof LocalContext.NotFound)) throw err - } - })() - const workspace = (yield* WorkspaceRef) ?? WorkspaceContext.workspaceID + const captured = captureSync() + const instance = value ?? captured.instance + const workspace = (yield* WorkspaceRef) ?? captured.workspace const attach = (effect: Effect.Effect) => attachWith(effect, { instance, workspace }) const wrap = (effect: Effect.Effect) => attach(effect).pipe(Effect.provide(ctx)) as Effect.Effect @@ -72,6 +88,10 @@ export function make(): Effect.Effect { ), ) }), + bind: + (fn: (...args: Args) => Result) => + (...args: Args) => + restore(instance, workspace, () => fn(...args)), } satisfies Shape }) } diff --git a/packages/opencode/src/file/watcher.ts b/packages/opencode/src/file/watcher.ts index d940c7c4228..6c3a611d286 100644 --- a/packages/opencode/src/file/watcher.ts +++ b/packages/opencode/src/file/watcher.ts @@ -6,6 +6,7 @@ import { readdir, realpath } from "fs/promises" import path from "path" import { Bus } from "@/bus" import { BusEvent } from "@/bus/bus-event" +import { EffectBridge } from "@/effect/bridge" import { InstanceState } from "@/effect/instance-state" import { Flag } from "@opencode-ai/core/flag/flag" import { Git } from "@/git" @@ -88,13 +89,13 @@ export const layer = Layer.effect( if (!w) return log.info("watcher backend", { directory: ctx.directory, platform: process.platform, backend }) - + const bridge = yield* EffectBridge.make() const subs: ParcelWatcher.AsyncSubscription[] = [] yield* Effect.addFinalizer(() => Effect.promise(() => Promise.allSettled(subs.map((sub) => sub.unsubscribe()))), ) - const cb: ParcelWatcher.SubscribeCallback = InstanceState.bind((err, evts) => { + const cb: ParcelWatcher.SubscribeCallback = bridge.bind((err, evts) => { if (err) return for (const evt of evts) { if (evt.type === "create") void Bus.publish(Event.Updated, { file: evt.path, event: "add" }) diff --git a/packages/opencode/src/session/llm.ts b/packages/opencode/src/session/llm.ts index 55178788885..0cf3a2398f9 100644 --- a/packages/opencode/src/session/llm.ts +++ b/packages/opencode/src/session/llm.ts @@ -256,7 +256,7 @@ const live: Layer.Layer< const bridge = yield* EffectBridge.make() const approvedToolsForSession = new Set() - workflowModel.approvalHandler = InstanceState.bind(async (approvalTools) => { + workflowModel.approvalHandler = bridge.bind(async (approvalTools) => { const uniqueNames = [...new Set(approvalTools.map((t: { name: string }) => t.name))] as string[] // Auto-approve tools that were already approved in this session // (prevents infinite approval loops for server-side MCP tools) diff --git a/packages/opencode/src/storage/db.ts b/packages/opencode/src/storage/db.ts index 6cb819a6fd0..06f1f84a9ae 100644 --- a/packages/opencode/src/storage/db.ts +++ b/packages/opencode/src/storage/db.ts @@ -11,7 +11,7 @@ import path from "path" import { readFileSync, readdirSync, existsSync } from "fs" import { Flag } from "@opencode-ai/core/flag/flag" import { InstallationChannel } from "@opencode-ai/core/installation/version" -import { InstanceState } from "@/effect/instance-state" +import { EffectBridge } from "@/effect/bridge" import { init } from "#db" import { Effect, Schema } from "effect" @@ -167,7 +167,7 @@ export function use(callback: (trx: TxOrDb) => T): T { } export function effect(fn: () => any | Promise) { - const bound = InstanceState.bind(fn) + const bound = EffectBridge.bind(fn) try { ctx.use().effects.push(bound) } catch { @@ -188,7 +188,7 @@ export function transaction( } catch (err) { if (err instanceof LocalContext.NotFound) { const effects: (() => void | Promise)[] = [] - const txCallback = InstanceState.bind((tx: TxOrDb) => ctx.provide({ tx, effects }, () => callback(tx))) + const txCallback = EffectBridge.bind((tx: TxOrDb) => ctx.provide({ tx, effects }, () => callback(tx))) const result = Client().transaction(txCallback, { behavior: options?.behavior }) for (const effect of effects) effect() return result as NotPromise From f9371eb66c3369f138e23f91afa73afccf0c081d Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Fri, 15 May 2026 14:36:04 +0000 Subject: [PATCH 037/164] chore: generate --- packages/opencode/src/effect/bridge.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/effect/bridge.ts b/packages/opencode/src/effect/bridge.ts index 820a7009b8c..a0f2c224d04 100644 --- a/packages/opencode/src/effect/bridge.ts +++ b/packages/opencode/src/effect/bridge.ts @@ -35,8 +35,8 @@ function captureSync() { if (!(err instanceof LocalContext.NotFound)) throw err } })() - const workspace = (fiber ? Context.getReferenceUnsafe(fiber.context, WorkspaceRef) : undefined) ?? - WorkspaceContext.workspaceID + const workspace = + (fiber ? Context.getReferenceUnsafe(fiber.context, WorkspaceRef) : undefined) ?? WorkspaceContext.workspaceID return { instance, workspace } } From a2392ca60d974e5d4de907ec04f79e1668141225 Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Fri, 15 May 2026 20:30:29 +0530 Subject: [PATCH 038/164] refactor(worktree): provide runtime reentry refs (#27754) --- .../src/control-plane/adapters/worktree.ts | 44 +++++++++++++++---- 1 file changed, 36 insertions(+), 8 deletions(-) diff --git a/packages/opencode/src/control-plane/adapters/worktree.ts b/packages/opencode/src/control-plane/adapters/worktree.ts index 1c85d125a2d..0bfdeb3225c 100644 --- a/packages/opencode/src/control-plane/adapters/worktree.ts +++ b/packages/opencode/src/control-plane/adapters/worktree.ts @@ -1,4 +1,6 @@ -import { Schema } from "effect" +import { Effect, Schema } from "effect" +import { InstanceRef, WorkspaceRef } from "@/effect/instance-ref" +import { WorkspaceContext } from "../workspace-context" import { type WorkspaceAdapter, WorkspaceInfo } from "../types" const WorktreeConfig = Schema.Struct({ @@ -21,8 +23,15 @@ export const WorktreeAdapter: WorkspaceAdapter = { name: "Worktree", description: "Create a git worktree", async configure(info) { - const { AppRuntime, Worktree } = await loadWorktree() - const next = await AppRuntime.runPromise(Worktree.Service.use((svc) => svc.makeWorktreeInfo({ detached: true }))) + const { AppRuntime, Instance, Worktree } = await loadWorktree() + const ctx = Instance.current + const workspaceID = WorkspaceContext.workspaceID + const next = await AppRuntime.runPromise( + Worktree.Service.use((svc) => svc.makeWorktreeInfo({ detached: true })).pipe( + Effect.provideService(InstanceRef, ctx), + Effect.provideService(WorkspaceRef, workspaceID), + ), + ) return { ...info, name: next.name, @@ -30,7 +39,9 @@ export const WorktreeAdapter: WorkspaceAdapter = { } }, async create(info) { - const { AppRuntime, Worktree } = await loadWorktree() + const { AppRuntime, Instance, Worktree } = await loadWorktree() + const ctx = Instance.current + const workspaceID = WorkspaceContext.workspaceID const config = decodeWorktreeConfig(info) await AppRuntime.runPromise( Worktree.Service.use((svc) => @@ -39,23 +50,40 @@ export const WorktreeAdapter: WorkspaceAdapter = { directory: config.directory, ...(config.branch ? { branch: config.branch } : {}), }), + ).pipe( + Effect.provideService(InstanceRef, ctx), + Effect.provideService(WorkspaceRef, workspaceID), ), ) }, async list() { const { AppRuntime, Instance, Worktree } = await loadWorktree() - return (await AppRuntime.runPromise(Worktree.Service.use((svc) => svc.list()))).map((info) => ({ + const ctx = Instance.current + const workspaceID = WorkspaceContext.workspaceID + return (await AppRuntime.runPromise( + Worktree.Service.use((svc) => svc.list()).pipe( + Effect.provideService(InstanceRef, ctx), + Effect.provideService(WorkspaceRef, workspaceID), + ), + )).map((info) => ({ type: "worktree", name: info.name, branch: info.branch, directory: info.directory, - projectID: Instance.project.id, + projectID: ctx.project.id, })) }, async remove(info) { - const { AppRuntime, Worktree } = await loadWorktree() + const { AppRuntime, Instance, Worktree } = await loadWorktree() + const ctx = Instance.current + const workspaceID = WorkspaceContext.workspaceID const config = decodeWorktreeConfig(info) - await AppRuntime.runPromise(Worktree.Service.use((svc) => svc.remove({ directory: config.directory }))) + await AppRuntime.runPromise( + Worktree.Service.use((svc) => svc.remove({ directory: config.directory })).pipe( + Effect.provideService(InstanceRef, ctx), + Effect.provideService(WorkspaceRef, workspaceID), + ), + ) }, target(info) { const config = decodeWorktreeConfig(info) From eb630075c3d30ca55b9d581413e9f1e80f4aa1c4 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Fri, 15 May 2026 15:01:55 +0000 Subject: [PATCH 039/164] chore: generate --- .../src/control-plane/adapters/worktree.ts | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/packages/opencode/src/control-plane/adapters/worktree.ts b/packages/opencode/src/control-plane/adapters/worktree.ts index 0bfdeb3225c..81d9990e7cd 100644 --- a/packages/opencode/src/control-plane/adapters/worktree.ts +++ b/packages/opencode/src/control-plane/adapters/worktree.ts @@ -50,22 +50,21 @@ export const WorktreeAdapter: WorkspaceAdapter = { directory: config.directory, ...(config.branch ? { branch: config.branch } : {}), }), - ).pipe( - Effect.provideService(InstanceRef, ctx), - Effect.provideService(WorkspaceRef, workspaceID), - ), + ).pipe(Effect.provideService(InstanceRef, ctx), Effect.provideService(WorkspaceRef, workspaceID)), ) }, async list() { const { AppRuntime, Instance, Worktree } = await loadWorktree() const ctx = Instance.current const workspaceID = WorkspaceContext.workspaceID - return (await AppRuntime.runPromise( - Worktree.Service.use((svc) => svc.list()).pipe( - Effect.provideService(InstanceRef, ctx), - Effect.provideService(WorkspaceRef, workspaceID), - ), - )).map((info) => ({ + return ( + await AppRuntime.runPromise( + Worktree.Service.use((svc) => svc.list()).pipe( + Effect.provideService(InstanceRef, ctx), + Effect.provideService(WorkspaceRef, workspaceID), + ), + ) + ).map((info) => ({ type: "worktree", name: info.name, branch: info.branch, From ef7d8012719871e6d9b1955f8699077970089519 Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Fri, 15 May 2026 10:11:01 -0500 Subject: [PATCH 040/164] fix(tool): preserve custom tool arg descriptions (#27750) Co-authored-by: khimaros <231498+khimaros@users.noreply.github.com> --- packages/opencode/src/tool/registry.ts | 9 ++- packages/opencode/test/tool/registry.test.ts | 67 +++++++++++++++++++- packages/plugin/src/tool.ts | 8 ++- 3 files changed, 81 insertions(+), 3 deletions(-) diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index 5869b50a2a3..8d4dd5440a7 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -145,7 +145,14 @@ export const layer: Layer.Layer< const entries = Object.entries(def.args) const allZod = entries.every((entry) => isZodType(entry[1])) const zodParams = allZod ? z.object(def.args) : undefined - const jsonSchema = zodParams ? zodJsonSchema(zodParams) : legacyJsonSchema(entries) + // Newer @opencode-ai/plugin versions precompute JSON Schema with the + // Zod instance that owns arg metadata. Fall back for older/manual + // custom tools that only expose raw Zod args. + const jsonSchema = zodParams + ? isJsonSchemaDefinition(def.jsonSchema) + ? (def.jsonSchema as JSONSchema7) + : zodJsonSchema(zodParams) + : legacyJsonSchema(entries) const parameters = zodParams ? Schema.declare((u): u is unknown => zodParams.safeParse(u).success) : Schema.Unknown diff --git a/packages/opencode/test/tool/registry.test.ts b/packages/opencode/test/tool/registry.test.ts index 0a96a689cde..84d2ff8a39e 100644 --- a/packages/opencode/test/tool/registry.test.ts +++ b/packages/opencode/test/tool/registry.test.ts @@ -1,7 +1,7 @@ import { afterEach, describe, expect } from "bun:test" import path from "path" import fs from "fs/promises" -import { pathToFileURL } from "url" +import { fileURLToPath, pathToFileURL } from "url" import { Effect, Layer, Result, Schema } from "effect" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { ToolRegistry } from "@/tool/registry" @@ -263,6 +263,71 @@ describe("tool.registry", () => { }), ) + it.instance("preserves Zod arg descriptions from config-scoped plugin packages", () => + Effect.gen(function* () { + const test = yield* TestInstance + const opencode = path.join(test.directory, ".opencode") + const customTools = path.join(opencode, "tools") + const plugin = path.join(opencode, "node_modules", "@opencode-ai", "plugin") + yield* Effect.promise(() => fs.mkdir(path.join(plugin, "dist"), { recursive: true })) + yield* Effect.promise(() => fs.mkdir(customTools, { recursive: true })) + yield* Effect.promise(() => + fs.cp(path.dirname(fileURLToPath(import.meta.resolve("zod"))), path.join(opencode, "node_modules", "zod"), { + dereference: true, + recursive: true, + }), + ) + yield* Effect.promise(() => + Bun.write( + path.join(plugin, "package.json"), + JSON.stringify({ name: "@opencode-ai/plugin", type: "module", exports: { ".": "./dist/index.js" } }), + ), + ) + yield* Effect.promise(() => + Bun.write( + path.join(plugin, "dist", "index.js"), + [ + "import { z } from 'zod'", + "export function tool(input) {", + " return { ...input, jsonSchema: z.toJSONSchema(z.object(input.args), { target: 'draft-7', io: 'input' }) }", + "}", + "tool.schema = z", + "", + ].join("\n"), + ), + ) + yield* Effect.promise(() => + Bun.write( + path.join(customTools, "addition.ts"), + [ + 'import { tool } from "@opencode-ai/plugin"', + "export default tool({", + " description: 'Use this tool to add two numbers and return their sum.',", + " args: {", + " left: tool.schema.number().describe('The first number to add'),", + " right: tool.schema.number().describe('The second number to add'),", + " },", + " execute: async (args) => `${args.left} + ${args.right} = ${args.left + args.right}`,", + "})", + "", + ].join("\n"), + ), + ) + + const registry = yield* ToolRegistry.Service + const loaded = (yield* registry.all()).find((tool) => tool.id === "addition") + if (!loaded) throw new Error("custom addition tool was not loaded") + + expect(ToolJsonSchema.fromTool(loaded)).toMatchObject({ + properties: { + left: { type: "number", description: "The first number to add" }, + right: { type: "number", description: "The second number to add" }, + }, + }) + }), + 20_000, + ) + it.instance("preserves attachments from structured custom tool results", () => Effect.gen(function* () { const test = yield* TestInstance diff --git a/packages/plugin/src/tool.ts b/packages/plugin/src/tool.ts index b8a634c7967..daf6b0bbd70 100644 --- a/packages/plugin/src/tool.ts +++ b/packages/plugin/src/tool.ts @@ -48,7 +48,13 @@ export function tool(input: { args: Args execute(args: z.infer>, context: ToolContext): Promise }) { - return input + return { + ...input, + // Generate JSON Schema here with the same Zod instance that created + // `tool.schema` args. Zod metadata such as `.describe()` is stored in a + // module-local registry, so converting later from opencode can lose it. + jsonSchema: z.toJSONSchema(z.object(input.args), { target: "draft-7", io: "input" }), + } } tool.schema = z From 9975c1ed1ce3517251cd69e52f76a16eb4d2a664 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Fri, 15 May 2026 15:12:20 +0000 Subject: [PATCH 041/164] chore: generate --- packages/opencode/test/tool/registry.test.ts | 122 ++++++++++--------- 1 file changed, 62 insertions(+), 60 deletions(-) diff --git a/packages/opencode/test/tool/registry.test.ts b/packages/opencode/test/tool/registry.test.ts index 84d2ff8a39e..ce7f89b35b6 100644 --- a/packages/opencode/test/tool/registry.test.ts +++ b/packages/opencode/test/tool/registry.test.ts @@ -263,68 +263,70 @@ describe("tool.registry", () => { }), ) - it.instance("preserves Zod arg descriptions from config-scoped plugin packages", () => - Effect.gen(function* () { - const test = yield* TestInstance - const opencode = path.join(test.directory, ".opencode") - const customTools = path.join(opencode, "tools") - const plugin = path.join(opencode, "node_modules", "@opencode-ai", "plugin") - yield* Effect.promise(() => fs.mkdir(path.join(plugin, "dist"), { recursive: true })) - yield* Effect.promise(() => fs.mkdir(customTools, { recursive: true })) - yield* Effect.promise(() => - fs.cp(path.dirname(fileURLToPath(import.meta.resolve("zod"))), path.join(opencode, "node_modules", "zod"), { - dereference: true, - recursive: true, - }), - ) - yield* Effect.promise(() => - Bun.write( - path.join(plugin, "package.json"), - JSON.stringify({ name: "@opencode-ai/plugin", type: "module", exports: { ".": "./dist/index.js" } }), - ), - ) - yield* Effect.promise(() => - Bun.write( - path.join(plugin, "dist", "index.js"), - [ - "import { z } from 'zod'", - "export function tool(input) {", - " return { ...input, jsonSchema: z.toJSONSchema(z.object(input.args), { target: 'draft-7', io: 'input' }) }", - "}", - "tool.schema = z", - "", - ].join("\n"), - ), - ) - yield* Effect.promise(() => - Bun.write( - path.join(customTools, "addition.ts"), - [ - 'import { tool } from "@opencode-ai/plugin"', - "export default tool({", - " description: 'Use this tool to add two numbers and return their sum.',", - " args: {", - " left: tool.schema.number().describe('The first number to add'),", - " right: tool.schema.number().describe('The second number to add'),", - " },", - " execute: async (args) => `${args.left} + ${args.right} = ${args.left + args.right}`,", - "})", - "", - ].join("\n"), - ), - ) + it.instance( + "preserves Zod arg descriptions from config-scoped plugin packages", + () => + Effect.gen(function* () { + const test = yield* TestInstance + const opencode = path.join(test.directory, ".opencode") + const customTools = path.join(opencode, "tools") + const plugin = path.join(opencode, "node_modules", "@opencode-ai", "plugin") + yield* Effect.promise(() => fs.mkdir(path.join(plugin, "dist"), { recursive: true })) + yield* Effect.promise(() => fs.mkdir(customTools, { recursive: true })) + yield* Effect.promise(() => + fs.cp(path.dirname(fileURLToPath(import.meta.resolve("zod"))), path.join(opencode, "node_modules", "zod"), { + dereference: true, + recursive: true, + }), + ) + yield* Effect.promise(() => + Bun.write( + path.join(plugin, "package.json"), + JSON.stringify({ name: "@opencode-ai/plugin", type: "module", exports: { ".": "./dist/index.js" } }), + ), + ) + yield* Effect.promise(() => + Bun.write( + path.join(plugin, "dist", "index.js"), + [ + "import { z } from 'zod'", + "export function tool(input) {", + " return { ...input, jsonSchema: z.toJSONSchema(z.object(input.args), { target: 'draft-7', io: 'input' }) }", + "}", + "tool.schema = z", + "", + ].join("\n"), + ), + ) + yield* Effect.promise(() => + Bun.write( + path.join(customTools, "addition.ts"), + [ + 'import { tool } from "@opencode-ai/plugin"', + "export default tool({", + " description: 'Use this tool to add two numbers and return their sum.',", + " args: {", + " left: tool.schema.number().describe('The first number to add'),", + " right: tool.schema.number().describe('The second number to add'),", + " },", + " execute: async (args) => `${args.left} + ${args.right} = ${args.left + args.right}`,", + "})", + "", + ].join("\n"), + ), + ) - const registry = yield* ToolRegistry.Service - const loaded = (yield* registry.all()).find((tool) => tool.id === "addition") - if (!loaded) throw new Error("custom addition tool was not loaded") + const registry = yield* ToolRegistry.Service + const loaded = (yield* registry.all()).find((tool) => tool.id === "addition") + if (!loaded) throw new Error("custom addition tool was not loaded") - expect(ToolJsonSchema.fromTool(loaded)).toMatchObject({ - properties: { - left: { type: "number", description: "The first number to add" }, - right: { type: "number", description: "The second number to add" }, - }, - }) - }), + expect(ToolJsonSchema.fromTool(loaded)).toMatchObject({ + properties: { + left: { type: "number", description: "The first number to add" }, + right: { type: "number", description: "The second number to add" }, + }, + }) + }), 20_000, ) From 0c9cfe923f29de41427354585da6404a277aab27 Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Fri, 15 May 2026 23:05:44 +0530 Subject: [PATCH 042/164] refactor(instance): remove legacy runtime fallback (#27757) --- packages/opencode/AGENTS.md | 15 +- packages/opencode/specs/effect/facades.md | 3 - packages/opencode/specs/effect/guide.md | 10 +- .../opencode/specs/effect/instance-context.md | 314 +----------- packages/opencode/specs/effect/loose-ends.md | 4 - packages/opencode/specs/effect/todo.md | 79 +-- packages/opencode/src/acp/agent.ts | 16 +- packages/opencode/src/cli/effect-cmd.ts | 11 +- .../src/control-plane/adapters/worktree.ts | 78 ++- packages/opencode/src/control-plane/types.ts | 21 +- .../opencode/src/control-plane/workspace.ts | 123 +++-- packages/opencode/src/effect/bridge.ts | 56 +- .../opencode/src/effect/instance-state.ts | 20 +- packages/opencode/src/effect/run-service.ts | 12 +- packages/opencode/src/lsp/client.ts | 33 +- packages/opencode/src/lsp/lsp.ts | 1 + packages/opencode/src/project/instance.ts | 36 -- .../opencode/src/project/with-instance.ts | 6 +- .../httpapi/middleware/instance-context.ts | 9 +- packages/opencode/src/tool/shell.ts | 10 +- packages/opencode/test/AGENTS.md | 2 +- .../test/cli/effect-cmd-instance-als.test.ts | 35 +- packages/opencode/test/config/config.test.ts | 296 ++++++----- .../test/control-plane/workspace.test.ts | 17 +- .../test/effect/instance-state.test.ts | 1 - packages/opencode/test/fixture/fixture.ts | 14 +- packages/opencode/test/lsp/client.test.ts | 37 +- .../test/plugin/workspace-adapter.test.ts | 5 +- .../opencode/test/project/instance.test.ts | 23 +- .../opencode/test/project/worktree.test.ts | 7 +- .../test/provider/amazon-bedrock.test.ts | 108 ++-- .../opencode/test/provider/gitlab-duo.test.ts | 1 - .../opencode/test/provider/provider.test.ts | 482 +++++++++--------- .../opencode/test/question/question.test.ts | 7 +- .../test/server/httpapi-event.test.ts | 9 +- .../test/server/httpapi-exercise/runtime.ts | 3 - .../opencode/test/server/httpapi-file.test.ts | 1 - .../server/httpapi-instance-context.test.ts | 1 - .../opencode/test/server/httpapi-pty.test.ts | 1 - .../server/httpapi-raw-route-auth.test.ts | 1 - .../test/server/httpapi-workspace.test.ts | 9 +- packages/opencode/test/session/llm.test.ts | 268 +++++----- .../test/tool/external-directory.test.ts | 2 +- packages/opencode/test/tool/lsp.test.ts | 1 - packages/opencode/test/tool/read.test.ts | 1 - packages/opencode/test/tool/skill.test.ts | 1 - packages/opencode/test/tool/write.test.ts | 1 - 47 files changed, 937 insertions(+), 1254 deletions(-) delete mode 100644 packages/opencode/src/project/instance.ts diff --git a/packages/opencode/AGENTS.md b/packages/opencode/AGENTS.md index ec4131a46cf..d367f44083a 100644 --- a/packages/opencode/AGENTS.md +++ b/packages/opencode/AGENTS.md @@ -128,17 +128,8 @@ See `specs/effect/migration.md` for the compact pattern reference and examples. Use `Effect.cached` when multiple concurrent callers should share a single in-flight computation rather than storing `Fiber | undefined` or `Promise | undefined` manually. See `specs/effect/migration.md` for the full pattern. -## Instance.bind — ALS for native callbacks +## Callback boundaries -`Instance.bind(fn)` captures the current Instance AsyncLocalStorage context and restores it synchronously when called. +Use `EffectBridge` for native or external callbacks (`@parcel/watcher`, `node-pty`, native `fs.watch`, plugin callbacks, etc.) that need to re-enter Effect services with instance/workspace context. -Use it for native addon callbacks (`@parcel/watcher`, `node-pty`, native `fs.watch`, etc.) that need to call `Bus.publish` or anything that reads `Instance.directory`. - -You do not need it for `setTimeout`, `Promise.then`, `EventEmitter.on`, or Effect fibers. - -```typescript -const cb = Instance.bind((err, evts) => { - Bus.publish(MyEvent, { ... }) -}) -nativeAddon.subscribe(dir, cb) -``` +Plain async code should pass explicit context or stay inside an Effect fiber; do not add ambient instance context shims. diff --git a/packages/opencode/specs/effect/facades.md b/packages/opencode/specs/effect/facades.md index 8bf7d97badc..f7e3165f007 100644 --- a/packages/opencode/specs/effect/facades.md +++ b/packages/opencode/specs/effect/facades.md @@ -6,7 +6,6 @@ Current status on this branch: - `src/` has 5 `makeRuntime(...)` call sites total. - 2 are intentionally excluded from this checklist: `src/bus/index.ts` and `src/effect/cross-spawn-spawner.ts`. -- 1 is tracked primarily by the instance-context migration rather than facade removal: `src/project/instance.ts`. - That leaves 2 live runtime-backed service facades still worth tracking here: `src/npm/index.ts` and `src/cli/cmd/tui/config/tui.ts`. Recent progress: @@ -18,7 +17,6 @@ Recent progress: - `src/cli/cmd/tui/config/tui.ts` still exports `makeRuntime(...)` plus async facade helpers for `get()` and `waitForDependencies()`. - `src/npm/index.ts` still exports `makeRuntime(...)` plus async facade helpers for `install()`, `add()`, `outdated()`, and `which()`. -- `src/project/instance.ts` still uses a dedicated runtime for project boot, but that file is really part of the broader legacy instance-context transition tracked in `instance-context.md`. ## Completed Batches @@ -192,7 +190,6 @@ Most of the original facade-removal backlog is already done. The practical remai 1. remove the `Npm` runtime-backed facade from `src/npm/index.ts` 2. remove the `TuiConfig` runtime-backed facade from `src/cli/cmd/tui/config/tui.ts` -3. keep `src/project/instance.ts` in the separate instance-context migration, not this checklist ## Checklist diff --git a/packages/opencode/specs/effect/guide.md b/packages/opencode/specs/effect/guide.md index 5df02934485..e8a1a19c564 100644 --- a/packages/opencode/specs/effect/guide.md +++ b/packages/opencode/specs/effect/guide.md @@ -197,13 +197,9 @@ For background loops, use `Effect.repeat` or `Effect.schedule` with [`EffectBridge`](../../src/effect/bridge.ts) is the sanctioned helper for Promise/callback interop that needs to preserve instance/workspace context. -Keep it, but reduce its dependency on legacy `Instance.current` / -`Instance.restore` over time. - -`Instance.bind` / `Instance.restore` are transitional legacy tools. Use -them only for native callbacks that still require legacy ALS context. Do -not use them for `setTimeout`, `Promise.then`, `EventEmitter.on`, or -Effect fibers. +It preserves explicit `InstanceRef` / `WorkspaceRef` context for effects run +through the bridge. Plain JS callbacks that need instance data should receive +that data explicitly. ## Testing diff --git a/packages/opencode/specs/effect/instance-context.md b/packages/opencode/specs/effect/instance-context.md index 6d63715030f..94564004c9c 100644 --- a/packages/opencode/specs/effect/instance-context.md +++ b/packages/opencode/specs/effect/instance-context.md @@ -1,309 +1,13 @@ -# Instance context migration +# Instance Context -Practical plan for retiring the promise-backed / ALS-backed `Instance` helper in `src/project/instance.ts` and moving instance selection fully into Effect-provided scope. +Instance selection is now Effect-provided context. -## Goal +Use these APIs: -End state: +- `InstanceRef` for the current project context. +- `WorkspaceRef` for the current workspace id. +- `InstanceState.context` / `InstanceState.directory` inside Effect services that require an instance. +- `InstanceStore` at entry boundaries that need to load, reload, or dispose project contexts. +- `EffectBridge` for native, plugin, or plain JavaScript callback boundaries that need to re-enter Effect with captured refs. -- request, CLI, TUI, and tool entrypoints shift into an instance through Effect, not `Instance.provide(...)` -- Effect code reads the current instance from `InstanceRef` or its eventual replacement, not from ALS-backed sync getters -- per-directory boot, caching, and disposal are scoped Effect resources, not a module-level `Map>` -- ALS remains only as a temporary bridge for native callback APIs that fire outside the Effect fiber tree - -## Current split - -Today `src/project/instance.ts` still owns two separate concerns: - -- ambient current-instance context through `LocalContext` / `AsyncLocalStorage` -- per-directory boot and deduplication through `cache: Map>` - -At the same time, the Effect side already exists: - -- `src/effect/instance-ref.ts` provides `InstanceRef` and `WorkspaceRef` -- `src/effect/run-service.ts` already attaches those refs when a runtime starts inside an active instance ALS context -- `src/effect/instance-state.ts` already prefers `InstanceRef` and only falls back to ALS when needed - -That means the migration is not "invent instance context in Effect". The migration is "stop relying on the legacy helper as the primary source of truth". - -## End state shape - -Near-term target shape: - -```ts -InstanceScope.with({ directory, workspaceID }, effect) -``` - -Responsibilities of `InstanceScope.with(...)`: - -- resolve `directory`, `project`, and `worktree` -- acquire or reuse the scoped per-directory instance environment -- provide `InstanceRef` and `WorkspaceRef` -- run the caller's Effect inside that environment - -Code inside the boundary should then do one of these: - -```ts -const ctx = yield * InstanceState.context -const dir = yield * InstanceState.directory -``` - -Long-term, once `InstanceState` itself is replaced by keyed layers / `LayerMap`, those reads can move to an `InstanceContext` service without changing the outer migration order. - -## Migration phases - -### Phase 1: stop expanding the legacy surface - -Rules for all new code: - -- do not add new `Instance.directory`, `Instance.worktree`, `Instance.project`, or `Instance.current` reads inside Effect code -- do not add new `Instance.provide(...)` boundaries unless there is no Effect-native seam yet -- use `InstanceState.context`, `InstanceState.directory`, or an explicit `ctx` parameter inside Effect code - -Success condition: - -- the file inventory below only shrinks from here - -### Phase 2: remove direct sync getter reads from Effect services - -Convert Effect services first, before replacing the top-level boundary. These modules already run inside Effect and mostly need `yield* InstanceState.context` or a yielded `ctx` instead of ambient sync access. - -Primary batch, highest payoff: - -- `src/file/index.ts` -- `src/lsp/server.ts` -- `src/worktree/index.ts` -- `src/file/watcher.ts` -- `src/format/formatter.ts` -- `src/session/index.ts` -- `src/project/vcs.ts` - -Mechanical replacement rule: - -- `Instance.directory` -> `ctx.directory` or `yield* InstanceState.directory` -- `Instance.worktree` -> `ctx.worktree` -- `Instance.project` -> `ctx.project` - -Do not thread strings manually through every public method if the service already has access to Effect context. - -### Phase 3: convert entry boundaries to provide instance refs directly - -After the service bodies stop assuming ALS, move the top-level boundaries to shift into Effect explicitly. - -Main boundaries: - -- HTTP server middleware and experimental `HttpApi` entrypoints -- CLI commands -- TUI worker / attach / thread entrypoints -- tool execution entrypoints - -These boundaries should become Effect-native wrappers that: - -- decode directory / workspace inputs -- resolve the instance context once -- provide `InstanceRef` and `WorkspaceRef` -- run the requested Effect - -At that point `Instance.provide(...)` becomes a legacy adapter instead of the normal code path. - -### Phase 4: replace promise boot cache with scoped instance runtime - -Once boundaries and services both rely on Effect context, replace the module-level promise cache in `src/project/instance.ts`. - -Target replacement: - -- keyed scoped runtime or keyed layer acquisition for each directory -- reuse via `ScopedCache`, `LayerMap`, or another keyed Effect resource manager -- cleanup performed by scope finalizers instead of `disposeAll()` iterating a Promise map - -This phase should absorb the current responsibilities of: - -- `cache` in `src/project/instance.ts` -- `boot(...)` -- most of `disposeInstance(...)` -- manual `reload(...)` / `disposeAll()` fan-out logic - -### Phase 5: shrink ALS to callback bridges only - -Keep ALS only where a library invokes callbacks outside the Effect fiber tree and we still need to call code that reads instance context synchronously. - -Known bridge cases today: - -- `src/file/watcher.ts` -- `src/session/llm.ts` -- some LSP and plugin callback paths - -If those libraries become fully wrapped in Effect services, the remaining `Instance.bind(...)` uses can disappear too. - -### Phase 6: delete the legacy sync API - -Only after earlier phases land: - -- remove broad use of `Instance.current`, `Instance.directory`, `Instance.worktree`, `Instance.project` -- reduce `src/project/instance.ts` to a thin compatibility shim or delete it entirely -- remove the ALS fallback from `InstanceState.context` - -## Inventory of direct legacy usage - -Direct legacy usage means any source file that still calls one of: - -- `Instance.current` -- `Instance.directory` -- `Instance.worktree` -- `Instance.project` -- `Instance.provide(...)` -- `Instance.bind(...)` -- `Instance.restore(...)` -- `Instance.reload(...)` -- `Instance.dispose()` / `Instance.disposeAll()` - -Current total: `56` files in `packages/opencode/src`. - -### Core bridge and plumbing - -These files define or adapt the current bridge. They should change last, after callers have moved. - -- `src/project/instance.ts` -- `src/effect/run-service.ts` -- `src/effect/instance-state.ts` -- `src/project/bootstrap.ts` -- `src/config/config.ts` - -Migration rule: - -- keep these as compatibility glue until the outer boundaries and inner services stop depending on ALS - -### HTTP and server boundaries - -These are the current request-entry seams that still create or consume instance context through the legacy helper. - -- `src/server/routes/instance/middleware.ts` -- `src/server/routes/instance/index.ts` -- `src/server/routes/instance/project.ts` -- `src/server/routes/control/workspace.ts` -- `src/server/routes/instance/file.ts` -- `src/server/routes/instance/experimental.ts` -- `src/server/routes/global.ts` - -Migration rule: - -- move these to explicit Effect entrypoints that provide `InstanceRef` / `WorkspaceRef` -- do not move these first; first reduce the number of downstream handlers and services that still expect ambient ALS - -### CLI and TUI boundaries - -These commands still enter an instance through `Instance.provide(...)` or read sync getters directly. - -- `src/cli/bootstrap.ts` -- `src/cli/cmd/agent.ts` -- `src/cli/cmd/debug/agent.ts` -- `src/cli/cmd/debug/ripgrep.ts` -- `src/cli/cmd/github.ts` -- `src/cli/cmd/import.ts` -- `src/cli/cmd/mcp.ts` -- `src/cli/cmd/models.ts` -- `src/cli/cmd/plug.ts` -- `src/cli/cmd/pr.ts` -- `src/cli/cmd/providers.ts` -- `src/cli/cmd/stats.ts` -- `src/cli/cmd/tui/attach.ts` -- `src/cli/cmd/tui/plugin/runtime.ts` -- `src/cli/cmd/tui/thread.ts` -- `src/cli/cmd/tui/worker.ts` - -Migration rule: - -- converge these on one shared `withInstance(...)` Effect entry helper instead of open-coded `Instance.provide(...)` -- after that helper is proven, inline the legacy implementation behind an Effect-native scope provider - -### Tool boundary code - -These tools mostly use direct getters for path resolution and repo-relative display logic. - -- `src/tool/apply_patch.ts` -- `src/tool/bash.ts` -- `src/tool/edit.ts` -- `src/tool/lsp.ts` -- `src/tool/plan.ts` -- `src/tool/read.ts` -- `src/tool/write.ts` - -Migration rule: - -- expose the current instance as an explicit Effect dependency for tool execution -- keep path logic local; avoid introducing another global singleton for tool state - -### Effect services still reading ambient instance state - -These modules are already the best near-term migration targets because they are in Effect code but still read sync getters from the legacy helper. - -- `src/agent/agent.ts` -- `src/cli/cmd/tui/config/tui-migrate.ts` -- `src/file/index.ts` -- `src/file/watcher.ts` -- `src/format/formatter.ts` -- `src/lsp/client.ts` -- `src/lsp/index.ts` -- `src/lsp/server.ts` -- `src/mcp/index.ts` -- `src/project/vcs.ts` -- `src/provider/provider.ts` -- `src/pty/index.ts` -- `src/session/session.ts` -- `src/session/instruction.ts` -- `src/session/llm.ts` -- `src/session/system.ts` -- `src/sync/index.ts` -- `src/worktree/index.ts` - -Migration rule: - -- replace direct getter reads with `yield* InstanceState.context` or a yielded `ctx` -- isolate `Instance.bind(...)` callers and convert only the truly callback-driven edges to bridge mode - -### Highest-churn hotspots - -Current highest direct-usage counts by file: - -- `src/file/index.ts` - `18` -- `src/lsp/server.ts` - `14` -- `src/worktree/index.ts` - `12` -- `src/file/watcher.ts` - `9` -- `src/cli/cmd/mcp.ts` - `8` -- `src/format/formatter.ts` - `8` -- `src/tool/apply_patch.ts` - `8` -- `src/cli/cmd/github.ts` - `7` - -These files should drive the first measurable burn-down. - -## Recommended implementation order - -1. Migrate direct getter reads inside Effect services, starting with `file`, `lsp`, `worktree`, `format`, and `session`. -2. Add one shared Effect-native boundary helper for CLI / tool / HTTP entrypoints so we stop open-coding `Instance.provide(...)`. -3. Move experimental `HttpApi` entrypoints to that helper so the new server stack proves the pattern. -4. Convert remaining CLI and tool boundaries. -5. Replace the promise cache with a keyed scoped runtime or keyed layer map. -6. Delete ALS fallback paths once only callback bridges still depend on them. - -## Definition of done - -This migration is done when all of the following are true: - -- new requests and commands enter an instance by providing Effect context, not ALS -- Effect services no longer read `Instance.directory`, `Instance.worktree`, `Instance.project`, or `Instance.current` -- `Instance.provide(...)` is gone from normal request / CLI / tool execution -- per-directory boot and disposal are handled by scoped Effect resources -- `Instance.bind(...)` is either gone or confined to a tiny set of native callback adapters - -## Tracker and worktree - -Active tracker items: - -- `lh7l73` - overall `HttpApi` migration -- `yobwlk` - remove direct `Instance.*` reads inside Effect services -- `7irl1e` - replace `InstanceState` / legacy instance caching with keyed Effect layers - -Dedicated worktree for this transition: - -- path: `/Users/kit/code/open-source/opencode-worktrees/instance-effect-shift` -- branch: `kit/instance-effect-shift` +Do not add new ambient instance globals. Promise and callback boundaries should either stay in Effect, use `EffectBridge`, or pass the required context explicitly. diff --git a/packages/opencode/specs/effect/loose-ends.md b/packages/opencode/specs/effect/loose-ends.md index 4e7ada7ff91..d30efd18155 100644 --- a/packages/opencode/specs/effect/loose-ends.md +++ b/packages/opencode/specs/effect/loose-ends.md @@ -24,10 +24,6 @@ Small follow-ups that do not fit neatly into the main facade, route, tool, or sc - [ ] `cli/cmd/tui/config/tui.ts` - switch TUI config loading from async `ConfigPaths.*` wrappers to the `ConfigPaths.Service` once that service exists. - [ ] `cli/cmd/tui/config/tui-migrate.ts` - decide whether to leave this as a plain async module using wrapper functions or effectify it fully after `ConfigPaths.Service` lands. -## Instance cleanup - -- [ ] `project/instance.ts` - keep shrinking the legacy ALS / Promise cache after the remaining `Instance.*` callers move over. - ## Notes - Prefer small, semantics-preserving config migrations. Config precedence, legacy key migration, and plugin origin tracking are easy to break accidentally. diff --git a/packages/opencode/specs/effect/todo.md b/packages/opencode/specs/effect/todo.md index 092e80b767b..acb4a995c8c 100644 --- a/packages/opencode/specs/effect/todo.md +++ b/packages/opencode/specs/effect/todo.md @@ -64,13 +64,11 @@ P6 OA explicit and testable instead of mutable module state. Shrinks: [`global.ts`](../../../core/src/global.ts) import-time side effects, mutable `Global.Path` overrides, and its `Flag` dependency. -- `INST` Instance shim — remove ambient `Instance` usage and old ALS - access patterns. - Shrinks: [`src/project/instance.ts`](../../src/project/instance.ts). +- `INST` Instance context — keep project context explicit through Effect refs + and bridge boundaries. - `BRIDGE` Promise/callback interop — keep bridge helpers, but reduce legacy ALS coupling. - Shrinks: [`src/effect/bridge.ts`](../../src/effect/bridge.ts) - dependency on [`project/instance.ts`](../../src/project/instance.ts). + Shrinks: ad hoc Promise/callback re-entry code. - `PROC` AppProcess migration — prefer `AppProcess.Service` over raw process wrappers. Shrinks: direct spawn callsites and legacy process helpers. @@ -221,74 +219,13 @@ Next PR candidates: ## P4: Instance And Bridge -[`project/instance.ts`](../../src/project/instance.ts) is the deletion -target. [`effect/bridge.ts`](../../src/effect/bridge.ts) is not a near-term -deletion target; Promise/callback interop will continue to exist. +Instance context migration is complete for the legacy sync shim. Promise and callback interop continues through [`effect/bridge.ts`](../../src/effect/bridge.ts). -Goal: +Current rules: -- Keep a sanctioned bridge for Promise/callback boundaries. -- Reduce bridge dependence on legacy `Instance.restore` / `Instance.current`. -- Move callers toward `InstanceRef`, `WorkspaceRef`, `InstanceState`, or - explicit context where practical. -- Delete `project/instance.ts` only after ambient Instance coupling is gone. - -Important distinction: - -- `InstanceState.context`, `InstanceState.directory`, and - `InstanceState.workspaceID` are acceptable inside normal Effect service - code when `InstanceRef` / `WorkspaceRef` are provided by the runtime. -- The deletion blockers are the fallback and callback paths that rely on - ambient ALS: direct `Instance.*` reads, `InstanceState.bind(...)`, - `AppRuntime.runPromise(...)` re-entry from plain JS, and bridge restore - code that installs legacy ALS before invoking callbacks. - -Current bottom-up inventory from `dev`: - -- Direct `Instance.*` value readers: - [`tool/repo_overview.ts`](../../src/tool/repo_overview.ts), - [`control-plane/adapters/worktree.ts`](../../src/control-plane/adapters/worktree.ts), - [`cli/bootstrap.ts`](../../src/cli/bootstrap.ts). -- `InstanceState.bind(...)` callback boundaries: - [`file/watcher.ts`](../../src/file/watcher.ts) native watcher callback, - [`storage/db.ts`](../../src/storage/db.ts) transaction/effect callbacks, - [`session/llm.ts`](../../src/session/llm.ts) workflow approval callback. -- `AppRuntime.runPromise(...)` / re-entry from plain JS: - [`project/with-instance.ts`](../../src/project/with-instance.ts), - [`project/instance-runtime.ts`](../../src/project/instance-runtime.ts), - [`control-plane/adapters/worktree.ts`](../../src/control-plane/adapters/worktree.ts), - [`cli/effect-cmd.ts`](../../src/cli/effect-cmd.ts), plus global/non-instance - callsites such as CLI upgrade and ACP agent defaults. -- Intentional bridge users to classify, not delete blindly: - workspace adapters in [`control-plane/workspace.ts`](../../src/control-plane/workspace.ts), - MCP, command execution, plugins, pty lifecycle, bus scope cleanup, task - cancellation, and HTTP lifecycle reload/dispose paths. -- Core fallback layer to shrink last: - [`effect/run-service.ts`](../../src/effect/run-service.ts), - [`effect/bridge.ts`](../../src/effect/bridge.ts), and - [`effect/instance-state.ts`](../../src/effect/instance-state.ts). - -Recommended PR order: - -- [ ] `INST-1` Remove direct `Instance.*` value readers. Start with - `repo_overview`, `worktree` adapter, and `cli/bootstrap`; pass context - explicitly or obtain it from an Effect boundary. -- [ ] `INST-2` Move type-only `InstanceContext` imports from - [`project/instance.ts`](../../src/project/instance.ts) to - [`project/instance-context.ts`](../../src/project/instance-context.ts). -- [ ] `INST-3` Audit each `InstanceState.bind(...)` callback from the inside - out: list what the callback calls (`Bus.publish`, database effects, - permission/session services), then replace ambient capture with explicit - `InstanceRef` / `WorkspaceRef` provision or an `EffectBridge` call. -- [ ] `INST-4` Classify `AppRuntime.runPromise(...)` callsites as global, - instance-scoped with explicit refs, or bridge-required. Eliminate the - instance-scoped callsites that rely on `run-service.attach()` falling - back to `Instance.current`. -- [ ] `INST-5` After consumers are explicit, remove `Instance.current` fallback - from `InstanceState.context` and `run-service.attach()`. -- [ ] `INST-6` Move any remaining `restore` / `bind` compatibility helpers to - the boundary that still needs them, then delete - [`project/instance.ts`](../../src/project/instance.ts). +- Effect services read instance data from `InstanceRef`, `WorkspaceRef`, `InstanceState`, or explicit arguments. +- Plain JavaScript callback boundaries use `EffectBridge` or explicit context arguments. +- Runtime entrypoints must provide refs explicitly when they are instance-scoped. ## Lower Priority Tracks diff --git a/packages/opencode/src/acp/agent.ts b/packages/opencode/src/acp/agent.ts index aa123d5991c..8cc1750ca03 100644 --- a/packages/opencode/src/acp/agent.ts +++ b/packages/opencode/src/acp/agent.ts @@ -43,16 +43,25 @@ import { Provider } from "@/provider/provider" import { ModelID, ProviderID } from "../provider/schema" import { Agent as AgentModule } from "../agent/agent" import { AppRuntime } from "@/effect/app-runtime" +import { InstanceRef } from "@/effect/instance-ref" +import { InstanceRuntime } from "@/project/instance-runtime" import { Installation } from "@/installation" import { MessageV2 } from "@/session/message-v2" import { Config } from "@/config/config" import { ConfigMCP } from "@/config/mcp" import { Todo } from "@/session/todo" -import { Result, Schema } from "effect" +import { Effect, Result, Schema } from "effect" import { LoadAPIKeyError } from "ai" import type { AssistantMessage, Event, OpencodeClient, SessionMessageResponse, ToolPart } from "@opencode-ai/sdk/v2" import { applyPatch } from "diff" import { InstallationVersion } from "@opencode-ai/core/installation/version" + +const defaultAgentInfo = async (directory: string) => { + const ctx = await InstanceRuntime.load({ directory }) + return AppRuntime.runPromise( + AgentModule.Service.use((svc) => svc.defaultInfo()).pipe(Effect.provideService(InstanceRef, ctx)), + ) +} import { ShellID } from "@/tool/shell/id" type ModeOption = { id: string; name: string; description?: string } @@ -1094,7 +1103,7 @@ export class Agent implements ACPAgent { const currentModeId = await (async () => { if (!availableModes.length) return undefined - const defaultAgent = await AppRuntime.runPromise(AgentModule.Service.use((svc) => svc.defaultInfo())) + const defaultAgent = await defaultAgentInfo(directory) const resolvedModeId = availableModes.find((mode) => mode.name === defaultAgent.name)?.id ?? availableModes[0].id this.sessionManager.setMode(sessionId, resolvedModeId) return resolvedModeId @@ -1328,8 +1337,7 @@ export class Agent implements ACPAgent { if (!current) { this.sessionManager.setModel(session.id, model) } - const agent = - session.modeId ?? (await AppRuntime.runPromise(AgentModule.Service.use((svc) => svc.defaultInfo()))).name + const agent = session.modeId ?? (await defaultAgentInfo(directory)).name const parts: Array< | { type: "text"; text: string; synthetic?: boolean; ignored?: boolean } diff --git a/packages/opencode/src/cli/effect-cmd.ts b/packages/opencode/src/cli/effect-cmd.ts index ada5f8677d7..c96bc99ac42 100644 --- a/packages/opencode/src/cli/effect-cmd.ts +++ b/packages/opencode/src/cli/effect-cmd.ts @@ -3,7 +3,6 @@ import { Effect, Schema } from "effect" import { AppRuntime, type AppServices } from "@/effect/app-runtime" import { InstanceStore } from "@/project/instance-store" import { InstanceRef } from "@/effect/instance-ref" -import { Instance } from "@/project/instance" import { cmd, type WithDoubleDash } from "./cmd/cmd" /** @@ -83,19 +82,11 @@ export const effectCmd = (opts: EffectCmdOpts) => return } const directory = opts.directory?.(args) ?? process.cwd() - // Two-phase: load ctx, then run body inside Instance.current ALS. - // Effect's InstanceRef is provided via fiber context, but that context is - // lost across `await` inside `Effect.promise(async () => ...)` callbacks - // — when handlers re-enter Effect via `AppRuntime.runPromise(svc.method())` - // there, attach() falls back to Instance.current ALS, which Node preserves - // across awaits. Matches the pre-effectCmd `bootstrap()` behavior. const { store, ctx } = await AppRuntime.runPromise( InstanceStore.Service.use((store) => store.load({ directory }).pipe(Effect.map((ctx) => ({ store, ctx })))), ) try { - await Instance.restore(ctx, () => - AppRuntime.runPromise(opts.handler(args).pipe(Effect.provideService(InstanceRef, ctx))), - ) + await AppRuntime.runPromise(opts.handler(args).pipe(Effect.provideService(InstanceRef, ctx))) } finally { await AppRuntime.runPromise(store.dispose(ctx)) } diff --git a/packages/opencode/src/control-plane/adapters/worktree.ts b/packages/opencode/src/control-plane/adapters/worktree.ts index 81d9990e7cd..b85ee7ae25a 100644 --- a/packages/opencode/src/control-plane/adapters/worktree.ts +++ b/packages/opencode/src/control-plane/adapters/worktree.ts @@ -1,7 +1,6 @@ import { Effect, Schema } from "effect" import { InstanceRef, WorkspaceRef } from "@/effect/instance-ref" -import { WorkspaceContext } from "../workspace-context" -import { type WorkspaceAdapter, WorkspaceInfo } from "../types" +import { type WorkspaceAdapter, type WorkspaceAdapterContext, WorkspaceInfo } from "../types" const WorktreeConfig = Schema.Struct({ name: WorkspaceInfo.fields.name, @@ -11,26 +10,31 @@ const WorktreeConfig = Schema.Struct({ const decodeWorktreeConfig = Schema.decodeUnknownSync(WorktreeConfig) async function loadWorktree() { - const [{ AppRuntime }, { Instance }, { Worktree }] = await Promise.all([ + const [{ AppRuntime }, { Worktree }] = await Promise.all([ import("@/effect/app-runtime"), - import("@/project/instance"), import("@/worktree"), ]) - return { AppRuntime, Instance, Worktree } + return { AppRuntime, Worktree } } +function requireInstance(context: WorkspaceAdapterContext | undefined) { + if (!context?.instance) throw new Error("Worktree adapter requires an instance context") + return context.instance +} + +const provideContext = (effect: Effect.Effect, context: WorkspaceAdapterContext | undefined) => + effect.pipe( + Effect.provideService(InstanceRef, requireInstance(context)), + Effect.provideService(WorkspaceRef, context?.workspaceID), + ) + export const WorktreeAdapter: WorkspaceAdapter = { name: "Worktree", description: "Create a git worktree", - async configure(info) { - const { AppRuntime, Instance, Worktree } = await loadWorktree() - const ctx = Instance.current - const workspaceID = WorkspaceContext.workspaceID + async configure(info, context) { + const { AppRuntime, Worktree } = await loadWorktree() const next = await AppRuntime.runPromise( - Worktree.Service.use((svc) => svc.makeWorktreeInfo({ detached: true })).pipe( - Effect.provideService(InstanceRef, ctx), - Effect.provideService(WorkspaceRef, workspaceID), - ), + provideContext(Worktree.Service.use((svc) => svc.makeWorktreeInfo({ detached: true })), context), ) return { ...info, @@ -38,32 +42,27 @@ export const WorktreeAdapter: WorkspaceAdapter = { directory: next.directory, } }, - async create(info) { - const { AppRuntime, Instance, Worktree } = await loadWorktree() - const ctx = Instance.current - const workspaceID = WorkspaceContext.workspaceID + async create(info, _env, _from, context) { + const { AppRuntime, Worktree } = await loadWorktree() const config = decodeWorktreeConfig(info) await AppRuntime.runPromise( - Worktree.Service.use((svc) => - svc.createFromInfo({ - name: config.name, - directory: config.directory, - ...(config.branch ? { branch: config.branch } : {}), - }), - ).pipe(Effect.provideService(InstanceRef, ctx), Effect.provideService(WorkspaceRef, workspaceID)), + provideContext( + Worktree.Service.use((svc) => + svc.createFromInfo({ + name: config.name, + directory: config.directory, + ...(config.branch ? { branch: config.branch } : {}), + }), + ), + context, + ), ) }, - async list() { - const { AppRuntime, Instance, Worktree } = await loadWorktree() - const ctx = Instance.current - const workspaceID = WorkspaceContext.workspaceID + async list(context) { + const { AppRuntime, Worktree } = await loadWorktree() + const ctx = requireInstance(context) return ( - await AppRuntime.runPromise( - Worktree.Service.use((svc) => svc.list()).pipe( - Effect.provideService(InstanceRef, ctx), - Effect.provideService(WorkspaceRef, workspaceID), - ), - ) + await AppRuntime.runPromise(provideContext(Worktree.Service.use((svc) => svc.list()), context)) ).map((info) => ({ type: "worktree", name: info.name, @@ -72,16 +71,11 @@ export const WorktreeAdapter: WorkspaceAdapter = { projectID: ctx.project.id, })) }, - async remove(info) { - const { AppRuntime, Instance, Worktree } = await loadWorktree() - const ctx = Instance.current - const workspaceID = WorkspaceContext.workspaceID + async remove(info, context) { + const { AppRuntime, Worktree } = await loadWorktree() const config = decodeWorktreeConfig(info) await AppRuntime.runPromise( - Worktree.Service.use((svc) => svc.remove({ directory: config.directory })).pipe( - Effect.provideService(InstanceRef, ctx), - Effect.provideService(WorkspaceRef, workspaceID), - ), + provideContext(Worktree.Service.use((svc) => svc.remove({ directory: config.directory })), context), ) }, target(info) { diff --git a/packages/opencode/src/control-plane/types.ts b/packages/opencode/src/control-plane/types.ts index e55ae2194e4..daa83745302 100644 --- a/packages/opencode/src/control-plane/types.ts +++ b/packages/opencode/src/control-plane/types.ts @@ -1,5 +1,6 @@ import { Schema, Struct } from "effect" import { ProjectID } from "@/project/schema" +import type { InstanceContext } from "@/project/instance-context" import { WorkspaceID } from "./schema" import type { DeepMutable } from "@opencode-ai/core/schema" @@ -37,12 +38,22 @@ export type Target = headers?: HeadersInit } +export type WorkspaceAdapterContext = { + readonly instance?: InstanceContext + readonly workspaceID?: WorkspaceID +} + export type WorkspaceAdapter = { name: string description: string - configure(info: WorkspaceInfo): WorkspaceInfo | Promise - create(info: WorkspaceInfo, env: Record, from?: WorkspaceInfo): Promise - list?(): WorkspaceListedInfo[] | Promise - remove(info: WorkspaceInfo): Promise - target(info: WorkspaceInfo): Target | Promise + configure(info: WorkspaceInfo, context?: WorkspaceAdapterContext): WorkspaceInfo | Promise + create( + info: WorkspaceInfo, + env: Record, + from?: WorkspaceInfo, + context?: WorkspaceAdapterContext, + ): Promise + list?(context?: WorkspaceAdapterContext): WorkspaceListedInfo[] | Promise + remove(info: WorkspaceInfo, context?: WorkspaceAdapterContext): Promise + target(info: WorkspaceInfo, context?: WorkspaceAdapterContext): Target | Promise } diff --git a/packages/opencode/src/control-plane/workspace.ts b/packages/opencode/src/control-plane/workspace.ts index 5b7f867ca91..7bbe4aa3257 100644 --- a/packages/opencode/src/control-plane/workspace.ts +++ b/packages/opencode/src/control-plane/workspace.ts @@ -26,8 +26,8 @@ import { SessionID } from "@/session/schema" import { NotFoundError } from "@/storage/storage" import { errorData } from "@/util/error" import { waitEvent } from "./util" -import { WorkspaceContext } from "./workspace-context" import { EffectBridge } from "@/effect/bridge" +import { InstanceRef, WorkspaceRef } from "@/effect/instance-ref" import { Vcs } from "@/project/vcs" import { InstanceStore } from "@/project/instance-store" import { InstanceBootstrap } from "@/project/bootstrap" @@ -196,6 +196,50 @@ export const layer = Layer.effect( }) } + const adapterContext = Effect.gen(function* () { + return { + instance: yield* InstanceRef, + workspaceID: yield* WorkspaceRef, + } + }) + + const adapterTarget = (workspace: Info) => + Effect.gen(function* () { + const adapter = getAdapter(workspace.projectID, workspace.type) + const context = yield* adapterContext + return yield* EffectBridge.fromPromise(() => adapter.target(workspace, context)) + }) + + const adapterConfigure = (adapter: ReturnType, info: WorkspaceInfo) => + Effect.gen(function* () { + const context = yield* adapterContext + return yield* EffectBridge.fromPromise(() => adapter.configure(info, context)) + }) + + const adapterCreate = ( + adapter: ReturnType, + info: WorkspaceInfo, + env: Record, + from?: WorkspaceInfo, + ) => + Effect.gen(function* () { + const context = yield* adapterContext + return yield* EffectBridge.fromPromise(() => adapter.create(info, env, from, context)) + }) + + const adapterList = (adapter: ReturnType) => + Effect.gen(function* () { + const context = yield* adapterContext + return yield* EffectBridge.fromPromise(() => Promise.resolve(adapter.list?.(context) ?? [])) + }) + + const adapterRemove = (info: Info, type: string) => + Effect.gen(function* () { + const adapter = getAdapter(info.projectID, type) + const context = yield* adapterContext + return yield* EffectBridge.fromPromise(() => adapter.remove(info, context)) + }) + const connectSSE = Effect.fn("Workspace.connectSSE")(function* ( url: URL | string, headers: HeadersInit | undefined, @@ -281,8 +325,7 @@ export const layer = Layer.effect( const workspace = yield* get(input.workspaceID) if (!workspace) return input.fallback - const adapter = getAdapter(workspace.projectID, workspace.type) - const target = yield* EffectBridge.fromPromise(() => adapter.target(workspace)) + const target = yield* adapterTarget(workspace) if (target.type === "local") { const store = yield* InstanceStore.Service @@ -375,35 +418,27 @@ export const layer = Layer.effect( events: events.length, }) - yield* Effect.promise(async () => { - await WorkspaceContext.provide({ - workspaceID: space.id, - async fn() { - await Effect.runPromise( - Effect.forEach( - events, - (event) => - sync.replay( - { - id: event.id, - aggregateID: event.aggregate_id, - seq: event.seq, - type: event.type, - data: event.data, - }, - { publish: true }, - ), - { discard: true }, - ), + yield* Effect.forEach( + events, + (event) => + sync + .replay( + { + id: event.id, + aggregateID: event.aggregate_id, + seq: event.seq, + type: event.type, + data: event.data, + }, + { publish: true }, ) - }, - }) - }) + .pipe(Effect.provideService(WorkspaceRef, space.id)), + { discard: true }, + ) }) const syncWorkspaceLoop = Effect.fn("Workspace.syncWorkspaceLoop")(function* (space: Info) { - const adapter = getAdapter(space.projectID, space.type) - const target = yield* EffectBridge.fromPromise(() => adapter.target(space)) + const target = yield* adapterTarget(space) if (target.type === "local") return @@ -486,8 +521,7 @@ export const layer = Layer.effect( const startSync = Effect.fn("Workspace.startSync")(function* (space: Info) { if (!flags.experimentalWorkspaces) return - const adapter = getAdapter(space.projectID, space.type) - const target = yield* EffectBridge.fromPromise(() => adapter.target(space)).pipe( + const target = yield* adapterTarget(space).pipe( Effect.catch((error) => Effect.sync(() => { setStatus(space.id, "error") @@ -538,15 +572,13 @@ export const layer = Layer.effect( const create = Effect.fn("Workspace.create")(function* (input: CreateInput) { const id = WorkspaceID.ascending(input.id) const adapter = getAdapter(input.projectID, input.type) - const config = yield* EffectBridge.fromPromise(() => - adapter.configure({ - ...input, - id, - name: Slug.create(), - directory: null, - extra: input.extra ?? null, - }), - ) + const config = yield* adapterConfigure(adapter, { + ...input, + id, + name: Slug.create(), + directory: null, + extra: input.extra ?? null, + }) const info: Info = { id, @@ -583,7 +615,7 @@ export const layer = Layer.effect( OTEL_RESOURCE_ATTRIBUTES: process.env.OTEL_RESOURCE_ATTRIBUTES, } - yield* EffectBridge.fromPromise(() => adapter.create(config, env)) + yield* adapterCreate(adapter, config, env) yield* Effect.all( [ waitEvent({ @@ -622,8 +654,7 @@ export const layer = Layer.effect( if (current?.workspaceID) { const previous = yield* get(current.workspaceID) if (previous) { - const adapter = getAdapter(previous.projectID, previous.type) - const target = yield* EffectBridge.fromPromise(() => adapter.target(previous)) + const target = yield* adapterTarget(previous) if (target.type === "remote") { yield* syncHistory(previous, target.url, target.headers).pipe( @@ -701,8 +732,7 @@ export const layer = Layer.effect( workspaceID, }) - const adapter = getAdapter(space.projectID, space.type) - const target = yield* EffectBridge.fromPromise(() => adapter.target(space)) + const target = yield* adapterTarget(space) if (target.type === "local") { yield* sync.run(Session.Event.Updated, { @@ -856,7 +886,7 @@ export const layer = Layer.effect( registeredAdapters(project.id), ([type, adapter]) => adapter.list - ? EffectBridge.fromPromise(() => Promise.resolve(adapter.list?.() ?? [])).pipe( + ? adapterList(adapter).pipe( Effect.catchCause((error) => Effect.sync(() => { log.warn("workspace adapter list failed", { type, error }) @@ -937,8 +967,7 @@ export const layer = Layer.effect( const info = fromRow(row) yield* Effect.catchCause( Effect.gen(function* () { - const adapter = getAdapter(info.projectID, row.type) - yield* EffectBridge.fromPromise(() => adapter.remove(info)) + yield* adapterRemove(info, row.type) }), () => Effect.sync(() => { diff --git a/packages/opencode/src/effect/bridge.ts b/packages/opencode/src/effect/bridge.ts index a0f2c224d04..590fb2d7ef2 100644 --- a/packages/opencode/src/effect/bridge.ts +++ b/packages/opencode/src/effect/bridge.ts @@ -1,9 +1,6 @@ import { Context, Effect, Exit, Fiber } from "effect" import { WorkspaceContext } from "@/control-plane/workspace-context" -import { Instance } from "@/project/instance" -import type { InstanceContext } from "@/project/instance-context" import type { WorkspaceID } from "@/control-plane/schema" -import { LocalContext } from "@/util/local-context" import { InstanceRef, WorkspaceRef } from "./instance-ref" import { attachWith } from "./run-service" @@ -14,27 +11,14 @@ export interface Shape { readonly bind: (fn: (...args: Args) => Result) => (...args: Args) => Result } -function restore(instance: InstanceContext | undefined, workspace: WorkspaceID | undefined, fn: () => R): R { - if (instance && workspace !== undefined) { - return WorkspaceContext.restore(workspace, () => Instance.restore(instance, fn)) - } - if (instance) return Instance.restore(instance, fn) +function restoreWorkspace(workspace: WorkspaceID | undefined, fn: () => R): R { if (workspace !== undefined) return WorkspaceContext.restore(workspace, fn) return fn() } function captureSync() { const fiber = Fiber.getCurrent() - const value = fiber ? Context.getReferenceUnsafe(fiber.context, InstanceRef) : undefined - const instance = - value ?? - (() => { - try { - return Instance.current - } catch (err) { - if (!(err instanceof LocalContext.NotFound)) throw err - } - })() + const instance = fiber ? Context.getReferenceUnsafe(fiber.context, InstanceRef) : undefined const workspace = (fiber ? Context.getReferenceUnsafe(fiber.context, WorkspaceRef) : undefined) ?? WorkspaceContext.workspaceID return { instance, workspace } @@ -42,47 +26,43 @@ function captureSync() { export const bind = (fn: (...args: Args) => Result) => { const captured = captureSync() - return (...args: Args) => restore(captured.instance, captured.workspace, () => fn(...args)) + return (...args: Args) => + restoreWorkspace(captured.workspace, () => + Effect.runSync(attachWith(Effect.sync(() => fn(...args)), captured)), + ) } /** - * Bridge from Effect into a Promise-returning JS callback while installing - * legacy `Instance.context` and `WorkspaceContext` AsyncLocalStorage for - * the duration of the callback. Effect's `InstanceRef`/`WorkspaceRef` do - * not propagate across async/await boundaries inside `Effect.promise(() => - * async fn)` callbacks that re-enter Effect via `AppRuntime.runPromise`, - * but Node's AsyncLocalStorage does. Use this whenever an Effect crosses - * into JS that may itself spawn new Effect runtimes (workspace adapters, - * legacy plugins, etc.). + * Bridge from Effect into a Promise-returning JS callback while preserving + * `WorkspaceContext` AsyncLocalStorage for callback code that still reads it. + * `InstanceRef` is captured for effects run through the returned bridge APIs; + * plain JS callbacks that need it should receive the ref explicitly. * - * Mirrors `Effect.promise` but restores legacy ALS first. + * Mirrors `Effect.promise` but restores workspace ALS first. */ export const fromPromise = (fn: () => Promise | T): Effect.Effect => Effect.gen(function* () { - const instance = yield* InstanceRef const workspace = yield* WorkspaceRef - return yield* Effect.promise(() => Promise.resolve(restore(instance, workspace, () => fn()))) + return yield* Effect.promise(() => Promise.resolve(restoreWorkspace(workspace, () => fn()))) }) export function make(): Effect.Effect { return Effect.gen(function* () { const ctx = yield* Effect.context() - const value = yield* InstanceRef const captured = captureSync() - const instance = value ?? captured.instance + const instance = (yield* InstanceRef) ?? captured.instance const workspace = (yield* WorkspaceRef) ?? captured.workspace - const attach = (effect: Effect.Effect) => attachWith(effect, { instance, workspace }) const wrap = (effect: Effect.Effect) => - attach(effect).pipe(Effect.provide(ctx)) as Effect.Effect + attachWith(effect.pipe(Effect.provide(ctx)) as Effect.Effect, { instance, workspace }) return { promise: (effect: Effect.Effect) => - restore(instance, workspace, () => Effect.runPromise(wrap(effect))), + restoreWorkspace(workspace, () => Effect.runPromise(wrap(effect))), fork: (effect: Effect.Effect) => - restore(instance, workspace, () => Effect.runFork(wrap(effect))), + restoreWorkspace(workspace, () => Effect.runFork(wrap(effect))), run: (effect: Effect.Effect) => Effect.callback((resume) => { - restore(instance, workspace, () => + restoreWorkspace(workspace, () => Effect.runPromiseExit(wrap(effect)).then((exit) => resume(Exit.isSuccess(exit) ? Effect.succeed(exit.value) : Effect.failCause(exit.cause)), ), @@ -91,7 +71,7 @@ export function make(): Effect.Effect { bind: (fn: (...args: Args) => Result) => (...args: Args) => - restore(instance, workspace, () => fn(...args)), + restoreWorkspace(workspace, () => Effect.runSync(wrap(Effect.sync(() => fn(...args))))), } satisfies Shape }) } diff --git a/packages/opencode/src/effect/instance-state.ts b/packages/opencode/src/effect/instance-state.ts index 5c95e012821..1c299cda55c 100644 --- a/packages/opencode/src/effect/instance-state.ts +++ b/packages/opencode/src/effect/instance-state.ts @@ -1,8 +1,6 @@ -import { Effect, Fiber, ScopedCache, Scope, Context } from "effect" +import { Effect, ScopedCache, Scope } from "effect" import * as EffectLogger from "@opencode-ai/core/effect/logger" -import { Instance } from "@/project/instance" import type { InstanceContext } from "@/project/instance-context" -import { LocalContext } from "@/util/local-context" import { InstanceRef, WorkspaceRef } from "./instance-ref" import { registerDisposer } from "./instance-registry" import { WorkspaceContext } from "@/control-plane/workspace-context" @@ -14,20 +12,10 @@ export interface InstanceState { readonly cache: ScopedCache.ScopedCache } -export const bind = any>(fn: F): F => { - try { - return Instance.bind(fn) - } catch (err) { - if (!(err instanceof LocalContext.NotFound)) throw err - } - const fiber = Fiber.getCurrent() - const ctx = fiber ? Context.getReferenceUnsafe(fiber.context, InstanceRef) : undefined - if (!ctx) return fn - return ((...args: any[]) => Instance.restore(ctx, () => fn(...args))) as F -} - export const context = Effect.gen(function* () { - return (yield* InstanceRef) ?? Instance.current + const ctx = yield* InstanceRef + if (!ctx) return yield* Effect.die(new Error("InstanceRef not provided")) + return ctx }) export const workspaceID = Effect.gen(function* () { diff --git a/packages/opencode/src/effect/run-service.ts b/packages/opencode/src/effect/run-service.ts index 75cc0d58b7c..a9c454f78b8 100644 --- a/packages/opencode/src/effect/run-service.ts +++ b/packages/opencode/src/effect/run-service.ts @@ -1,7 +1,5 @@ import { Effect, Fiber, Layer, ManagedRuntime } from "effect" import * as Context from "effect/Context" -import { Instance } from "@/project/instance" -import { LocalContext } from "@/util/local-context" import { InstanceRef, WorkspaceRef } from "./instance-ref" import * as Observability from "@opencode-ai/core/effect/observability" import { WorkspaceContext } from "@/control-plane/workspace-context" @@ -25,17 +23,9 @@ export function attachWith(effect: Effect.Effect, refs: Refs): export function attach(effect: Effect.Effect): Effect.Effect { const workspace = WorkspaceContext.workspaceID - const instance = (() => { - try { - return Instance.current - } catch (err) { - if (!(err instanceof LocalContext.NotFound)) throw err - } - })() - if (instance && workspace !== undefined) return attachWith(effect, { instance, workspace }) const fiber = Fiber.getCurrent() return attachWith(effect, { - instance: instance ?? (fiber ? Context.getReferenceUnsafe(fiber.context, InstanceRef) : undefined), + instance: fiber ? Context.getReferenceUnsafe(fiber.context, InstanceRef) : undefined, workspace: workspace ?? (fiber ? Context.getReferenceUnsafe(fiber.context, WorkspaceRef) : undefined), }) } diff --git a/packages/opencode/src/lsp/client.ts b/packages/opencode/src/lsp/client.ts index 30577a8f116..6b7f0c06037 100644 --- a/packages/opencode/src/lsp/client.ts +++ b/packages/opencode/src/lsp/client.ts @@ -7,10 +7,13 @@ import type { Diagnostic as VSCodeDiagnostic } from "vscode-languageserver-types import * as Log from "@opencode-ai/core/util/log" import { Process } from "@/util/process" import { LANGUAGE_EXTENSIONS } from "./language" -import { Schema } from "effect" +import { Effect, Schema } from "effect" import type * as LSPServer from "./server" import { withTimeout } from "../util/timeout" import { Filesystem } from "@/util/filesystem" +import { InstanceRef } from "@/effect/instance-ref" +import { makeRuntime } from "@/effect/run-service" +import { context, type InstanceContext } from "@/project/instance-context" const DIAGNOSTICS_DEBOUNCE_MS = 150 const DIAGNOSTICS_DOCUMENT_WAIT_TIMEOUT_MS = 5_000 @@ -25,6 +28,7 @@ const FILE_CHANGE_CHANGED = 2 const TEXT_DOCUMENT_SYNC_INCREMENTAL = 2 const log = Log.create({ service: "lsp.client" }) +const busRuntime = makeRuntime(Bus.Service, Bus.layer) export type Info = NonNullable>> @@ -134,9 +138,16 @@ function shouldSeedDiagnosticsOnFirstPush(serverID: string) { return serverID === "typescript" } -export async function create(input: { serverID: string; server: LSPServer.Handle; root: string; directory: string }) { +export async function create(input: { + serverID: string + server: LSPServer.Handle + root: string + directory: string + instance?: InstanceContext +}) { const logger = log.clone().tag("serverID", input.serverID) logger.info("starting client") + const instance = input.instance ?? context.use() const connection = createMessageConnection( new StreamMessageReader(input.server.process.stdout as any), @@ -162,7 +173,11 @@ export async function create(input: { serverID: string; server: LSPServer.Handle dedupeDiagnostics([...(pushDiagnostics.get(filePath) ?? []), ...(pullDiagnostics.get(filePath) ?? [])]) const updatePushDiagnostics = (filePath: string, next: Diagnostic[]) => { pushDiagnostics.set(filePath, next) - Bus.publish(Event.Diagnostics, { path: filePath, serverID: input.serverID }) + void busRuntime.runPromise((svc) => + svc.publish(Event.Diagnostics, { path: filePath, serverID: input.serverID }).pipe( + Effect.provideService(InstanceRef, instance), + ), + ) } const updatePullDiagnostics = (filePath: string, next: Diagnostic[]) => { pullDiagnostics.set(filePath, next) @@ -510,10 +525,14 @@ export async function create(input: { serverID: string; server: LSPServer.Handle } timeoutTimer = setTimeout(() => finish(false), request.timeout) - unsub = Bus.subscribe(Event.Diagnostics, (event) => { - if (event.properties.path !== request.path || event.properties.serverID !== input.serverID) return - schedule() - }) + unsub = busRuntime.runSync((svc) => + svc + .subscribeCallback(Event.Diagnostics, (event) => { + if (event.properties.path !== request.path || event.properties.serverID !== input.serverID) return + schedule() + }) + .pipe(Effect.provideService(InstanceRef, instance)), + ) schedule() }) } diff --git a/packages/opencode/src/lsp/lsp.ts b/packages/opencode/src/lsp/lsp.ts index d74bbae9335..307e85ae739 100644 --- a/packages/opencode/src/lsp/lsp.ts +++ b/packages/opencode/src/lsp/lsp.ts @@ -237,6 +237,7 @@ export const layer = Layer.effect( server: handle, root, directory: ctx.directory, + instance: ctx, }).catch(async (err) => { s.broken.add(key) await Process.stop(handle.process) diff --git a/packages/opencode/src/project/instance.ts b/packages/opencode/src/project/instance.ts deleted file mode 100644 index a54291cf0c7..00000000000 --- a/packages/opencode/src/project/instance.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { context, type InstanceContext } from "./instance-context" - -export type { InstanceContext } from "./instance-context" - -export const Instance = { - get current() { - return context.use() - }, - get directory() { - return context.use().directory - }, - get worktree() { - return context.use().worktree - }, - get project() { - return context.use().project - }, - - /** - * Captures the current instance ALS context and returns a wrapper that - * restores it when called. Use this for callbacks that fire outside the - * instance async context (native addons, event emitters, timers, etc.). - */ - bind any>(fn: F): F { - const ctx = context.use() - return ((...args: any[]) => context.provide(ctx, () => fn(...args))) as F - }, - /** - * Run a synchronous function within the given instance context ALS. - * Use this to bridge from Effect (where InstanceRef carries context) - * back to sync code that reads Instance.directory from ALS. - */ - restore(ctx: InstanceContext, fn: () => R): R { - return context.provide(ctx, fn) - }, -} diff --git a/packages/opencode/src/project/with-instance.ts b/packages/opencode/src/project/with-instance.ts index b7b5360c753..27360736a9a 100644 --- a/packages/opencode/src/project/with-instance.ts +++ b/packages/opencode/src/project/with-instance.ts @@ -1,12 +1,12 @@ import { AppRuntime } from "@/effect/app-runtime" -import { context } from "./instance-context" +import type { InstanceContext } from "./instance-context" import { InstanceStore } from "./instance-store" -export async function provide(input: { directory: string; fn: () => R }): Promise { +export async function provide(input: { directory: string; fn: (ctx: InstanceContext) => R }): Promise { const ctx = await AppRuntime.runPromise( InstanceStore.Service.use((store) => store.load({ directory: input.directory })), ) - return context.provide(ctx, () => input.fn()) + return input.fn(ctx) } export * as WithInstance from "./with-instance" diff --git a/packages/opencode/src/server/routes/instance/httpapi/middleware/instance-context.ts b/packages/opencode/src/server/routes/instance/httpapi/middleware/instance-context.ts index d4913696d29..90f3ce47738 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/middleware/instance-context.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/middleware/instance-context.ts @@ -1,4 +1,4 @@ -import { WorkspaceRef } from "@/effect/instance-ref" +import { InstanceRef, WorkspaceRef } from "@/effect/instance-ref" import { InstanceStore } from "@/project/instance-store" import { Effect, Layer } from "effect" import { HttpRouter, HttpServerResponse } from "effect/unstable/http" @@ -26,9 +26,10 @@ function provideInstanceContext( ): Effect.Effect { return Effect.gen(function* () { const route = yield* WorkspaceRouteContext - return yield* store.provide( - { directory: decode(route.directory) }, - effect.pipe(Effect.provideService(WorkspaceRef, route.workspaceID)), + const ctx = yield* store.load({ directory: decode(route.directory) }) + return yield* effect.pipe( + Effect.provideService(InstanceRef, ctx), + Effect.provideService(WorkspaceRef, route.workspaceID), ) }) } diff --git a/packages/opencode/src/tool/shell.ts b/packages/opencode/src/tool/shell.ts index 1b3a6152ef0..506d98466e7 100644 --- a/packages/opencode/src/tool/shell.ts +++ b/packages/opencode/src/tool/shell.ts @@ -609,10 +609,10 @@ export const ShellTool = Tool.define( parameters: prompt.parameters, execute: (params: Parameters, ctx: Tool.Context) => Effect.gen(function* () { - const executeInstance = yield* InstanceState.context + const instanceCtx = yield* InstanceState.context const cwd = params.workdir - ? yield* resolvePath(params.workdir, executeInstance.directory, shell) - : executeInstance.directory + ? yield* resolvePath(params.workdir, instanceCtx.directory, shell) + : instanceCtx.directory if (params.timeout !== undefined && params.timeout < 0) { throw new Error(`Invalid timeout value: ${params.timeout}. Timeout must be a positive number.`) } @@ -623,8 +623,8 @@ export const ShellTool = Tool.define( const tree = yield* Effect.acquireRelease(parse(params.command, ps), (tree) => Effect.sync(() => tree.delete()), ) - const scan = yield* collect(tree.rootNode, cwd, ps, shell, executeInstance) - if (!containsPath(cwd, executeInstance)) scan.dirs.add(cwd) + const scan = yield* collect(tree.rootNode, cwd, ps, shell, instanceCtx) + if (!containsPath(cwd, instanceCtx)) scan.dirs.add(cwd) yield* ask(ctx, scan) }), ) diff --git a/packages/opencode/test/AGENTS.md b/packages/opencode/test/AGENTS.md index bff1ff5dde0..464b75cd82f 100644 --- a/packages/opencode/test/AGENTS.md +++ b/packages/opencode/test/AGENTS.md @@ -116,7 +116,7 @@ describe("my service", () => { Prefer the Effect-aware helpers from `fixture/fixture.ts` instead of building a manual runtime in each test. - `tmpdirScoped(options?)` creates a scoped temp directory and cleans it up when the Effect scope closes. -- `provideInstance(dir)(effect)` is the low-level helper. It does not create a directory; it just runs an Effect with `Instance.current` bound to `dir`. +- `provideInstance(dir)(effect)` is the low-level helper. It does not create a directory; it runs an Effect with `InstanceRef` provided for `dir`. - `provideTmpdirInstance((dir) => effect, options?)` is the convenience helper. It creates a temp directory, binds it as the active instance, and disposes the instance on cleanup. - `provideTmpdirServer((input) => effect, options?)` does the same, but also provides the test LLM server. diff --git a/packages/opencode/test/cli/effect-cmd-instance-als.test.ts b/packages/opencode/test/cli/effect-cmd-instance-als.test.ts index 122b87f1742..c8ed9722e78 100644 --- a/packages/opencode/test/cli/effect-cmd-instance-als.test.ts +++ b/packages/opencode/test/cli/effect-cmd-instance-als.test.ts @@ -3,7 +3,6 @@ import { AppFileSystem } from "@opencode-ai/core/filesystem" import { Effect } from "effect" import { fileURLToPath } from "url" import { InstanceRef } from "../../src/effect/instance-ref" -import { Instance } from "../../src/project/instance" import { disposeAllInstances, TestInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" @@ -13,46 +12,28 @@ afterEach(async () => { await disposeAllInstances() }) -// Regression for PR #25522: when an effectCmd handler does -// `yield* Effect.promise(async () => { ... await runPromise(svcMethod) ... })`, -// the inner runPromise creates a fresh fiber after `await` whose Effect context -// has lost the outer InstanceRef. Services that read `InstanceState.context` -// then fall back to `Instance.current` ALS, which must be installed at the JS -// callback boundary (Node ALS persists across awaits, Effect's fiber context -// does not). `it.instance` provides the loaded InstanceRef; the explicit -// Instance.restore mirrors effectCmd's load + ALS-restore wrap. -// Pins effect-cmd.ts directly: the pattern test below exercises the load + -// Instance.restore boundary via the shared `it.instance` fixture, -// so a regression that removed `Instance.restore` from effect-cmd.ts wouldn't -// fail it. This grep guards the actual production callsite. -it.live("effect-cmd.ts wraps the handler body in Instance.restore", () => +it.live("effect-cmd.ts does not restore legacy instance ALS", () => Effect.gen(function* () { const fs = yield* AppFileSystem.Service const source = yield* fs.readFileString(fileURLToPath(new URL("../../src/cli/effect-cmd.ts", import.meta.url))) - expect(source).toContain("Instance.restore(ctx") + expect(source).not.toContain("restore(ctx") }), ) it.instance( - "Instance.current reachable after await inside restored Effect.promise(async)", + "InstanceRef remains the handler context across Effect promise awaits", () => Effect.gen(function* () { const test = yield* TestInstance const ctx = yield* InstanceRef if (!ctx) throw new Error("InstanceRef not provided") - const current = yield* Effect.promise(() => - Instance.restore(ctx, async () => { - await Promise.resolve() - try { - return Instance.current - } catch { - return undefined - } - }), - ) + const directory = yield* Effect.promise(async () => { + await Promise.resolve() + return ctx.directory + }) - expect(current?.directory).toBe(test.directory) + expect(directory).toBe(test.directory) }), { git: true }, ) diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts index 90e78efcdba..e270b1e3633 100644 --- a/packages/opencode/test/config/config.test.ts +++ b/packages/opencode/test/config/config.test.ts @@ -6,7 +6,8 @@ import { ConfigManaged } from "@/config/managed" import { ConfigParse } from "../../src/config/parse" import { EffectFlock } from "@opencode-ai/core/util/effect-flock" -import { Instance } from "../../src/project/instance" +import { InstanceRef } from "../../src/effect/instance-ref" +import type { InstanceContext } from "../../src/project/instance-context" import { WithInstance } from "../../src/project/with-instance" import { Auth } from "../../src/auth" import { Account } from "../../src/account/account" @@ -61,9 +62,20 @@ const layer = Config.layer.pipe( const it = testEffect(layer) -const load = () => Effect.runPromise(Config.Service.use((svc) => svc.get()).pipe(Effect.scoped, Effect.provide(layer))) -const save = (config: Config.Info) => - Effect.runPromise(Config.Service.use((svc) => svc.update(config)).pipe(Effect.scoped, Effect.provide(layer))) +const provideCurrentInstance = (effect: Effect.Effect, ctx: InstanceContext) => + effect.pipe(Effect.provideService(InstanceRef, ctx)) + +const load = (ctx: InstanceContext) => + Effect.runPromise( + Config.Service.use((svc) => provideCurrentInstance(svc.get(), ctx)).pipe(Effect.scoped, Effect.provide(layer)), + ) +const save = (config: Config.Info, ctx: InstanceContext) => + Effect.runPromise( + Config.Service.use((svc) => provideCurrentInstance(svc.update(config), ctx)).pipe( + Effect.scoped, + Effect.provide(layer), + ), + ) const saveGlobal = (config: Config.Info) => Effect.runPromise( Config.Service.use((svc) => svc.updateGlobal(config)).pipe( @@ -76,10 +88,20 @@ const clear = async (wait = false) => { await Effect.runPromise(Config.Service.use((svc) => svc.invalidate()).pipe(Effect.scoped, Effect.provide(layer))) if (wait) await InstanceRuntime.disposeAllInstances() } -const listDirs = () => - Effect.runPromise(Config.Service.use((svc) => svc.directories()).pipe(Effect.scoped, Effect.provide(layer))) -const ready = () => - Effect.runPromise(Config.Service.use((svc) => svc.waitForDependencies()).pipe(Effect.scoped, Effect.provide(layer))) +const listDirs = (ctx: InstanceContext) => + Effect.runPromise( + Config.Service.use((svc) => provideCurrentInstance(svc.directories(), ctx)).pipe( + Effect.scoped, + Effect.provide(layer), + ), + ) +const ready = (ctx: InstanceContext) => + Effect.runPromise( + Config.Service.use((svc) => provideCurrentInstance(svc.waitForDependencies(), ctx)).pipe( + Effect.scoped, + Effect.provide(layer), + ), + ) // Get managed config directory from environment (set in preload.ts) const managedConfigDir = process.env.OPENCODE_TEST_MANAGED_CONFIG_DIR! @@ -116,11 +138,11 @@ async function check(map: (dir: string) => string) { }) await WithInstance.provide({ directory: map(tmp.path), - fn: async () => { - const cfg = await load() + fn: async (ctx) => { + const cfg = await load(ctx) expect(cfg.snapshot).toBe(true) - expect(Instance.directory).toBe(Filesystem.resolve(tmp.path)) - expect(Instance.project.id).not.toBe(ProjectID.global) + expect(ctx.directory).toBe(Filesystem.resolve(tmp.path)) + expect(ctx.project.id).not.toBe(ProjectID.global) }, }) } finally { @@ -134,8 +156,8 @@ test("loads config with defaults when no files exist", async () => { await using tmp = await tmpdir() await WithInstance.provide({ directory: tmp.path, - fn: async () => { - const config = await load() + fn: async (ctx) => { + const config = await load(ctx) expect(config.username).toBeDefined() }, }) @@ -150,8 +172,8 @@ test("creates global jsonc config with schema when no global configs exist", asy try { await WithInstance.provide({ directory: tmp.path, - fn: async () => { - await load() + fn: async (ctx) => { + await load(ctx) }, }) @@ -175,8 +197,8 @@ test("does not create global config when OPENCODE_CONFIG_DIR is set", async () = try { await WithInstance.provide({ directory: tmp.path, - fn: async () => { - await load() + fn: async (ctx) => { + await load(ctx) }, }) @@ -201,8 +223,8 @@ test("loads JSON config file", async () => { }) await WithInstance.provide({ directory: tmp.path, - fn: async () => { - const config = await load() + fn: async (ctx) => { + const config = await load(ctx) expect(config.model).toBe("test/model") expect(config.username).toBe("testuser") }, @@ -220,8 +242,8 @@ test("loads shell config field", async () => { }) await WithInstance.provide({ directory: tmp.path, - fn: async () => { - const config = await load() + fn: async (ctx) => { + const config = await load(ctx) expect(config.shell).toBe("bash") }, }) @@ -242,8 +264,8 @@ test("updates config and preserves empty shell sentinel", async () => { }) await WithInstance.provide({ directory: tmp.path, - fn: async () => { - await save({ shell: "" }) + fn: async (ctx) => { + await save({ shell: "" }, ctx) const writtenConfig = await Filesystem.readJson<{ shell?: string }>(path.join(tmp.path, "config.json")) expect(writtenConfig.shell).toBe("") @@ -320,8 +342,8 @@ test("loads formatter boolean config", async () => { }) await WithInstance.provide({ directory: tmp.path, - fn: async () => { - const config = await load() + fn: async (ctx) => { + const config = await load(ctx) expect(config.formatter).toBe(true) }, }) @@ -338,8 +360,8 @@ test("loads lsp boolean config", async () => { }) await WithInstance.provide({ directory: tmp.path, - fn: async () => { - const config = await load() + fn: async (ctx) => { + const config = await load(ctx) expect(config.lsp).toBe(true) }, }) @@ -375,8 +397,8 @@ test("ignores legacy tui keys in opencode config", async () => { }) await WithInstance.provide({ directory: tmp.path, - fn: async () => { - const config = await load() + fn: async (ctx) => { + const config = await load(ctx) expect(config.model).toBe("test/model") expect((config as Record).theme).toBeUndefined() expect((config as Record).tui).toBeUndefined() @@ -400,8 +422,8 @@ test("loads JSONC config file", async () => { }) await WithInstance.provide({ directory: tmp.path, - fn: async () => { - const config = await load() + fn: async (ctx) => { + const config = await load(ctx) expect(config.model).toBe("test/model") expect(config.username).toBe("testuser") }, @@ -428,8 +450,8 @@ test("jsonc overrides json in the same directory", async () => { }) await WithInstance.provide({ directory: tmp.path, - fn: async () => { - const config = await load() + fn: async (ctx) => { + const config = await load(ctx) expect(config.model).toBe("base") expect(config.username).toBe("base") }, @@ -451,8 +473,8 @@ test("handles environment variable substitution", async () => { }) await WithInstance.provide({ directory: tmp.path, - fn: async () => { - const config = await load() + fn: async (ctx) => { + const config = await load(ctx) expect(config.username).toBe("test-user") }, }) @@ -483,8 +505,8 @@ test("preserves env variables when adding $schema to config", async () => { }) await WithInstance.provide({ directory: tmp.path, - fn: async () => { - const config = await load() + fn: async (ctx) => { + const config = await load(ctx) expect(config.username).toBe("secret_value") // Read the file to verify the env variable was preserved @@ -580,8 +602,8 @@ test("handles file inclusion substitution", async () => { }) await WithInstance.provide({ directory: tmp.path, - fn: async () => { - const config = await load() + fn: async (ctx) => { + const config = await load(ctx) expect(config.username).toBe("test-user") }, }) @@ -599,8 +621,8 @@ test("handles file inclusion with replacement tokens", async () => { }) await WithInstance.provide({ directory: tmp.path, - fn: async () => { - const config = await load() + fn: async (ctx) => { + const config = await load(ctx) expect(config.username).toBe("const out = await Bun.$`echo hi`") }, }) @@ -617,9 +639,9 @@ test("validates config schema and throws on invalid fields", async () => { }) await provideTestInstance({ directory: tmp.path, - fn: async () => { + fn: async (ctx) => { // Strict schema should throw an error for invalid fields - await expect(load()).rejects.toThrow() + await expect(load(ctx)).rejects.toThrow() }, }) }) @@ -632,8 +654,8 @@ test("throws error for invalid JSON", async () => { }) await provideTestInstance({ directory: tmp.path, - fn: async () => { - await expect(load()).rejects.toThrow() + fn: async (ctx) => { + await expect(load(ctx)).rejects.toThrow() }, }) }) @@ -655,8 +677,8 @@ test("handles agent configuration", async () => { }) await WithInstance.provide({ directory: tmp.path, - fn: async () => { - const config = await load() + fn: async (ctx) => { + const config = await load(ctx) expect(config.agent?.["test_agent"]).toEqual( expect.objectContaining({ model: "test/model", @@ -686,8 +708,8 @@ test("treats agent variant as model-scoped setting (not provider option)", async await WithInstance.provide({ directory: tmp.path, - fn: async () => { - const config = await load() + fn: async (ctx) => { + const config = await load(ctx) const agent = config.agent?.["test_agent"] expect(agent?.variant).toBe("xhigh") @@ -716,8 +738,8 @@ test("handles command configuration", async () => { }) await WithInstance.provide({ directory: tmp.path, - fn: async () => { - const config = await load() + fn: async (ctx) => { + const config = await load(ctx) expect(config.command?.["test_command"]).toEqual({ template: "test template", description: "test command", @@ -741,8 +763,8 @@ test("migrates autoshare to share field", async () => { }) await WithInstance.provide({ directory: tmp.path, - fn: async () => { - const config = await load() + fn: async (ctx) => { + const config = await load(ctx) expect(config.share).toBe("auto") expect(config.autoshare).toBe(true) }, @@ -768,8 +790,8 @@ test("migrates mode field to agent field", async () => { }) await WithInstance.provide({ directory: tmp.path, - fn: async () => { - const config = await load() + fn: async (ctx) => { + const config = await load(ctx) expect(config.agent?.["test_mode"]).toEqual({ model: "test/model", temperature: 0.5, @@ -800,8 +822,8 @@ Test agent prompt`, }) await WithInstance.provide({ directory: tmp.path, - fn: async () => { - const config = await load() + fn: async (ctx) => { + const config = await load(ctx) expect(config.agent?.["test"]).toEqual( expect.objectContaining({ name: "test", @@ -833,8 +855,8 @@ Ordered permissions`, }) await WithInstance.provide({ directory: tmp.path, - fn: async () => { - const config = await load() + fn: async (ctx) => { + const config = await load(ctx) expect(Object.keys(config.agent?.ordered?.permission ?? {})).toEqual(["bash", "*", "edit"]) }, }) @@ -871,8 +893,8 @@ Nested agent prompt`, await WithInstance.provide({ directory: tmp.path, - fn: async () => { - const config = await load() + fn: async (ctx) => { + const config = await load(ctx) expect(config.agent?.["helper"]).toMatchObject({ name: "helper", @@ -920,8 +942,8 @@ Nested command template`, await WithInstance.provide({ directory: tmp.path, - fn: async () => { - const config = await load() + fn: async (ctx) => { + const config = await load(ctx) expect(config.command?.["hello"]).toEqual({ description: "Test command", @@ -965,8 +987,8 @@ Nested command template`, await WithInstance.provide({ directory: tmp.path, - fn: async () => { - const config = await load() + fn: async (ctx) => { + const config = await load(ctx) expect(config.command?.["hello"]).toEqual({ description: "Test command", @@ -985,9 +1007,9 @@ test("updates config and writes to file", async () => { await using tmp = await tmpdir() await WithInstance.provide({ directory: tmp.path, - fn: async () => { + fn: async (ctx) => { const newConfig = { model: "updated/model" } - await save(newConfig as any) + await save(newConfig as any, ctx) const writtenConfig = await Filesystem.readJson<{ model: string }>(path.join(tmp.path, "config.json")) expect(writtenConfig.model).toBe("updated/model") @@ -999,8 +1021,8 @@ test("gets config directories", async () => { await using tmp = await tmpdir() await WithInstance.provide({ directory: tmp.path, - fn: async () => { - const dirs = await listDirs() + fn: async (ctx) => { + const dirs = await listDirs(ctx) expect(dirs.length).toBeGreaterThanOrEqual(1) }, }) @@ -1029,8 +1051,8 @@ test("does not try to install dependencies in read-only OPENCODE_CONFIG_DIR", as try { await WithInstance.provide({ directory: tmp.path, - fn: async () => { - await load() + fn: async (ctx) => { + await load(ctx) }, }) } finally { @@ -1064,10 +1086,18 @@ test("installs dependencies in writable OPENCODE_CONFIG_DIR", async () => { try { await WithInstance.provide({ directory: tmp.path, - fn: async () => { - await Effect.runPromise(Config.Service.use((svc) => svc.get()).pipe(Effect.scoped, Effect.provide(testLayer))) + fn: async (ctx) => { await Effect.runPromise( - Config.Service.use((svc) => svc.waitForDependencies()).pipe(Effect.scoped, Effect.provide(testLayer)), + Config.Service.use((svc) => svc.get().pipe(Effect.provideService(InstanceRef, ctx))).pipe( + Effect.scoped, + Effect.provide(testLayer), + ), + ) + await Effect.runPromise( + Config.Service.use((svc) => svc.waitForDependencies().pipe(Effect.provideService(InstanceRef, ctx))).pipe( + Effect.scoped, + Effect.provide(testLayer), + ), ) }, }) @@ -1123,8 +1153,8 @@ test("resolves scoped npm plugins in config", async () => { await provideTestInstance({ directory: tmp.path, - fn: async () => { - const config = await load() + fn: async (ctx) => { + const config = await load(ctx) const pluginEntries = config.plugin ?? [] expect(pluginEntries).toContain("@scope/plugin") }, @@ -1161,8 +1191,8 @@ test("merges plugin arrays from global and local configs", async () => { await provideTestInstance({ directory: path.join(tmp.path, "project"), - fn: async () => { - const config = await load() + fn: async (ctx) => { + const config = await load(ctx) const plugins = config.plugin ?? [] // Should contain both global and local plugins @@ -1197,8 +1227,8 @@ Helper subagent prompt`, }) await WithInstance.provide({ directory: tmp.path, - fn: async () => { - const config = await load() + fn: async (ctx) => { + const config = await load(ctx) expect(config.agent?.["helper"]).toMatchObject({ name: "helper", model: "test/model", @@ -1236,8 +1266,8 @@ test("merges instructions arrays from global and local configs", async () => { await WithInstance.provide({ directory: path.join(tmp.path, "project"), - fn: async () => { - const config = await load() + fn: async (ctx) => { + const config = await load(ctx) const instructions = config.instructions ?? [] expect(instructions).toContain("global-instructions.md") @@ -1275,8 +1305,8 @@ test("deduplicates duplicate instructions from global and local configs", async await WithInstance.provide({ directory: path.join(tmp.path, "project"), - fn: async () => { - const config = await load() + fn: async (ctx) => { + const config = await load(ctx) const instructions = config.instructions ?? [] expect(instructions).toContain("global-only.md") @@ -1320,8 +1350,8 @@ test("deduplicates duplicate plugins from global and local configs", async () => await provideTestInstance({ directory: path.join(tmp.path, "project"), - fn: async () => { - const config = await load() + fn: async (ctx) => { + const config = await load(ctx) const plugins = config.plugin ?? [] // Should contain all unique plugins @@ -1369,8 +1399,8 @@ test("keeps plugin origins aligned with merged plugin list", async () => { await provideTestInstance({ directory: path.join(tmp.path, "project"), - fn: async () => { - const cfg = await load() + fn: async (ctx) => { + const cfg = await load(ctx) const plugins = cfg.plugin ?? [] const origins = cfg.plugin_origins ?? [] const names = plugins.map((item) => ConfigPlugin.pluginSpecifier(item)) @@ -1410,8 +1440,8 @@ test("migrates legacy tools config to permissions - allow", async () => { }) await WithInstance.provide({ directory: tmp.path, - fn: async () => { - const config = await load() + fn: async (ctx) => { + const config = await load(ctx) expect(config.agent?.["test"]?.permission).toEqual({ bash: "allow", read: "allow", @@ -1441,8 +1471,8 @@ test("migrates legacy tools config to permissions - deny", async () => { }) await WithInstance.provide({ directory: tmp.path, - fn: async () => { - const config = await load() + fn: async (ctx) => { + const config = await load(ctx) expect(config.agent?.["test"]?.permission).toEqual({ bash: "deny", webfetch: "deny", @@ -1471,8 +1501,8 @@ test("migrates legacy write tool to edit permission", async () => { }) await WithInstance.provide({ directory: tmp.path, - fn: async () => { - const config = await load() + fn: async (ctx) => { + const config = await load(ctx) expect(config.agent?.["test"]?.permission).toEqual({ edit: "allow", }) @@ -1503,8 +1533,8 @@ test("managed settings override user settings", async () => { await WithInstance.provide({ directory: tmp.path, - fn: async () => { - const config = await load() + fn: async (ctx) => { + const config = await load(ctx) expect(config.model).toBe("managed/model") expect(config.share).toBe("disabled") expect(config.username).toBe("testuser") @@ -1531,8 +1561,8 @@ test("managed settings override project settings", async () => { await WithInstance.provide({ directory: tmp.path, - fn: async () => { - const config = await load() + fn: async (ctx) => { + const config = await load(ctx) expect(config.autoupdate).toBe(false) expect(config.disabled_providers).toEqual(["openai"]) }, @@ -1551,8 +1581,8 @@ test("missing managed settings file is not an error", async () => { await WithInstance.provide({ directory: tmp.path, - fn: async () => { - const config = await load() + fn: async (ctx) => { + const config = await load(ctx) expect(config.model).toBe("user/model") }, }) @@ -1578,8 +1608,8 @@ test("migrates legacy edit tool to edit permission", async () => { }) await WithInstance.provide({ directory: tmp.path, - fn: async () => { - const config = await load() + fn: async (ctx) => { + const config = await load(ctx) expect(config.agent?.["test"]?.permission).toEqual({ edit: "deny", }) @@ -1607,8 +1637,8 @@ test("migrates legacy patch tool to edit permission", async () => { }) await WithInstance.provide({ directory: tmp.path, - fn: async () => { - const config = await load() + fn: async (ctx) => { + const config = await load(ctx) expect(config.agent?.["test"]?.permission).toEqual({ edit: "allow", }) @@ -1639,8 +1669,8 @@ test("migrates mixed legacy tools config", async () => { }) await WithInstance.provide({ directory: tmp.path, - fn: async () => { - const config = await load() + fn: async (ctx) => { + const config = await load(ctx) expect(config.agent?.["test"]?.permission).toEqual({ bash: "allow", edit: "allow", @@ -1674,8 +1704,8 @@ test("merges legacy tools with existing permission config", async () => { }) await WithInstance.provide({ directory: tmp.path, - fn: async () => { - const config = await load() + fn: async (ctx) => { + const config = await load(ctx) expect(config.agent?.["test"]?.permission).toEqual({ glob: "allow", bash: "allow", @@ -1711,8 +1741,8 @@ test("permission config preserves user key order", async () => { }) await WithInstance.provide({ directory: tmp.path, - fn: async () => { - const config = await load() + fn: async (ctx) => { + const config = await load(ctx) expect(Object.keys(config.permission!)).toEqual([ "*", "edit", @@ -1794,8 +1824,8 @@ test("project config can override MCP server enabled status", async () => { }) await WithInstance.provide({ directory: tmp.path, - fn: async () => { - const config = await load() + fn: async (ctx) => { + const config = await load(ctx) // jira should be enabled (overridden by project config) expect(config.mcp?.jira).toEqual({ type: "remote", @@ -1850,8 +1880,8 @@ test("MCP config deep merges preserving base config properties", async () => { }) await WithInstance.provide({ directory: tmp.path, - fn: async () => { - const config = await load() + fn: async (ctx) => { + const config = await load(ctx) expect(config.mcp?.myserver).toEqual({ type: "remote", url: "https://myserver.example.com/mcp", @@ -1901,8 +1931,8 @@ test("local .opencode config can override MCP from project config", async () => }) await WithInstance.provide({ directory: tmp.path, - fn: async () => { - const config = await load() + fn: async (ctx) => { + const config = await load(ctx) expect(config.mcp?.docs?.enabled).toBe(true) }, }) @@ -2235,8 +2265,8 @@ describe("deduplicatePluginOrigins", () => { await provideTestInstance({ directory: path.join(tmp.path, "project"), - fn: async () => { - const config = await load() + fn: async (ctx) => { + const config = await load(ctx) const plugins = config.plugin ?? [] expect(plugins.some((p) => ConfigPlugin.pluginSpecifier(p) === "my-plugin@1.0.0")).toBe(true) @@ -2267,8 +2297,8 @@ describe("OPENCODE_DISABLE_PROJECT_CONFIG", () => { }) await WithInstance.provide({ directory: tmp.path, - fn: async () => { - const config = await load() + fn: async (ctx) => { + const config = await load(ctx) // Project config should NOT be loaded - model should be default, not "project/model" expect(config.model).not.toBe("project/model") expect(config.username).not.toBe("project-user") @@ -2298,8 +2328,8 @@ describe("OPENCODE_DISABLE_PROJECT_CONFIG", () => { }) await WithInstance.provide({ directory: tmp.path, - fn: async () => { - const directories = await listDirs() + fn: async (ctx) => { + const directories = await listDirs(ctx) // Project .opencode should NOT be in directories list const hasProjectOpencode = directories.some((d) => d.startsWith(tmp.path)) expect(hasProjectOpencode).toBe(false) @@ -2322,9 +2352,9 @@ describe("OPENCODE_DISABLE_PROJECT_CONFIG", () => { await using tmp = await tmpdir() await WithInstance.provide({ directory: tmp.path, - fn: async () => { + fn: async (ctx) => { // Should still get default config (from global or defaults) - const config = await load() + const config = await load(ctx) expect(config).toBeDefined() expect(config.username).toBeDefined() }, @@ -2364,10 +2394,10 @@ describe("OPENCODE_DISABLE_PROJECT_CONFIG", () => { await WithInstance.provide({ directory: tmp.path, - fn: async () => { + fn: async (ctx) => { // The relative instruction should be skipped without error // We're mainly verifying this doesn't throw and the config loads - const config = await load() + const config = await load(ctx) expect(config).toBeDefined() // The instruction should have been skipped (warning logged) // We can't easily test the warning was logged, but we verify @@ -2424,8 +2454,8 @@ describe("OPENCODE_DISABLE_PROJECT_CONFIG", () => { await WithInstance.provide({ directory: projectTmp.path, - fn: async () => { - const config = await load() + fn: async (ctx) => { + const config = await load(ctx) // Should load from OPENCODE_CONFIG_DIR, not project expect(config.model).toBe("configdir/model") }, @@ -2459,8 +2489,8 @@ describe("OPENCODE_CONFIG_CONTENT token substitution", () => { await using tmp = await tmpdir() await WithInstance.provide({ directory: tmp.path, - fn: async () => { - const config = await load() + fn: async (ctx) => { + const config = await load(ctx) expect(config.username).toBe("test_api_key_12345") }, }) @@ -2493,8 +2523,8 @@ describe("OPENCODE_CONFIG_CONTENT token substitution", () => { }) await WithInstance.provide({ directory: tmp.path, - fn: async () => { - const config = await load() + fn: async (ctx) => { + const config = await load(ctx) expect(config.username).toBe("secret_key_from_file") }, }) diff --git a/packages/opencode/test/control-plane/workspace.test.ts b/packages/opencode/test/control-plane/workspace.test.ts index 00cd32a3f77..26784592fbd 100644 --- a/packages/opencode/test/control-plane/workspace.test.ts +++ b/packages/opencode/test/control-plane/workspace.test.ts @@ -139,7 +139,18 @@ async function initGitRepo(dir: string) { await $`git commit -m "base"`.cwd(dir).quiet() } -const runWorkspace = (effect: Effect.Effect) => AppRuntime.runPromise(effect) +function currentInstance() { + try { + return context.use() + } catch { + return undefined + } +} + +const runWorkspace = (effect: Effect.Effect) => { + const ctx = currentInstance() + return AppRuntime.runPromise(ctx ? effect.pipe(Effect.provideService(InstanceRef, ctx)) : effect) +} const createWorkspace = (input: Workspace.CreateInput) => runWorkspace(Workspace.Service.use((workspace) => workspace.create(input))) const warpWorkspaceSession = (input: Workspace.SessionWarpInput) => @@ -917,7 +928,9 @@ describe("workspace CRUD", () => { const previous = workspaceInfo(projectID, previousType) insertWorkspace(previous) registerAdapter(projectID, previousType, localAdapter(workspaceTmp.path, { createDir: false }).adapter) - const session = await AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.create({}))) + const session = await AppRuntime.runPromise( + SessionNs.Service.use((svc) => svc.create({})).pipe(Effect.provideService(InstanceRef, instance)), + ) attachSessionToWorkspace(session.id, previous.id) const workspaceCtx = await AppRuntime.runPromise( diff --git a/packages/opencode/test/effect/instance-state.test.ts b/packages/opencode/test/effect/instance-state.test.ts index b928f16bc6a..23cd51d7f35 100644 --- a/packages/opencode/test/effect/instance-state.test.ts +++ b/packages/opencode/test/effect/instance-state.test.ts @@ -3,7 +3,6 @@ import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { $ } from "bun" import { Context, Deferred, Duration, Effect, Exit, Fiber, Layer } from "effect" import { InstanceState } from "@/effect/instance-state" -import { Instance } from "../../src/project/instance" import { disposeAllInstances, provideInstance, reloadTestInstance, tmpdirScoped } from "../fixture/fixture" import { testEffect } from "../lib/effect" diff --git a/packages/opencode/test/fixture/fixture.ts b/packages/opencode/test/fixture/fixture.ts index fedbc246bc1..51b62949759 100644 --- a/packages/opencode/test/fixture/fixture.ts +++ b/packages/opencode/test/fixture/fixture.ts @@ -11,9 +11,9 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" import type { Config } from "@/config/config" import { InstanceRef } from "../../src/effect/instance-ref" import { InstanceBootstrap } from "../../src/project/bootstrap-service" +import type { InstanceContext } from "../../src/project/instance-context" import { InstanceRuntime } from "../../src/project/instance-runtime" import { InstanceStore } from "../../src/project/instance-store" -import { Instance } from "../../src/project/instance" import { TestLLMServer } from "../lib/llm-server" const noopBootstrap = Layer.succeed(InstanceBootstrap.Service, InstanceBootstrap.Service.of({ run: Effect.void })) @@ -24,11 +24,15 @@ const testInstanceRuntime = ManagedRuntime.make( const runTestInstanceStore = (fn: (store: InstanceStore.Interface) => Effect.Effect) => testInstanceRuntime.runPromise(InstanceStore.Service.use(fn)) -export async function provideTestInstance(input: { directory: string; init?: Effect.Effect; fn: () => R }) { +export async function provideTestInstance(input: { + directory: string + init?: Effect.Effect + fn: (ctx: InstanceContext) => R +}) { const ctx = await runTestInstanceStore((store) => store.load({ directory: input.directory })) try { if (input.init) await testInstanceRuntime.runPromise(input.init.pipe(Effect.provideService(InstanceRef, ctx))) - return await Instance.restore(ctx, () => input.fn()) + return await input.fn(ctx) } finally { await runTestInstanceStore((store) => store.dispose(ctx)) } @@ -157,9 +161,7 @@ export const provideInstance = Effect.contextWith((services: Context.Context) => Effect.promise(async () => { const ctx = await runTestInstanceStore((store) => store.load({ directory })) - return Instance.restore(ctx, () => - Effect.runPromiseWith(services)(self.pipe(Effect.provideService(InstanceRef, ctx))), - ) + return Effect.runPromiseWith(services)(self.pipe(Effect.provideService(InstanceRef, ctx))) }), ) diff --git a/packages/opencode/test/lsp/client.test.ts b/packages/opencode/test/lsp/client.test.ts index 7d9f5a71555..1897e6537d6 100644 --- a/packages/opencode/test/lsp/client.test.ts +++ b/packages/opencode/test/lsp/client.test.ts @@ -4,7 +4,6 @@ import { pathToFileURL } from "url" import { tmpdir } from "../fixture/fixture" import { LSPClient } from "@/lsp/client" import * as LSPServer from "@/lsp/server" -import { Instance } from "../../src/project/instance" import { WithInstance } from "../../src/project/with-instance" import * as Log from "@opencode-ai/core/util/log" @@ -28,12 +27,13 @@ describe("LSPClient interop", () => { const client = await WithInstance.provide({ directory: process.cwd(), - fn: () => + fn: (ctx) => LSPClient.create({ serverID: "fake", server: handle as unknown as LSPServer.Handle, root: process.cwd(), directory: process.cwd(), + instance: ctx, }), }) @@ -51,12 +51,13 @@ describe("LSPClient interop", () => { const client = await WithInstance.provide({ directory: process.cwd(), - fn: () => + fn: (ctx) => LSPClient.create({ serverID: "fake", server: handle as unknown as LSPServer.Handle, root: process.cwd(), directory: process.cwd(), + instance: ctx, }), }) @@ -74,12 +75,13 @@ describe("LSPClient interop", () => { const client = await WithInstance.provide({ directory: process.cwd(), - fn: () => + fn: (ctx) => LSPClient.create({ serverID: "fake", server: handle as unknown as LSPServer.Handle, root: process.cwd(), directory: process.cwd(), + instance: ctx, }), }) @@ -97,12 +99,13 @@ describe("LSPClient interop", () => { const client = await WithInstance.provide({ directory: process.cwd(), - fn: () => + fn: (ctx) => LSPClient.create({ serverID: "fake", server: handle as unknown as LSPServer.Handle, root: process.cwd(), directory: process.cwd(), + instance: ctx, }), }) @@ -124,7 +127,7 @@ describe("LSPClient interop", () => { const client = await WithInstance.provide({ directory: process.cwd(), - fn: () => + fn: (ctx) => LSPClient.create({ serverID: "fake", server: { @@ -133,6 +136,7 @@ describe("LSPClient interop", () => { }, root: process.cwd(), directory: process.cwd(), + instance: ctx, }), }) @@ -153,12 +157,13 @@ describe("LSPClient interop", () => { await WithInstance.provide({ directory: tmp.path, - fn: async () => { + fn: async (ctx) => { const client = await LSPClient.create({ serverID: "fake", server: handle as unknown as LSPServer.Handle, root: tmp.path, directory: tmp.path, + instance: ctx, }) await client.notify.open({ path: file }) @@ -196,12 +201,13 @@ describe("LSPClient interop", () => { await WithInstance.provide({ directory: tmp.path, - fn: async () => { + fn: async (ctx) => { const client = await LSPClient.create({ serverID: "fake", server: handle as unknown as LSPServer.Handle, root: tmp.path, directory: tmp.path, + instance: ctx, }) const version = await client.notify.open({ path: file }) @@ -242,12 +248,13 @@ describe("LSPClient interop", () => { await WithInstance.provide({ directory: tmp.path, - fn: async () => { + fn: async (ctx) => { const client = await LSPClient.create({ serverID: "fake", server: handle as unknown as LSPServer.Handle, root: tmp.path, directory: tmp.path, + instance: ctx, }) const version = await client.notify.open({ path: file }) @@ -289,12 +296,13 @@ describe("LSPClient interop", () => { await WithInstance.provide({ directory: tmp.path, - fn: async () => { + fn: async (ctx) => { const client = await LSPClient.create({ serverID: "fake", server: handle as unknown as LSPServer.Handle, root: tmp.path, directory: tmp.path, + instance: ctx, }) await client.connection.sendRequest("test/configure-pull-diagnostics", { @@ -337,12 +345,13 @@ describe("LSPClient interop", () => { await WithInstance.provide({ directory: tmp.path, - fn: async () => { + fn: async (ctx) => { const client = await LSPClient.create({ serverID: "fake", server: handle as unknown as LSPServer.Handle, root: tmp.path, directory: tmp.path, + instance: ctx, }) await client.connection.sendRequest("test/configure-pull-diagnostics", { @@ -390,12 +399,13 @@ describe("LSPClient interop", () => { await WithInstance.provide({ directory: tmp.path, - fn: async () => { + fn: async (ctx) => { const client = await LSPClient.create({ serverID: "fake", server: handle as unknown as LSPServer.Handle, root: tmp.path, directory: tmp.path, + instance: ctx, }) await client.connection.sendRequest("test/configure-pull-diagnostics", { @@ -454,12 +464,13 @@ describe("LSPClient interop", () => { await WithInstance.provide({ directory: tmp.path, - fn: async () => { + fn: async (ctx) => { const client = await LSPClient.create({ serverID: "fake", server: handle as unknown as LSPServer.Handle, root: tmp.path, directory: tmp.path, + instance: ctx, }) await client.connection.sendRequest("test/configure-pull-diagnostics", { diff --git a/packages/opencode/test/plugin/workspace-adapter.test.ts b/packages/opencode/test/plugin/workspace-adapter.test.ts index 41dbf53445d..b4b40fe7677 100644 --- a/packages/opencode/test/plugin/workspace-adapter.test.ts +++ b/packages/opencode/test/plugin/workspace-adapter.test.ts @@ -15,10 +15,10 @@ import { RuntimeFlags } from "../../src/effect/runtime-flags" import { Workspace } from "../../src/control-plane/workspace" import { Plugin } from "../../src/plugin/index" import { InstanceBootstrap } from "../../src/project/bootstrap-service" -import { Instance } from "../../src/project/instance" import { InstanceStore } from "../../src/project/instance-store" import { Project } from "../../src/project/project" import { Vcs } from "../../src/project/vcs" +import { InstanceState } from "../../src/effect/instance-state" import { Session } from "../../src/session/session" import { SessionPrompt } from "../../src/session/prompt" import { SyncEvent } from "../../src/sync" @@ -116,11 +116,12 @@ describe("plugin.workspace", () => { const plugin = yield* Plugin.Service yield* plugin.init() const workspace = yield* Workspace.Service + const ctx = yield* InstanceState.context const info = yield* workspace.create({ type, branch: null, extra: { key: "value" }, - projectID: Instance.project.id, + projectID: ctx.project.id, }) expect(info.type).toBe(type) diff --git a/packages/opencode/test/project/instance.test.ts b/packages/opencode/test/project/instance.test.ts index 9c0f9150e10..491cfe93d70 100644 --- a/packages/opencode/test/project/instance.test.ts +++ b/packages/opencode/test/project/instance.test.ts @@ -4,9 +4,8 @@ import { Deferred, Effect, Fiber, Layer } from "effect" import { InstanceRef } from "../../src/effect/instance-ref" import { registerDisposer } from "../../src/effect/instance-registry" import { InstanceBootstrap } from "../../src/project/bootstrap-service" -import { Instance } from "../../src/project/instance" import { InstanceStore } from "../../src/project/instance-store" -import { TestInstance, tmpdirScoped } from "../fixture/fixture" +import { tmpdirScoped } from "../fixture/fixture" import { testEffect } from "../lib/effect" let bootstrapRun: Effect.Effect = Effect.void @@ -37,7 +36,7 @@ const registerDisposerScoped = (disposer: (directory: string) => Promise) ) describe("InstanceStore", () => { - it.live("loads instance context without installing ALS for the caller", () => + it.live("loads instance context", () => Effect.gen(function* () { const dir = yield* tmpdirScoped({ git: true }) const store = yield* InstanceStore.Service @@ -45,7 +44,6 @@ describe("InstanceStore", () => { expect(ctx.directory).toBe(dir) expect(ctx.worktree).toBe(dir) - expect(() => Instance.current).toThrow() }), ) @@ -63,7 +61,6 @@ describe("InstanceStore", () => { yield* store.load({ directory: dir }) expect(initializedDirectory).toBe(dir) - expect(() => Instance.current).toThrow() }), ) @@ -245,20 +242,4 @@ describe("InstanceStore", () => { expect(disposed).toEqual([dir1, dir2]) }), ) - - it.instance( - "provides legacy Promise callers with instance ALS", - () => - Effect.gen(function* () { - const test = yield* TestInstance - const ctx = yield* InstanceRef - if (!ctx) throw new Error("InstanceRef not provided") - - const directory = yield* Effect.promise(() => Promise.resolve(Instance.restore(ctx, () => Instance.directory))) - - expect(directory).toBe(test.directory) - expect(() => Instance.current).toThrow() - }), - { git: true }, - ) }) diff --git a/packages/opencode/test/project/worktree.test.ts b/packages/opencode/test/project/worktree.test.ts index 308e2f957b4..688b818beed 100644 --- a/packages/opencode/test/project/worktree.test.ts +++ b/packages/opencode/test/project/worktree.test.ts @@ -5,7 +5,7 @@ import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { Cause, Deferred, Effect, Exit, Fiber, Layer } from "effect" import { GlobalBus, type GlobalEvent } from "../../src/bus/global" import { Git } from "../../src/git" -import { Instance } from "../../src/project/instance" +import { InstanceRef } from "../../src/effect/instance-ref" import { InstanceRuntime } from "../../src/project/instance-runtime" import { Worktree } from "../../src/worktree" import { disposeAllInstances, provideInstance, TestInstance } from "../fixture/fixture" @@ -41,7 +41,10 @@ const waitReady = Effect.fn("WorktreeTest.waitReady")(function* () { const removeCreatedWorktree = (directory: string) => Effect.gen(function* () { const svc = yield* Worktree.Service - const ctx = yield* Effect.sync(() => Instance.current).pipe(provideInstance(directory)) + const ctx = yield* Effect.gen(function* () { + return yield* InstanceRef + }).pipe(provideInstance(directory)) + if (!ctx) return yield* Effect.die(new Error("missing test instance")) yield* Effect.promise(() => InstanceRuntime.disposeInstance(ctx)) const ok = yield* svc.remove({ directory }) if (!ok) return yield* Effect.fail(new Error(`failed to remove worktree ${directory}`)) diff --git a/packages/opencode/test/provider/amazon-bedrock.test.ts b/packages/opencode/test/provider/amazon-bedrock.test.ts index c35a03d78bc..35824fb2f7b 100644 --- a/packages/opencode/test/provider/amazon-bedrock.test.ts +++ b/packages/opencode/test/provider/amazon-bedrock.test.ts @@ -1,10 +1,10 @@ -import { test, expect, describe } from "bun:test" +import { afterEach, test, expect, describe } from "bun:test" import path from "path" import { unlink } from "fs/promises" import { ProviderID } from "../../src/provider/schema" -import { tmpdir } from "../fixture/fixture" -import { Instance } from "../../src/project/instance" +import { disposeAllInstances, tmpdir } from "../fixture/fixture" +import type { InstanceContext } from "../../src/project/instance-context" import { WithInstance } from "../../src/project/with-instance" import { Provider } from "@/provider/provider" import { Env } from "../../src/env" @@ -12,17 +12,37 @@ import { Global } from "@opencode-ai/core/global" import { Filesystem } from "@/util/filesystem" import { Effect } from "effect" import { AppRuntime } from "../../src/effect/app-runtime" +import { InstanceRef } from "../../src/effect/instance-ref" import { makeRuntime } from "../../src/effect/run-service" const env = makeRuntime(Env.Service, Env.defaultLayer) -const set = (k: string, v: string) => env.runSync((svc) => svc.set(k, v)) +const originalEnv = new Map() -async function list() { +function rememberEnv(k: string) { + if (!originalEnv.has(k)) originalEnv.set(k, process.env[k]) +} + +const set = (ctx: InstanceContext, k: string, v: string) => { + rememberEnv(k) + process.env[k] = v + return env.runSync((svc) => svc.set(k, v).pipe(Effect.provideService(InstanceRef, ctx))) +} + +afterEach(async () => { + for (const [key, value] of originalEnv) { + if (value === undefined) delete process.env[key] + else process.env[key] = value + } + originalEnv.clear() + await disposeAllInstances() +}) + +async function list(ctx: InstanceContext) { return AppRuntime.runPromise( Effect.gen(function* () { const provider = yield* Provider.Service return yield* provider.list() - }), + }).pipe(Effect.provideService(InstanceRef, ctx)), ) } @@ -46,10 +66,10 @@ test("Bedrock: config region takes precedence over AWS_REGION env var", async () }) await WithInstance.provide({ directory: tmp.path, - fn: async () => { - set("AWS_REGION", "us-east-1") - set("AWS_PROFILE", "default") - const providers = await list() + fn: async (ctx) => { + set(ctx, "AWS_REGION", "us-east-1") + set(ctx, "AWS_PROFILE", "default") + const providers = await list(ctx) expect(providers[ProviderID.amazonBedrock]).toBeDefined() expect(providers[ProviderID.amazonBedrock].options?.region).toBe("eu-west-1") }, @@ -69,10 +89,10 @@ test("Bedrock: falls back to AWS_REGION env var when no config region", async () }) await WithInstance.provide({ directory: tmp.path, - fn: async () => { - set("AWS_REGION", "eu-west-1") - set("AWS_PROFILE", "default") - const providers = await list() + fn: async (ctx) => { + set(ctx, "AWS_REGION", "eu-west-1") + set(ctx, "AWS_PROFILE", "default") + const providers = await list(ctx) expect(providers[ProviderID.amazonBedrock]).toBeDefined() expect(providers[ProviderID.amazonBedrock].options?.region).toBe("eu-west-1") }, @@ -122,11 +142,11 @@ test("Bedrock: loads when bearer token from auth.json is present", async () => { await WithInstance.provide({ directory: tmp.path, - fn: async () => { - set("AWS_PROFILE", "") - set("AWS_ACCESS_KEY_ID", "") - set("AWS_BEARER_TOKEN_BEDROCK", "") - const providers = await list() + fn: async (ctx) => { + set(ctx, "AWS_PROFILE", "") + set(ctx, "AWS_ACCESS_KEY_ID", "") + set(ctx, "AWS_BEARER_TOKEN_BEDROCK", "") + const providers = await list(ctx) expect(providers[ProviderID.amazonBedrock]).toBeDefined() expect(providers[ProviderID.amazonBedrock].options?.region).toBe("eu-west-1") }, @@ -166,10 +186,10 @@ test("Bedrock: config profile takes precedence over AWS_PROFILE env var", async }) await WithInstance.provide({ directory: tmp.path, - fn: async () => { - set("AWS_PROFILE", "default") - set("AWS_ACCESS_KEY_ID", "test-key-id") - const providers = await list() + fn: async (ctx) => { + set(ctx, "AWS_PROFILE", "default") + set(ctx, "AWS_ACCESS_KEY_ID", "test-key-id") + const providers = await list(ctx) expect(providers[ProviderID.amazonBedrock]).toBeDefined() expect(providers[ProviderID.amazonBedrock].options?.region).toBe("us-east-1") }, @@ -196,9 +216,9 @@ test("Bedrock: includes custom endpoint in options when specified", async () => }) await WithInstance.provide({ directory: tmp.path, - fn: async () => { - set("AWS_PROFILE", "default") - const providers = await list() + fn: async (ctx) => { + set(ctx, "AWS_PROFILE", "default") + const providers = await list(ctx) expect(providers[ProviderID.amazonBedrock]).toBeDefined() expect(providers[ProviderID.amazonBedrock].options?.endpoint).toBe( "https://bedrock-runtime.us-east-1.vpce-xxxxx.amazonaws.com", @@ -227,12 +247,12 @@ test("Bedrock: autoloads when AWS_WEB_IDENTITY_TOKEN_FILE is present", async () }) await WithInstance.provide({ directory: tmp.path, - fn: async () => { - set("AWS_WEB_IDENTITY_TOKEN_FILE", "/var/run/secrets/eks.amazonaws.com/serviceaccount/token") - set("AWS_ROLE_ARN", "arn:aws:iam::123456789012:role/my-eks-role") - set("AWS_PROFILE", "") - set("AWS_ACCESS_KEY_ID", "") - const providers = await list() + fn: async (ctx) => { + set(ctx, "AWS_WEB_IDENTITY_TOKEN_FILE", "/var/run/secrets/eks.amazonaws.com/serviceaccount/token") + set(ctx, "AWS_ROLE_ARN", "arn:aws:iam::123456789012:role/my-eks-role") + set(ctx, "AWS_PROFILE", "") + set(ctx, "AWS_ACCESS_KEY_ID", "") + const providers = await list(ctx) expect(providers[ProviderID.amazonBedrock]).toBeDefined() expect(providers[ProviderID.amazonBedrock].options?.region).toBe("us-east-1") }, @@ -268,9 +288,9 @@ test("Bedrock: model with us. prefix should not be double-prefixed", async () => }) await WithInstance.provide({ directory: tmp.path, - fn: async () => { - set("AWS_PROFILE", "default") - const providers = await list() + fn: async (ctx) => { + set(ctx, "AWS_PROFILE", "default") + const providers = await list(ctx) expect(providers[ProviderID.amazonBedrock]).toBeDefined() // The model should exist with the us. prefix expect(providers[ProviderID.amazonBedrock].models["us.anthropic.claude-opus-4-5-20251101-v1:0"]).toBeDefined() @@ -303,9 +323,9 @@ test("Bedrock: model with global. prefix should not be prefixed", async () => { }) await WithInstance.provide({ directory: tmp.path, - fn: async () => { - set("AWS_PROFILE", "default") - const providers = await list() + fn: async (ctx) => { + set(ctx, "AWS_PROFILE", "default") + const providers = await list(ctx) expect(providers[ProviderID.amazonBedrock]).toBeDefined() expect(providers[ProviderID.amazonBedrock].models["global.anthropic.claude-opus-4-5-20251101-v1:0"]).toBeDefined() }, @@ -337,9 +357,9 @@ test("Bedrock: model with eu. prefix should not be double-prefixed", async () => }) await WithInstance.provide({ directory: tmp.path, - fn: async () => { - set("AWS_PROFILE", "default") - const providers = await list() + fn: async (ctx) => { + set(ctx, "AWS_PROFILE", "default") + const providers = await list(ctx) expect(providers[ProviderID.amazonBedrock]).toBeDefined() expect(providers[ProviderID.amazonBedrock].models["eu.anthropic.claude-opus-4-5-20251101-v1:0"]).toBeDefined() }, @@ -371,9 +391,9 @@ test("Bedrock: model without prefix in US region should get us. prefix added", a }) await WithInstance.provide({ directory: tmp.path, - fn: async () => { - set("AWS_PROFILE", "default") - const providers = await list() + fn: async (ctx) => { + set(ctx, "AWS_PROFILE", "default") + const providers = await list(ctx) expect(providers[ProviderID.amazonBedrock]).toBeDefined() // Non-prefixed model should still be registered expect(providers[ProviderID.amazonBedrock].models["anthropic.claude-opus-4-5-20251101-v1:0"]).toBeDefined() diff --git a/packages/opencode/test/provider/gitlab-duo.test.ts b/packages/opencode/test/provider/gitlab-duo.test.ts index 8bb3b96347e..4dd762f67cf 100644 --- a/packages/opencode/test/provider/gitlab-duo.test.ts +++ b/packages/opencode/test/provider/gitlab-duo.test.ts @@ -8,7 +8,6 @@ export {} // import { ProviderID, ModelID } from "../../src/provider/schema" // import { tmpdir } from "../fixture/fixture" -// import { Instance } from "../../src/project/instance" import { WithInstance } from "../../src/project/with-instance" // import { Provider } from "@/provider/provider" // import { Env } from "../../src/env" diff --git a/packages/opencode/test/provider/provider.test.ts b/packages/opencode/test/provider/provider.test.ts index 1c6a8b33779..579867b2a2e 100644 --- a/packages/opencode/test/provider/provider.test.ts +++ b/packages/opencode/test/provider/provider.test.ts @@ -1,10 +1,10 @@ -import { test, expect } from "bun:test" +import { afterEach, test, expect } from "bun:test" import { mkdir, unlink } from "fs/promises" import path from "path" import { disposeAllInstances, tmpdir } from "../fixture/fixture" import { Global } from "@opencode-ai/core/global" -import { Instance } from "../../src/project/instance" +import type { InstanceContext } from "../../src/project/instance-context" import { WithInstance } from "../../src/project/with-instance" import { Plugin } from "../../src/plugin/index" import { ModelsDev } from "@opencode-ai/core/models" @@ -14,6 +14,7 @@ import { Filesystem } from "@/util/filesystem" import { Env } from "../../src/env" import { Effect, Layer } from "effect" import { AppRuntime } from "../../src/effect/app-runtime" +import { InstanceRef } from "../../src/effect/instance-ref" import { makeRuntime } from "../../src/effect/run-service" import { testEffect } from "../lib/effect" import { AppFileSystem } from "@opencode-ai/core/filesystem" @@ -22,8 +23,31 @@ import { Auth } from "@/auth" import { RuntimeFlags } from "@/effect/runtime-flags" const env = makeRuntime(Env.Service, Env.defaultLayer) -const set = (k: string, v: string) => env.runSync((svc) => svc.set(k, v)) -const remove = (k: string) => env.runSync((svc) => svc.remove(k)) +const originalEnv = new Map() + +function rememberEnv(k: string) { + if (!originalEnv.has(k)) originalEnv.set(k, process.env[k]) +} + +const set = (ctx: InstanceContext, k: string, v: string) => { + rememberEnv(k) + process.env[k] = v + return env.runSync((svc) => svc.set(k, v).pipe(Effect.provideService(InstanceRef, ctx))) +} +const remove = (ctx: InstanceContext, k: string) => { + rememberEnv(k) + delete process.env[k] + return env.runSync((svc) => svc.remove(k).pipe(Effect.provideService(InstanceRef, ctx))) +} + +afterEach(async () => { + for (const [key, value] of originalEnv) { + if (value === undefined) delete process.env[key] + else process.env[key] = value + } + originalEnv.clear() + await disposeAllInstances() +}) const providerLayer = (flags: Partial = {}) => Provider.layer.pipe( @@ -36,41 +60,41 @@ const providerLayer = (flags: Partial = {}) => Layer.provide(RuntimeFlags.layer(flags)), ) -async function run(fn: (provider: Provider.Interface) => Effect.Effect) { +async function run(ctx: InstanceContext, fn: (provider: Provider.Interface) => Effect.Effect) { return AppRuntime.runPromise( Effect.gen(function* () { const provider = yield* Provider.Service return yield* fn(provider) - }), + }).pipe(Effect.provideService(InstanceRef, ctx)), ) } -async function list() { - return run((provider) => provider.list()) +async function list(ctx: InstanceContext) { + return run(ctx, (provider) => provider.list()) } -async function getProvider(providerID: ProviderID) { - return run((provider) => provider.getProvider(providerID)) +async function getProvider(providerID: ProviderID, ctx: InstanceContext) { + return run(ctx, (provider) => provider.getProvider(providerID)) } -async function getModel(providerID: ProviderID, modelID: ModelID) { - return run((provider) => provider.getModel(providerID, modelID)) +async function getModel(providerID: ProviderID, modelID: ModelID, ctx: InstanceContext) { + return run(ctx, (provider) => provider.getModel(providerID, modelID)) } -async function getLanguage(model: Provider.Model) { - return run((provider) => provider.getLanguage(model)) +async function getLanguage(model: Provider.Model, ctx: InstanceContext) { + return run(ctx, (provider) => provider.getLanguage(model)) } -async function closest(providerID: ProviderID, query: string[]) { - return run((provider) => provider.closest(providerID, query)) +async function closest(providerID: ProviderID, query: string[], ctx: InstanceContext) { + return run(ctx, (provider) => provider.closest(providerID, query)) } -async function getSmallModel(providerID: ProviderID) { - return run((provider) => provider.getSmallModel(providerID)) +async function getSmallModel(providerID: ProviderID, ctx: InstanceContext) { + return run(ctx, (provider) => provider.getSmallModel(providerID)) } -async function defaultModel() { - return run((provider) => provider.defaultModel()) +async function defaultModel(ctx: InstanceContext) { + return run(ctx, (provider) => provider.defaultModel()) } async function markPluginDependenciesReady(dir: string) { @@ -125,9 +149,9 @@ test("provider loaded from env variable", async () => { }) await WithInstance.provide({ directory: tmp.path, - fn: async () => { - set("ANTHROPIC_API_KEY", "test-api-key") - const providers = await list() + fn: async (ctx) => { + set(ctx, "ANTHROPIC_API_KEY", "test-api-key") + const providers = await list(ctx) expect(providers[ProviderID.anthropic]).toBeDefined() // Provider should retain its connection source even if custom loaders // merge additional options. @@ -157,8 +181,8 @@ test("provider loaded from config with apiKey option", async () => { }) await WithInstance.provide({ directory: tmp.path, - fn: async () => { - const providers = await list() + fn: async (ctx) => { + const providers = await list(ctx) expect(providers[ProviderID.anthropic]).toBeDefined() }, }) @@ -178,9 +202,9 @@ test("disabled_providers excludes provider", async () => { }) await WithInstance.provide({ directory: tmp.path, - fn: async () => { - set("ANTHROPIC_API_KEY", "test-api-key") - const providers = await list() + fn: async (ctx) => { + set(ctx, "ANTHROPIC_API_KEY", "test-api-key") + const providers = await list(ctx) expect(providers[ProviderID.anthropic]).toBeUndefined() }, }) @@ -200,10 +224,10 @@ test("enabled_providers restricts to only listed providers", async () => { }) await WithInstance.provide({ directory: tmp.path, - fn: async () => { - set("ANTHROPIC_API_KEY", "test-api-key") - set("OPENAI_API_KEY", "test-openai-key") - const providers = await list() + fn: async (ctx) => { + set(ctx, "ANTHROPIC_API_KEY", "test-api-key") + set(ctx, "OPENAI_API_KEY", "test-openai-key") + const providers = await list(ctx) expect(providers[ProviderID.anthropic]).toBeDefined() expect(providers[ProviderID.openai]).toBeUndefined() }, @@ -228,9 +252,9 @@ test("model whitelist filters models for provider", async () => { }) await WithInstance.provide({ directory: tmp.path, - fn: async () => { - set("ANTHROPIC_API_KEY", "test-api-key") - const providers = await list() + fn: async (ctx) => { + set(ctx, "ANTHROPIC_API_KEY", "test-api-key") + const providers = await list(ctx) expect(providers[ProviderID.anthropic]).toBeDefined() const models = Object.keys(providers[ProviderID.anthropic].models) expect(models).toContain("claude-sonnet-4-20250514") @@ -257,9 +281,9 @@ test("model blacklist excludes specific models", async () => { }) await WithInstance.provide({ directory: tmp.path, - fn: async () => { - set("ANTHROPIC_API_KEY", "test-api-key") - const providers = await list() + fn: async (ctx) => { + set(ctx, "ANTHROPIC_API_KEY", "test-api-key") + const providers = await list(ctx) expect(providers[ProviderID.anthropic]).toBeDefined() const models = Object.keys(providers[ProviderID.anthropic].models) expect(models).not.toContain("claude-sonnet-4-20250514") @@ -290,9 +314,9 @@ test("custom model alias via config", async () => { }) await WithInstance.provide({ directory: tmp.path, - fn: async () => { - set("ANTHROPIC_API_KEY", "test-api-key") - const providers = await list() + fn: async (ctx) => { + set(ctx, "ANTHROPIC_API_KEY", "test-api-key") + const providers = await list(ctx) expect(providers[ProviderID.anthropic]).toBeDefined() expect(providers[ProviderID.anthropic].models["my-alias"]).toBeDefined() expect(providers[ProviderID.anthropic].models["my-alias"].name).toBe("My Custom Alias") @@ -334,8 +358,8 @@ test("custom provider with npm package", async () => { }) await WithInstance.provide({ directory: tmp.path, - fn: async () => { - const providers = await list() + fn: async (ctx) => { + const providers = await list(ctx) expect(providers[ProviderID.make("custom-provider")]).toBeDefined() expect(providers[ProviderID.make("custom-provider")].name).toBe("Custom Provider") expect(providers[ProviderID.make("custom-provider")].models["custom-model"]).toBeDefined() @@ -411,8 +435,8 @@ test("custom DeepSeek openai-compatible model defaults interleaved reasoning fie }) await WithInstance.provide({ directory: tmp.path, - fn: async () => { - const providers = await list() + fn: async (ctx) => { + const providers = await list(ctx) const provider = providers[ProviderID.make("custom-provider")] expect(provider.models["deepseek-r1"].capabilities.interleaved).toEqual({ field: "reasoning_content" }) expect(provider.models["deepseek-details"].capabilities.interleaved).toEqual({ field: "reasoning_details" }) @@ -445,9 +469,9 @@ test("env variable takes precedence, config merges options", async () => { }) await WithInstance.provide({ directory: tmp.path, - fn: async () => { - set("ANTHROPIC_API_KEY", "env-api-key") - const providers = await list() + fn: async (ctx) => { + set(ctx, "ANTHROPIC_API_KEY", "env-api-key") + const providers = await list(ctx) expect(providers[ProviderID.anthropic]).toBeDefined() // Config options should be merged expect(providers[ProviderID.anthropic].options.timeout).toBe(60000) @@ -469,13 +493,13 @@ test("getModel returns model for valid provider/model", async () => { }) await WithInstance.provide({ directory: tmp.path, - fn: async () => { - set("ANTHROPIC_API_KEY", "test-api-key") - const model = await getModel(ProviderID.anthropic, ModelID.make("claude-sonnet-4-20250514")) + fn: async (ctx) => { + set(ctx, "ANTHROPIC_API_KEY", "test-api-key") + const model = await getModel(ProviderID.anthropic, ModelID.make("claude-sonnet-4-20250514"), ctx) expect(model).toBeDefined() expect(String(model.providerID)).toBe("anthropic") expect(String(model.id)).toBe("claude-sonnet-4-20250514") - const language = await getLanguage(model) + const language = await getLanguage(model, ctx) expect(language).toBeDefined() }, }) @@ -494,9 +518,9 @@ test("getModel throws ModelNotFoundError for invalid model", async () => { }) await WithInstance.provide({ directory: tmp.path, - fn: async () => { - set("ANTHROPIC_API_KEY", "test-api-key") - expect(getModel(ProviderID.anthropic, ModelID.make("nonexistent-model"))).rejects.toThrow() + fn: async (ctx) => { + set(ctx, "ANTHROPIC_API_KEY", "test-api-key") + expect(getModel(ProviderID.anthropic, ModelID.make("nonexistent-model"), ctx)).rejects.toThrow() }, }) }) @@ -514,8 +538,8 @@ test("getModel throws ModelNotFoundError for invalid provider", async () => { }) await WithInstance.provide({ directory: tmp.path, - fn: async () => { - expect(getModel(ProviderID.make("nonexistent-provider"), ModelID.make("some-model"))).rejects.toThrow() + fn: async (ctx) => { + expect(getModel(ProviderID.make("nonexistent-provider"), ModelID.make("some-model"), ctx)).rejects.toThrow() }, }) }) @@ -545,9 +569,9 @@ test("defaultModel returns first available model when no config set", async () = }) await WithInstance.provide({ directory: tmp.path, - fn: async () => { - set("ANTHROPIC_API_KEY", "test-api-key") - const model = await defaultModel() + fn: async (ctx) => { + set(ctx, "ANTHROPIC_API_KEY", "test-api-key") + const model = await defaultModel(ctx) expect(model.providerID).toBeDefined() expect(model.modelID).toBeDefined() }, @@ -568,9 +592,9 @@ test("defaultModel respects config model setting", async () => { }) await WithInstance.provide({ directory: tmp.path, - fn: async () => { - set("ANTHROPIC_API_KEY", "test-api-key") - const model = await defaultModel() + fn: async (ctx) => { + set(ctx, "ANTHROPIC_API_KEY", "test-api-key") + const model = await defaultModel(ctx) expect(String(model.providerID)).toBe("anthropic") expect(String(model.modelID)).toBe("claude-sonnet-4-20250514") }, @@ -701,9 +725,9 @@ test("closest finds model by partial match", async () => { }) await WithInstance.provide({ directory: tmp.path, - fn: async () => { - set("ANTHROPIC_API_KEY", "test-api-key") - const result = await closest(ProviderID.anthropic, ["sonnet-4"]) + fn: async (ctx) => { + set(ctx, "ANTHROPIC_API_KEY", "test-api-key") + const result = await closest(ProviderID.anthropic, ["sonnet-4"], ctx) expect(result).toBeDefined() expect(String(result?.providerID)).toBe("anthropic") expect(String(result?.modelID)).toContain("sonnet-4") @@ -724,8 +748,8 @@ test("closest returns undefined for nonexistent provider", async () => { }) await WithInstance.provide({ directory: tmp.path, - fn: async () => { - const result = await closest(ProviderID.make("nonexistent"), ["model"]) + fn: async (ctx) => { + const result = await closest(ProviderID.make("nonexistent"), ["model"], ctx) expect(result).toBeUndefined() }, }) @@ -754,12 +778,12 @@ test("getModel uses realIdByKey for aliased models", async () => { }) await WithInstance.provide({ directory: tmp.path, - fn: async () => { - set("ANTHROPIC_API_KEY", "test-api-key") - const providers = await list() + fn: async (ctx) => { + set(ctx, "ANTHROPIC_API_KEY", "test-api-key") + const providers = await list(ctx) expect(providers[ProviderID.anthropic].models["my-sonnet"]).toBeDefined() - const model = await getModel(ProviderID.anthropic, ModelID.make("my-sonnet")) + const model = await getModel(ProviderID.anthropic, ModelID.make("my-sonnet"), ctx) expect(model).toBeDefined() expect(String(model.id)).toBe("my-sonnet") expect(model.name).toBe("My Sonnet Alias") @@ -798,8 +822,8 @@ test("provider api field sets model api.url", async () => { }) await WithInstance.provide({ directory: tmp.path, - fn: async () => { - const providers = await list() + fn: async (ctx) => { + const providers = await list(ctx) // api field is stored on model.api.url, used by getSDK to set baseURL expect(providers[ProviderID.make("custom-api")].models["model-1"].api.url).toBe("https://api.example.com/v1") }, @@ -838,8 +862,8 @@ test("explicit baseURL overrides api field", async () => { }) await WithInstance.provide({ directory: tmp.path, - fn: async () => { - const providers = await list() + fn: async (ctx) => { + const providers = await list(ctx) expect(providers[ProviderID.make("custom-api")].options.baseURL).toBe("https://custom.override.com/v1") }, }) @@ -867,9 +891,9 @@ test("model inherits properties from existing database model", async () => { }) await WithInstance.provide({ directory: tmp.path, - fn: async () => { - set("ANTHROPIC_API_KEY", "test-api-key") - const providers = await list() + fn: async (ctx) => { + set(ctx, "ANTHROPIC_API_KEY", "test-api-key") + const providers = await list(ctx) const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"] expect(model.name).toBe("Custom Name for Sonnet") expect(model.capabilities.toolcall).toBe(true) @@ -893,9 +917,9 @@ test("disabled_providers prevents loading even with env var", async () => { }) await WithInstance.provide({ directory: tmp.path, - fn: async () => { - set("OPENAI_API_KEY", "test-openai-key") - const providers = await list() + fn: async (ctx) => { + set(ctx, "OPENAI_API_KEY", "test-openai-key") + const providers = await list(ctx) expect(providers[ProviderID.openai]).toBeUndefined() }, }) @@ -915,10 +939,10 @@ test("enabled_providers with empty array allows no providers", async () => { }) await WithInstance.provide({ directory: tmp.path, - fn: async () => { - set("ANTHROPIC_API_KEY", "test-api-key") - set("OPENAI_API_KEY", "test-openai-key") - const providers = await list() + fn: async (ctx) => { + set(ctx, "ANTHROPIC_API_KEY", "test-api-key") + set(ctx, "OPENAI_API_KEY", "test-openai-key") + const providers = await list(ctx) expect(Object.keys(providers).length).toBe(0) }, }) @@ -943,9 +967,9 @@ test("whitelist and blacklist can be combined", async () => { }) await WithInstance.provide({ directory: tmp.path, - fn: async () => { - set("ANTHROPIC_API_KEY", "test-api-key") - const providers = await list() + fn: async (ctx) => { + set(ctx, "ANTHROPIC_API_KEY", "test-api-key") + const providers = await list(ctx) expect(providers[ProviderID.anthropic]).toBeDefined() const models = Object.keys(providers[ProviderID.anthropic].models) expect(models).toContain("claude-sonnet-4-20250514") @@ -983,8 +1007,8 @@ test("model modalities default correctly", async () => { }) await WithInstance.provide({ directory: tmp.path, - fn: async () => { - const providers = await list() + fn: async (ctx) => { + const providers = await list(ctx) const model = providers[ProviderID.make("test-provider")].models["test-model"] expect(model.capabilities.input.text).toBe(true) expect(model.capabilities.output.text).toBe(true) @@ -1026,8 +1050,8 @@ test("model with custom cost values", async () => { }) await WithInstance.provide({ directory: tmp.path, - fn: async () => { - const providers = await list() + fn: async (ctx) => { + const providers = await list(ctx) const model = providers[ProviderID.make("test-provider")].models["test-model"] expect(model.cost.input).toBe(5) expect(model.cost.output).toBe(15) @@ -1050,9 +1074,9 @@ test("getSmallModel returns appropriate small model", async () => { }) await WithInstance.provide({ directory: tmp.path, - fn: async () => { - set("ANTHROPIC_API_KEY", "test-api-key") - const model = await getSmallModel(ProviderID.anthropic) + fn: async (ctx) => { + set(ctx, "ANTHROPIC_API_KEY", "test-api-key") + const model = await getSmallModel(ProviderID.anthropic, ctx) expect(model).toBeDefined() expect(model?.id).toContain("haiku") }, @@ -1073,9 +1097,9 @@ test("getSmallModel respects config small_model override", async () => { }) await WithInstance.provide({ directory: tmp.path, - fn: async () => { - set("ANTHROPIC_API_KEY", "test-api-key") - const model = await getSmallModel(ProviderID.anthropic) + fn: async (ctx) => { + set(ctx, "ANTHROPIC_API_KEY", "test-api-key") + const model = await getSmallModel(ProviderID.anthropic, ctx) expect(model).toBeDefined() expect(String(model?.providerID)).toBe("anthropic") expect(String(model?.id)).toBe("claude-sonnet-4-20250514") @@ -1097,9 +1121,9 @@ test("getSmallModel ignores invalid config small_model", async () => { }) await WithInstance.provide({ directory: tmp.path, - fn: async () => { - set("ANTHROPIC_API_KEY", "test-api-key") - expect(await getSmallModel(ProviderID.anthropic)).toBeUndefined() + fn: async (ctx) => { + set(ctx, "ANTHROPIC_API_KEY", "test-api-key") + expect(await getSmallModel(ProviderID.anthropic, ctx)).toBeUndefined() }, }) }) @@ -1140,10 +1164,10 @@ test("multiple providers can be configured simultaneously", async () => { }) await WithInstance.provide({ directory: tmp.path, - fn: async () => { - set("ANTHROPIC_API_KEY", "test-anthropic-key") - set("OPENAI_API_KEY", "test-openai-key") - const providers = await list() + fn: async (ctx) => { + set(ctx, "ANTHROPIC_API_KEY", "test-anthropic-key") + set(ctx, "OPENAI_API_KEY", "test-openai-key") + const providers = await list(ctx) expect(providers[ProviderID.anthropic]).toBeDefined() expect(providers[ProviderID.openai]).toBeDefined() expect(providers[ProviderID.anthropic].options.timeout).toBe(30000) @@ -1183,8 +1207,8 @@ test("provider with custom npm package", async () => { }) await WithInstance.provide({ directory: tmp.path, - fn: async () => { - const providers = await list() + fn: async (ctx) => { + const providers = await list(ctx) expect(providers[ProviderID.make("local-llm")]).toBeDefined() expect(providers[ProviderID.make("local-llm")].models["llama-3"].api.npm).toBe("@ai-sdk/openai-compatible") expect(providers[ProviderID.make("local-llm")].options.baseURL).toBe("http://localhost:11434/v1") @@ -1217,9 +1241,9 @@ test("model alias name defaults to alias key when id differs", async () => { }) await WithInstance.provide({ directory: tmp.path, - fn: async () => { - set("ANTHROPIC_API_KEY", "test-api-key") - const providers = await list() + fn: async (ctx) => { + set(ctx, "ANTHROPIC_API_KEY", "test-api-key") + const providers = await list(ctx) expect(providers[ProviderID.anthropic].models["sonnet"].name).toBe("sonnet") }, }) @@ -1255,9 +1279,9 @@ test("provider with multiple env var options only includes apiKey when single en }) await WithInstance.provide({ directory: tmp.path, - fn: async () => { - set("MULTI_ENV_KEY_1", "test-key") - const providers = await list() + fn: async (ctx) => { + set(ctx, "MULTI_ENV_KEY_1", "test-key") + const providers = await list(ctx) expect(providers[ProviderID.make("multi-env")]).toBeDefined() // When multiple env options exist, key should NOT be auto-set expect(providers[ProviderID.make("multi-env")].key).toBeUndefined() @@ -1295,9 +1319,9 @@ test("provider with single env var includes apiKey automatically", async () => { }) await WithInstance.provide({ directory: tmp.path, - fn: async () => { - set("SINGLE_ENV_KEY", "my-api-key") - const providers = await list() + fn: async (ctx) => { + set(ctx, "SINGLE_ENV_KEY", "my-api-key") + const providers = await list(ctx) expect(providers[ProviderID.make("single-env")]).toBeDefined() // Single env option should auto-set key expect(providers[ProviderID.make("single-env")].key).toBe("my-api-key") @@ -1330,9 +1354,9 @@ test("model cost overrides existing cost values", async () => { }) await WithInstance.provide({ directory: tmp.path, - fn: async () => { - set("ANTHROPIC_API_KEY", "test-api-key") - const providers = await list() + fn: async (ctx) => { + set(ctx, "ANTHROPIC_API_KEY", "test-api-key") + const providers = await list(ctx) const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"] expect(model.cost.input).toBe(999) expect(model.cost.output).toBe(888) @@ -1378,8 +1402,8 @@ test("completely new provider not in database can be configured", async () => { }) await WithInstance.provide({ directory: tmp.path, - fn: async () => { - const providers = await list() + fn: async (ctx) => { + const providers = await list(ctx) expect(providers[ProviderID.make("brand-new-provider")]).toBeDefined() expect(providers[ProviderID.make("brand-new-provider")].name).toBe("Brand New") const model = providers[ProviderID.make("brand-new-provider")].models["new-model"] @@ -1407,11 +1431,11 @@ test("disabled_providers and enabled_providers interaction", async () => { }) await WithInstance.provide({ directory: tmp.path, - fn: async () => { - set("ANTHROPIC_API_KEY", "test-anthropic") - set("OPENAI_API_KEY", "test-openai") - set("GOOGLE_GENERATIVE_AI_API_KEY", "test-google") - const providers = await list() + fn: async (ctx) => { + set(ctx, "ANTHROPIC_API_KEY", "test-anthropic") + set(ctx, "OPENAI_API_KEY", "test-openai") + set(ctx, "GOOGLE_GENERATIVE_AI_API_KEY", "test-google") + const providers = await list(ctx) // anthropic: in enabled, not in disabled = allowed expect(providers[ProviderID.anthropic]).toBeDefined() // openai: in enabled, but also in disabled = NOT allowed @@ -1450,8 +1474,8 @@ test("model with tool_call false", async () => { }) await WithInstance.provide({ directory: tmp.path, - fn: async () => { - const providers = await list() + fn: async (ctx) => { + const providers = await list(ctx) expect(providers[ProviderID.make("no-tools")].models["basic-model"].capabilities.toolcall).toBe(false) }, }) @@ -1485,8 +1509,8 @@ test("model defaults tool_call to true when not specified", async () => { }) await WithInstance.provide({ directory: tmp.path, - fn: async () => { - const providers = await list() + fn: async (ctx) => { + const providers = await list(ctx) expect(providers[ProviderID.make("default-tools")].models["model"].capabilities.toolcall).toBe(true) }, }) @@ -1524,8 +1548,8 @@ test("model headers are preserved", async () => { }) await WithInstance.provide({ directory: tmp.path, - fn: async () => { - const providers = await list() + fn: async (ctx) => { + const providers = await list(ctx) const model = providers[ProviderID.make("headers-provider")].models["model"] expect(model.headers).toEqual({ "X-Custom-Header": "custom-value", @@ -1563,10 +1587,10 @@ test("provider env fallback - second env var used if first missing", async () => }) await WithInstance.provide({ directory: tmp.path, - fn: async () => { + fn: async (ctx) => { // Only set fallback, not primary - set("FALLBACK_KEY", "fallback-api-key") - const providers = await list() + set(ctx, "FALLBACK_KEY", "fallback-api-key") + const providers = await list(ctx) // Provider should load because fallback env var is set expect(providers[ProviderID.make("fallback-env")]).toBeDefined() }, @@ -1586,10 +1610,10 @@ test("getModel returns consistent results", async () => { }) await WithInstance.provide({ directory: tmp.path, - fn: async () => { - set("ANTHROPIC_API_KEY", "test-api-key") - const model1 = await getModel(ProviderID.anthropic, ModelID.make("claude-sonnet-4-20250514")) - const model2 = await getModel(ProviderID.anthropic, ModelID.make("claude-sonnet-4-20250514")) + fn: async (ctx) => { + set(ctx, "ANTHROPIC_API_KEY", "test-api-key") + const model1 = await getModel(ProviderID.anthropic, ModelID.make("claude-sonnet-4-20250514"), ctx) + const model2 = await getModel(ProviderID.anthropic, ModelID.make("claude-sonnet-4-20250514"), ctx) expect(model1.providerID).toEqual(model2.providerID) expect(model1.id).toEqual(model2.id) expect(model1).toEqual(model2) @@ -1625,8 +1649,8 @@ test("provider name defaults to id when not in database", async () => { }) await WithInstance.provide({ directory: tmp.path, - fn: async () => { - const providers = await list() + fn: async (ctx) => { + const providers = await list(ctx) expect(providers[ProviderID.make("my-custom-id")].name).toBe("my-custom-id") }, }) @@ -1645,10 +1669,10 @@ test("ModelNotFoundError includes suggestions for typos", async () => { }) await WithInstance.provide({ directory: tmp.path, - fn: async () => { - set("ANTHROPIC_API_KEY", "test-api-key") + fn: async (ctx) => { + set(ctx, "ANTHROPIC_API_KEY", "test-api-key") try { - await getModel(ProviderID.anthropic, ModelID.make("claude-sonet-4")) // typo: sonet instead of sonnet + await getModel(ProviderID.anthropic, ModelID.make("claude-sonet-4"), ctx) // typo: sonet instead of sonnet expect(true).toBe(false) // Should not reach here } catch (e: any) { expect(e.suggestions).toBeDefined() @@ -1671,10 +1695,10 @@ test("ModelNotFoundError for provider includes suggestions", async () => { }) await WithInstance.provide({ directory: tmp.path, - fn: async () => { - set("ANTHROPIC_API_KEY", "test-api-key") + fn: async (ctx) => { + set(ctx, "ANTHROPIC_API_KEY", "test-api-key") try { - await getModel(ProviderID.make("antropic"), ModelID.make("claude-sonnet-4")) // typo: antropic + await getModel(ProviderID.make("antropic"), ModelID.make("claude-sonnet-4"), ctx) // typo: antropic expect(true).toBe(false) // Should not reach here } catch (e: any) { expect(e.suggestions).toBeDefined() @@ -1697,10 +1721,10 @@ test("ModelNotFoundError suggests catalog models for unloaded providers", async }) await WithInstance.provide({ directory: tmp.path, - fn: async () => { - remove("OPENCODE_API_KEY") + fn: async (ctx) => { + remove(ctx, "OPENCODE_API_KEY") try { - await getModel(ProviderID.opencode, ModelID.make("claude-haiku-fake-model")) + await getModel(ProviderID.opencode, ModelID.make("claude-haiku-fake-model"), ctx) throw new Error("expected model lookup to fail") } catch (e) { if (!Provider.ModelNotFoundError.isInstance(e)) throw e @@ -1723,8 +1747,8 @@ test("getProvider returns undefined for nonexistent provider", async () => { }) await WithInstance.provide({ directory: tmp.path, - fn: async () => { - const provider = await getProvider(ProviderID.make("nonexistent")) + fn: async (ctx) => { + const provider = await getProvider(ProviderID.make("nonexistent"), ctx) expect(provider).toBeUndefined() }, }) @@ -1743,9 +1767,9 @@ test("getProvider returns provider info", async () => { }) await WithInstance.provide({ directory: tmp.path, - fn: async () => { - set("ANTHROPIC_API_KEY", "test-api-key") - const provider = await getProvider(ProviderID.anthropic) + fn: async (ctx) => { + set(ctx, "ANTHROPIC_API_KEY", "test-api-key") + const provider = await getProvider(ProviderID.anthropic, ctx) expect(provider).toBeDefined() expect(String(provider?.id)).toBe("anthropic") }, @@ -1765,9 +1789,9 @@ test("closest returns undefined when no partial match found", async () => { }) await WithInstance.provide({ directory: tmp.path, - fn: async () => { - set("ANTHROPIC_API_KEY", "test-api-key") - const result = await closest(ProviderID.anthropic, ["nonexistent-xyz-model"]) + fn: async (ctx) => { + set(ctx, "ANTHROPIC_API_KEY", "test-api-key") + const result = await closest(ProviderID.anthropic, ["nonexistent-xyz-model"], ctx) expect(result).toBeUndefined() }, }) @@ -1786,10 +1810,10 @@ test("closest checks multiple query terms in order", async () => { }) await WithInstance.provide({ directory: tmp.path, - fn: async () => { - set("ANTHROPIC_API_KEY", "test-api-key") + fn: async (ctx) => { + set(ctx, "ANTHROPIC_API_KEY", "test-api-key") // First term won't match, second will - const result = await closest(ProviderID.anthropic, ["nonexistent", "haiku"]) + const result = await closest(ProviderID.anthropic, ["nonexistent", "haiku"], ctx) expect(result).toBeDefined() expect(result?.modelID).toContain("haiku") }, @@ -1824,8 +1848,8 @@ test("model limit defaults to zero when not specified", async () => { }) await WithInstance.provide({ directory: tmp.path, - fn: async () => { - const providers = await list() + fn: async (ctx) => { + const providers = await list(ctx) const model = providers[ProviderID.make("no-limit")].models["model"] expect(model.limit.context).toBe(0) expect(model.limit.output).toBe(0) @@ -1856,9 +1880,9 @@ test("provider options are deeply merged", async () => { }) await WithInstance.provide({ directory: tmp.path, - fn: async () => { - set("ANTHROPIC_API_KEY", "test-api-key") - const providers = await list() + fn: async (ctx) => { + set(ctx, "ANTHROPIC_API_KEY", "test-api-key") + const providers = await list(ctx) // Custom options should be merged expect(providers[ProviderID.anthropic].options.timeout).toBe(30000) expect(providers[ProviderID.anthropic].options.headers["X-Custom"]).toBe("custom-value") @@ -1888,8 +1912,8 @@ test("hosted nvidia provider adds billing origin header", async () => { }) await WithInstance.provide({ directory: tmp.path, - fn: async () => { - const providers = await list() + fn: async (ctx) => { + const providers = await list(ctx) expect(providers[ProviderID.make("nvidia")].options.headers).toEqual({ "HTTP-Referer": "https://opencode.ai/", "X-Title": "opencode", @@ -1920,8 +1944,8 @@ test("custom nvidia baseURL adds billing origin header", async () => { }) await WithInstance.provide({ directory: tmp.path, - fn: async () => { - const providers = await list() + fn: async (ctx) => { + const providers = await list(ctx) expect(providers[ProviderID.make("nvidia")].options.headers).toEqual({ "HTTP-Referer": "https://opencode.ai/", "X-Title": "opencode", @@ -1955,8 +1979,8 @@ test("explicit nvidia billing origin header is preserved", async () => { }) await WithInstance.provide({ directory: tmp.path, - fn: async () => { - const providers = await list() + fn: async (ctx) => { + const providers = await list(ctx) expect(providers[ProviderID.make("nvidia")].options.headers["X-BILLING-INVOKE-ORIGIN"]).toBe("CustomOrigin") }, }) @@ -1986,9 +2010,9 @@ test("custom model inherits npm package from models.dev provider config", async }) await WithInstance.provide({ directory: tmp.path, - fn: async () => { - set("OPENAI_API_KEY", "test-api-key") - const providers = await list() + fn: async (ctx) => { + set(ctx, "OPENAI_API_KEY", "test-api-key") + const providers = await list(ctx) const model = providers[ProviderID.openai].models["my-custom-model"] expect(model).toBeDefined() expect(model.api.npm).toBe("@ai-sdk/openai") @@ -2019,9 +2043,9 @@ test("custom model inherits api.url from models.dev provider", async () => { }) await WithInstance.provide({ directory: tmp.path, - fn: async () => { - set("OPENROUTER_API_KEY", "test-api-key") - const providers = await list() + fn: async (ctx) => { + set(ctx, "OPENROUTER_API_KEY", "test-api-key") + const providers = await list(ctx) expect(providers[ProviderID.openrouter]).toBeDefined() // New model not in database should inherit api.url from provider @@ -2150,9 +2174,9 @@ test("model variants are generated for reasoning models", async () => { }) await WithInstance.provide({ directory: tmp.path, - fn: async () => { - set("ANTHROPIC_API_KEY", "test-api-key") - const providers = await list() + fn: async (ctx) => { + set(ctx, "ANTHROPIC_API_KEY", "test-api-key") + const providers = await list(ctx) // Claude sonnet 4 has reasoning capability const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"] expect(model.capabilities.reasoning).toBe(true) @@ -2186,9 +2210,9 @@ test("model variants can be disabled via config", async () => { }) await WithInstance.provide({ directory: tmp.path, - fn: async () => { - set("ANTHROPIC_API_KEY", "test-api-key") - const providers = await list() + fn: async (ctx) => { + set(ctx, "ANTHROPIC_API_KEY", "test-api-key") + const providers = await list(ctx) const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"] expect(model.variants).toBeDefined() expect(model.variants!["high"]).toBeUndefined() @@ -2227,9 +2251,9 @@ test("model variants can be customized via config", async () => { }) await WithInstance.provide({ directory: tmp.path, - fn: async () => { - set("ANTHROPIC_API_KEY", "test-api-key") - const providers = await list() + fn: async (ctx) => { + set(ctx, "ANTHROPIC_API_KEY", "test-api-key") + const providers = await list(ctx) const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"] expect(model.variants!["high"]).toBeDefined() expect(model.variants!["high"].thinking.budgetTokens).toBe(20000) @@ -2264,9 +2288,9 @@ test("disabled key is stripped from variant config", async () => { }) await WithInstance.provide({ directory: tmp.path, - fn: async () => { - set("ANTHROPIC_API_KEY", "test-api-key") - const providers = await list() + fn: async (ctx) => { + set(ctx, "ANTHROPIC_API_KEY", "test-api-key") + const providers = await list(ctx) const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"] expect(model.variants!["max"]).toBeDefined() expect(model.variants!["max"].disabled).toBeUndefined() @@ -2300,9 +2324,9 @@ test("all variants can be disabled via config", async () => { }) await WithInstance.provide({ directory: tmp.path, - fn: async () => { - set("ANTHROPIC_API_KEY", "test-api-key") - const providers = await list() + fn: async (ctx) => { + set(ctx, "ANTHROPIC_API_KEY", "test-api-key") + const providers = await list(ctx) const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"] expect(model.variants).toBeDefined() expect(Object.keys(model.variants!).length).toBe(0) @@ -2336,9 +2360,9 @@ test("variant config merges with generated variants", async () => { }) await WithInstance.provide({ directory: tmp.path, - fn: async () => { - set("ANTHROPIC_API_KEY", "test-api-key") - const providers = await list() + fn: async (ctx) => { + set(ctx, "ANTHROPIC_API_KEY", "test-api-key") + const providers = await list(ctx) const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"] expect(model.variants!["high"]).toBeDefined() // Should have both the generated thinking config and the custom option @@ -2372,9 +2396,9 @@ test("variants filtered in second pass for database models", async () => { }) await WithInstance.provide({ directory: tmp.path, - fn: async () => { - set("OPENAI_API_KEY", "test-api-key") - const providers = await list() + fn: async (ctx) => { + set(ctx, "OPENAI_API_KEY", "test-api-key") + const providers = await list(ctx) const model = providers[ProviderID.openai].models["gpt-5"] expect(model.variants).toBeDefined() expect(model.variants!["high"]).toBeUndefined() @@ -2419,8 +2443,8 @@ test("custom model with variants enabled and disabled", async () => { }) await WithInstance.provide({ directory: tmp.path, - fn: async () => { - const providers = await list() + fn: async (ctx) => { + const providers = await list(ctx) const model = providers[ProviderID.make("custom-reasoning")].models["reasoning-model"] expect(model.variants).toBeDefined() // Enabled variants should exist @@ -2474,9 +2498,9 @@ test("Google Vertex: retains baseURL for custom proxy", async () => { await WithInstance.provide({ directory: tmp.path, - fn: async () => { - set("GOOGLE_APPLICATION_CREDENTIALS", "test-creds") - const providers = await list() + fn: async (ctx) => { + set(ctx, "GOOGLE_APPLICATION_CREDENTIALS", "test-creds") + const providers = await list(ctx) expect(providers[ProviderID.make("vertex-proxy")]).toBeDefined() expect(providers[ProviderID.make("vertex-proxy")].options.baseURL).toBe("https://my-proxy.com/v1") }, @@ -2517,9 +2541,9 @@ test("Google Vertex: supports OpenAI compatible models", async () => { await WithInstance.provide({ directory: tmp.path, - fn: async () => { - set("GOOGLE_APPLICATION_CREDENTIALS", "test-creds") - const providers = await list() + fn: async (ctx) => { + set(ctx, "GOOGLE_APPLICATION_CREDENTIALS", "test-creds") + const providers = await list(ctx) const model = providers[ProviderID.make("vertex-openai")].models["gpt-4"] expect(model).toBeDefined() @@ -2541,11 +2565,11 @@ test("cloudflare-ai-gateway loads with env variables", async () => { }) await WithInstance.provide({ directory: tmp.path, - fn: async () => { - set("CLOUDFLARE_ACCOUNT_ID", "test-account") - set("CLOUDFLARE_GATEWAY_ID", "test-gateway") - set("CLOUDFLARE_API_TOKEN", "test-token") - const providers = await list() + fn: async (ctx) => { + set(ctx, "CLOUDFLARE_ACCOUNT_ID", "test-account") + set(ctx, "CLOUDFLARE_GATEWAY_ID", "test-gateway") + set(ctx, "CLOUDFLARE_API_TOKEN", "test-token") + const providers = await list(ctx) expect(providers[ProviderID.make("cloudflare-ai-gateway")]).toBeDefined() }, }) @@ -2571,11 +2595,11 @@ test("cloudflare-ai-gateway forwards config metadata options", async () => { }) await WithInstance.provide({ directory: tmp.path, - fn: async () => { - set("CLOUDFLARE_ACCOUNT_ID", "test-account") - set("CLOUDFLARE_GATEWAY_ID", "test-gateway") - set("CLOUDFLARE_API_TOKEN", "test-token") - const providers = await list() + fn: async (ctx) => { + set(ctx, "CLOUDFLARE_ACCOUNT_ID", "test-account") + set(ctx, "CLOUDFLARE_GATEWAY_ID", "test-gateway") + set(ctx, "CLOUDFLARE_API_TOKEN", "test-token") + const providers = await list(ctx) expect(providers[ProviderID.make("cloudflare-ai-gateway")]).toBeDefined() expect(providers[ProviderID.make("cloudflare-ai-gateway")].options.metadata).toEqual({ invoked_by: "test", @@ -2624,14 +2648,14 @@ test("plugin config providers persist after instance dispose", async () => { const first = await WithInstance.provide({ directory: tmp.path, - fn: async () => + fn: async (ctx) => AppRuntime.runPromise( Effect.gen(function* () { const plugin = yield* Plugin.Service const provider = yield* Provider.Service yield* plugin.init() return yield* provider.list() - }), + }).pipe(Effect.provideService(InstanceRef, ctx)), ), }) expect(first[ProviderID.make("demo")]).toBeDefined() @@ -2641,7 +2665,7 @@ test("plugin config providers persist after instance dispose", async () => { const second = await WithInstance.provide({ directory: tmp.path, - fn: async () => list(), + fn: async (ctx) => list(ctx), }) expect(second[ProviderID.make("demo")]).toBeDefined() expect(second[ProviderID.make("demo")].models[ModelID.make("chat")]).toBeDefined() @@ -2672,10 +2696,10 @@ test("plugin config enabled and disabled providers are honored", async () => { await WithInstance.provide({ directory: tmp.path, - fn: async () => { - set("ANTHROPIC_API_KEY", "test-anthropic-key") - set("OPENAI_API_KEY", "test-openai-key") - const providers = await list() + fn: async (ctx) => { + set(ctx, "ANTHROPIC_API_KEY", "test-anthropic-key") + set(ctx, "OPENAI_API_KEY", "test-openai-key") + const providers = await list(ctx) expect(providers[ProviderID.anthropic]).toBeDefined() expect(providers[ProviderID.openai]).toBeUndefined() }, @@ -2696,7 +2720,7 @@ test("opencode loader keeps paid models when config apiKey is present", async () const none = await WithInstance.provide({ directory: base.path, - fn: async () => paid(await list()), + fn: async (ctx) => paid(await list(ctx)), }) await using keyed = await tmpdir({ @@ -2719,7 +2743,7 @@ test("opencode loader keeps paid models when config apiKey is present", async () const keyedCount = await WithInstance.provide({ directory: keyed.path, - fn: async () => paid(await list()), + fn: async (ctx) => paid(await list(ctx)), }) expect(none).toBe(0) @@ -2740,7 +2764,7 @@ test("opencode loader keeps paid models when auth exists", async () => { const none = await WithInstance.provide({ directory: base.path, - fn: async () => paid(await list()), + fn: async (ctx) => paid(await list(ctx)), }) await using keyed = await tmpdir({ @@ -2774,7 +2798,7 @@ test("opencode loader keeps paid models when auth exists", async () => { const keyedCount = await WithInstance.provide({ directory: keyed.path, - fn: async () => paid(await list()), + fn: async (ctx) => paid(await list(ctx)), }) expect(none).toBe(0) diff --git a/packages/opencode/test/question/question.test.ts b/packages/opencode/test/question/question.test.ts index ee3f6dc28a4..a5841bd08db 100644 --- a/packages/opencode/test/question/question.test.ts +++ b/packages/opencode/test/question/question.test.ts @@ -1,7 +1,7 @@ import { afterEach, expect } from "bun:test" import { Cause, Effect, Exit, Fiber, Layer, Queue } from "effect" import { Question } from "../../src/question" -import { Instance } from "../../src/project/instance" +import { InstanceRef } from "../../src/effect/instance-ref" import { InstanceRuntime } from "../../src/project/instance-runtime" import { QuestionID } from "../../src/question/schema" import { disposeAllInstances, provideInstance, reloadTestInstance, tmpdirScoped } from "../fixture/fixture" @@ -404,7 +404,10 @@ it.live("pending question rejects on instance dispose", () => }).pipe(provideInstance(dir), Effect.forkScoped) expect(yield* waitForPending(1).pipe(provideInstance(dir))).toHaveLength(1) - const ctx = yield* Effect.sync(() => Instance.current).pipe(provideInstance(dir)) + const ctx = yield* Effect.gen(function* () { + return yield* InstanceRef + }).pipe(provideInstance(dir)) + if (!ctx) return yield* Effect.die(new Error("missing test instance")) yield* Effect.promise(() => InstanceRuntime.disposeInstance(ctx)) const exit = yield* Fiber.await(fiber) diff --git a/packages/opencode/test/server/httpapi-event.test.ts b/packages/opencode/test/server/httpapi-event.test.ts index 3f1d1e11407..fcf7b59ff39 100644 --- a/packages/opencode/test/server/httpapi-event.test.ts +++ b/packages/opencode/test/server/httpapi-event.test.ts @@ -1,11 +1,12 @@ import { afterEach, describe, expect, test } from "bun:test" import { Bus } from "../../src/bus" -import { Instance } from "../../src/project/instance" +import { AppRuntime } from "../../src/effect/app-runtime" +import { InstanceRef } from "../../src/effect/instance-ref" import { Server } from "../../src/server/server" import { EventPaths } from "../../src/server/routes/instance/httpapi/groups/event" import { Event as ServerEvent } from "../../src/server/event" import * as Log from "@opencode-ai/core/util/log" -import { Schema } from "effect" +import { Effect, Schema } from "effect" import { resetDatabase } from "../fixture/db" import { disposeAllInstances, reloadTestInstance, tmpdir } from "../fixture/fixture" @@ -108,7 +109,9 @@ describe("event HttpApi", () => { const next = readEvent(reader) const ctx = await reloadTestInstance({ directory: tmp.path }) - await Instance.restore(ctx, () => Bus.publish(ServerEvent.Connected, {})) + await AppRuntime.runPromise( + Bus.Service.use((svc) => svc.publish(ServerEvent.Connected, {})).pipe(Effect.provideService(InstanceRef, ctx)), + ) expect(await next).toMatchObject({ type: "server.connected", properties: {} }) } finally { diff --git a/packages/opencode/test/server/httpapi-exercise/runtime.ts b/packages/opencode/test/server/httpapi-exercise/runtime.ts index 12c3adc27f3..7842752ad9a 100644 --- a/packages/opencode/test/server/httpapi-exercise/runtime.ts +++ b/packages/opencode/test/server/httpapi-exercise/runtime.ts @@ -3,7 +3,6 @@ export type Runtime = { HttpApiApp: (typeof import("../../../src/server/routes/instance/httpapi/server"))["HttpApiApp"] AppLayer: (typeof import("../../../src/effect/app-runtime"))["AppLayer"] InstanceRef: (typeof import("../../../src/effect/instance-ref"))["InstanceRef"] - Instance: (typeof import("../../../src/project/instance"))["Instance"] InstanceStore: (typeof import("../../../src/project/instance-store"))["InstanceStore"] Session: (typeof import("../../../src/session/session"))["Session"] Todo: (typeof import("../../../src/session/todo"))["Todo"] @@ -23,7 +22,6 @@ export function runtime() { const httpApiServer = await import("../../../src/server/routes/instance/httpapi/server") const appRuntime = await import("../../../src/effect/app-runtime") const instanceRef = await import("../../../src/effect/instance-ref") - const instance = await import("../../../src/project/instance") const instanceStore = await import("../../../src/project/instance-store") const session = await import("../../../src/session/session") const todo = await import("../../../src/session/todo") @@ -37,7 +35,6 @@ export function runtime() { HttpApiApp: httpApiServer.HttpApiApp, AppLayer: appRuntime.AppLayer, InstanceRef: instanceRef.InstanceRef, - Instance: instance.Instance, InstanceStore: instanceStore.InstanceStore, Session: session.Session, Todo: todo.Todo, diff --git a/packages/opencode/test/server/httpapi-file.test.ts b/packages/opencode/test/server/httpapi-file.test.ts index 00a2d42b118..b2403b9fb2b 100644 --- a/packages/opencode/test/server/httpapi-file.test.ts +++ b/packages/opencode/test/server/httpapi-file.test.ts @@ -3,7 +3,6 @@ import { Context } from "effect" import path from "path" import { HttpApiApp } from "../../src/server/routes/instance/httpapi/server" import { FilePaths } from "../../src/server/routes/instance/httpapi/groups/file" -import { Instance } from "../../src/project/instance" import * as Log from "@opencode-ai/core/util/log" import { resetDatabase } from "../fixture/db" import { disposeAllInstances, tmpdir } from "../fixture/fixture" diff --git a/packages/opencode/test/server/httpapi-instance-context.test.ts b/packages/opencode/test/server/httpapi-instance-context.test.ts index 24a989f7cfe..7c08d0e8d49 100644 --- a/packages/opencode/test/server/httpapi-instance-context.test.ts +++ b/packages/opencode/test/server/httpapi-instance-context.test.ts @@ -10,7 +10,6 @@ import { WorkspaceID } from "../../src/control-plane/schema" import type { WorkspaceAdapter } from "../../src/control-plane/types" import { Workspace } from "../../src/control-plane/workspace" import { InstanceRef, WorkspaceRef } from "../../src/effect/instance-ref" -import { Instance } from "../../src/project/instance" import { InstanceLayer } from "../../src/project/instance-layer" import { Project } from "../../src/project/project" import { disposeMiddleware, markInstanceForDisposal } from "../../src/server/routes/instance/httpapi/lifecycle" diff --git a/packages/opencode/test/server/httpapi-pty.test.ts b/packages/opencode/test/server/httpapi-pty.test.ts index 8bccbff8636..0f10dbd3a7d 100644 --- a/packages/opencode/test/server/httpapi-pty.test.ts +++ b/packages/opencode/test/server/httpapi-pty.test.ts @@ -1,7 +1,6 @@ import { afterEach, describe, expect, test } from "bun:test" import { NodeHttpServer, NodeServices } from "@effect/platform-node" import { PtyID } from "../../src/pty/schema" -import { Instance } from "../../src/project/instance" import { Server } from "../../src/server/server" import { PtyPaths } from "../../src/server/routes/instance/httpapi/groups/pty" import * as Log from "@opencode-ai/core/util/log" diff --git a/packages/opencode/test/server/httpapi-raw-route-auth.test.ts b/packages/opencode/test/server/httpapi-raw-route-auth.test.ts index adf6e18ee83..7436c10817a 100644 --- a/packages/opencode/test/server/httpapi-raw-route-auth.test.ts +++ b/packages/opencode/test/server/httpapi-raw-route-auth.test.ts @@ -1,7 +1,6 @@ import { afterEach, describe, expect, test } from "bun:test" import { ConfigProvider, Layer } from "effect" import { HttpRouter } from "effect/unstable/http" -import { Instance } from "../../src/project/instance" import { EventPaths } from "../../src/server/routes/instance/httpapi/groups/event" import { PtyPaths } from "../../src/server/routes/instance/httpapi/groups/pty" import { HttpApiApp } from "../../src/server/routes/instance/httpapi/server" diff --git a/packages/opencode/test/server/httpapi-workspace.test.ts b/packages/opencode/test/server/httpapi-workspace.test.ts index a2de1362fb8..d34bb762ff6 100644 --- a/packages/opencode/test/server/httpapi-workspace.test.ts +++ b/packages/opencode/test/server/httpapi-workspace.test.ts @@ -13,7 +13,6 @@ import * as Log from "@opencode-ai/core/util/log" import { Server } from "../../src/server/server" import { resetDatabase } from "../fixture/db" import { disposeAllInstances, provideInstance, tmpdirScoped } from "../fixture/fixture" -import { Instance } from "../../src/project/instance" import { InstanceBootstrap } from "../../src/project/bootstrap" import { InstanceStore } from "../../src/project/instance-store" import { Project } from "../../src/project/project" @@ -71,7 +70,7 @@ function listedAdapter(directory: string, type: string): WorkspaceAdapter { }, async create() {}, async remove() {}, - list() { + list(context) { return [ { type, @@ -79,7 +78,7 @@ function listedAdapter(directory: string, type: string): WorkspaceAdapter { branch: "listed/main", directory, extra: { listed: true }, - projectID: Instance.project.id, + projectID: context?.instance?.project.id ?? missingAdapterContext(), }, ] }, @@ -92,6 +91,10 @@ function listedAdapter(directory: string, type: string): WorkspaceAdapter { } } +function missingAdapterContext(): never { + throw new Error("missing workspace adapter context") +} + function remoteAdapter(directory: string, url: string, headers?: HeadersInit): WorkspaceAdapter { return { name: "Remote Test", diff --git a/packages/opencode/test/session/llm.test.ts b/packages/opencode/test/session/llm.test.ts index 3a949287e48..baeda4257c2 100644 --- a/packages/opencode/test/session/llm.test.ts +++ b/packages/opencode/test/session/llm.test.ts @@ -4,8 +4,9 @@ import { tool, type ModelMessage } from "ai" import { Cause, Effect, Exit, Stream } from "effect" import z from "zod" import { makeRuntime } from "../../src/effect/run-service" +import { InstanceRef } from "../../src/effect/instance-ref" import { LLM } from "../../src/session/llm" -import { Instance } from "../../src/project/instance" +import type { InstanceContext } from "../../src/project/instance-context" import { WithInstance } from "../../src/project/with-instance" import { Provider } from "@/provider/provider" import { ProviderTransform } from "@/provider/transform" @@ -18,19 +19,21 @@ import { MessageV2 } from "../../src/session/message-v2" import { SessionID, MessageID } from "../../src/session/schema" import { AppRuntime } from "../../src/effect/app-runtime" -async function getModel(providerID: ProviderID, modelID: ModelID) { - return AppRuntime.runPromise( - Effect.gen(function* () { - const provider = yield* Provider.Service - return yield* provider.getModel(providerID, modelID) - }), - ) +async function getModel(providerID: ProviderID, modelID: ModelID, ctx: InstanceContext) { + const effect = Effect.gen(function* () { + const provider = yield* Provider.Service + return yield* provider.getModel(providerID, modelID) + }) + return AppRuntime.runPromise(effect.pipe(Effect.provideService(InstanceRef, ctx))) } const llm = makeRuntime(LLM.Service, LLM.defaultLayer) -async function drain(input: LLM.StreamInput) { - return llm.runPromise((svc) => svc.stream(input).pipe(Stream.runDrain)) +async function drain(input: LLM.StreamInput, ctx: InstanceContext) { + return llm.runPromise((svc) => { + const effect = svc.stream(input).pipe(Stream.runDrain) + return effect.pipe(Effect.provideService(InstanceRef, ctx)) + }) } describe("session.llm.hasToolCalls", () => { @@ -360,8 +363,8 @@ describe("session.llm.stream", () => { await WithInstance.provide({ directory: tmp.path, - fn: async () => { - const resolved = await getModel(ProviderID.make(providerID), ModelID.make(model.id)) + fn: async (ctx) => { + const resolved = await getModel(ProviderID.make(providerID), ModelID.make(model.id), ctx) const sessionID = SessionID.make("session-test-1") const agent = { name: "test", @@ -381,15 +384,18 @@ describe("session.llm.stream", () => { model: { providerID: ProviderID.make(providerID), modelID: resolved.id, variant: "high" }, } satisfies MessageV2.User - await drain({ - user, - sessionID, - model: resolved, - agent, - system: ["You are a helpful assistant."], - messages: [{ role: "user", content: "Hello" }], - tools: {}, - }) + await drain( + { + user, + sessionID, + model: resolved, + agent, + system: ["You are a helpful assistant."], + messages: [{ role: "user", content: "Hello" }], + tools: {}, + }, + ctx, + ) const capture = await request const body = capture.body @@ -447,8 +453,8 @@ describe("session.llm.stream", () => { await WithInstance.provide({ directory: tmp.path, - fn: async () => { - const resolved = await getModel(ProviderID.make(providerID), ModelID.make(model.id)) + fn: async (ctx) => { + const resolved = await getModel(ProviderID.make(providerID), ModelID.make(model.id), ctx) const sessionID = SessionID.make("session-test-service-abort") const agent = { name: "test", @@ -478,7 +484,7 @@ describe("session.llm.stream", () => { messages: [{ role: "user", content: "Hello" }], tools: {}, }) - .pipe(Stream.runDrain), + .pipe(Stream.runDrain, Effect.provideService(InstanceRef, ctx)), { signal: ctrl.signal }, ) @@ -537,8 +543,8 @@ describe("session.llm.stream", () => { await WithInstance.provide({ directory: tmp.path, - fn: async () => { - const resolved = await getModel(ProviderID.make(providerID), ModelID.make(model.id)) + fn: async (ctx) => { + const resolved = await getModel(ProviderID.make(providerID), ModelID.make(model.id), ctx) const sessionID = SessionID.make("session-test-tools") const agent = { name: "test", @@ -557,22 +563,25 @@ describe("session.llm.stream", () => { tools: { question: true }, } satisfies MessageV2.User - await drain({ - user, - sessionID, - model: resolved, - agent, - permission: [{ permission: "question", pattern: "*", action: "allow" }], - system: ["You are a helpful assistant."], - messages: [{ role: "user", content: "Hello" }], - tools: { - question: tool({ - description: "Ask a question", - inputSchema: z.object({}), - execute: async () => ({ output: "" }), - }), + await drain( + { + user, + sessionID, + model: resolved, + agent, + permission: [{ permission: "question", pattern: "*", action: "allow" }], + system: ["You are a helpful assistant."], + messages: [{ role: "user", content: "Hello" }], + tools: { + question: tool({ + description: "Ask a question", + inputSchema: z.object({}), + execute: async () => ({ output: "" }), + }), + }, }, - }) + ctx, + ) const capture = await request const tools = capture.body.tools as Array<{ function?: { name?: string } }> | undefined @@ -651,8 +660,8 @@ describe("session.llm.stream", () => { await WithInstance.provide({ directory: tmp.path, - fn: async () => { - const resolved = await getModel(ProviderID.openai, ModelID.make(model.id)) + fn: async (ctx) => { + const resolved = await getModel(ProviderID.openai, ModelID.make(model.id), ctx) const sessionID = SessionID.make("session-test-2") const agent = { name: "test", @@ -671,15 +680,18 @@ describe("session.llm.stream", () => { model: { providerID: ProviderID.make("openai"), modelID: resolved.id, variant: "high" }, } satisfies MessageV2.User - await drain({ - user, - sessionID, - model: resolved, - agent, - system: ["You are a helpful assistant."], - messages: [{ role: "user", content: "Hello" }], - tools: {}, - }) + await drain( + { + user, + sessionID, + model: resolved, + agent, + system: ["You are a helpful assistant."], + messages: [{ role: "user", content: "Hello" }], + tools: {}, + }, + ctx, + ) const capture = await request const body = capture.body @@ -767,8 +779,8 @@ describe("session.llm.stream", () => { await WithInstance.provide({ directory: tmp.path, - fn: async () => { - const resolved = await getModel(ProviderID.openai, ModelID.make(model.id)) + fn: async (ctx) => { + const resolved = await getModel(ProviderID.openai, ModelID.make(model.id), ctx) const sessionID = SessionID.make("session-test-data-url") const agent = { name: "test", @@ -786,28 +798,31 @@ describe("session.llm.stream", () => { model: { providerID: ProviderID.make("openai"), modelID: resolved.id }, } satisfies MessageV2.User - await drain({ - user, - sessionID, - model: resolved, - agent, - system: ["You are a helpful assistant."], - messages: [ - { - role: "user", - content: [ - { type: "text", text: "Describe this image" }, - { - type: "file", - mediaType: "image/png", - filename: "large-image.png", - data: image, - }, - ], - }, - ] as ModelMessage[], - tools: {}, - }) + await drain( + { + user, + sessionID, + model: resolved, + agent, + system: ["You are a helpful assistant."], + messages: [ + { + role: "user", + content: [ + { type: "text", text: "Describe this image" }, + { + type: "file", + mediaType: "image/png", + filename: "large-image.png", + data: image, + }, + ], + }, + ] as ModelMessage[], + tools: {}, + }, + ctx, + ) const capture = await request expect(capture.url.pathname.endsWith("/responses")).toBe(true) @@ -886,8 +901,8 @@ describe("session.llm.stream", () => { await WithInstance.provide({ directory: tmp.path, - fn: async () => { - const resolved = await getModel(ProviderID.make(providerID), ModelID.make(model.id)) + fn: async (ctx) => { + const resolved = await getModel(ProviderID.make(providerID), ModelID.make(model.id), ctx) const sessionID = SessionID.make("session-test-3") const agent = { name: "test", @@ -907,15 +922,18 @@ describe("session.llm.stream", () => { model: { providerID: ProviderID.make("minimax"), modelID: ModelID.make("MiniMax-M2.5") }, } satisfies MessageV2.User - await drain({ - user, - sessionID, - model: resolved, - agent, - system: ["You are a helpful assistant."], - messages: [{ role: "user", content: "Hello" }], - tools: {}, - }) + await drain( + { + user, + sessionID, + model: resolved, + agent, + system: ["You are a helpful assistant."], + messages: [{ role: "user", content: "Hello" }], + tools: {}, + }, + ctx, + ) const capture = await request const body = capture.body @@ -1004,8 +1022,8 @@ describe("session.llm.stream", () => { await WithInstance.provide({ directory: tmp.path, - fn: async () => { - const resolved = await getModel(ProviderID.make("anthropic"), ModelID.make(model.id)) + fn: async (ctx) => { + const resolved = await getModel(ProviderID.make("anthropic"), ModelID.make(model.id), ctx) const sessionID = SessionID.make("session-test-anthropic-tools") const agent = { name: "test", @@ -1110,31 +1128,34 @@ describe("session.llm.stream", () => { }, ] as any[] - await drain({ - user, - sessionID, - model: resolved, - agent, - system: [], - messages: await MessageV2.toModelMessages(input as any, resolved), - tools: { - read: tool({ - description: "Stub read tool", - inputSchema: z.object({ - filePath: z.string(), + await drain( + { + user, + sessionID, + model: resolved, + agent, + system: [], + messages: await MessageV2.toModelMessages(input as any, resolved), + tools: { + read: tool({ + description: "Stub read tool", + inputSchema: z.object({ + filePath: z.string(), + }), + execute: async () => ({ output: "stub" }), }), - execute: async () => ({ output: "stub" }), - }), - glob: tool({ - description: "Stub glob tool", - inputSchema: z.object({ - pattern: z.string(), - path: z.string().optional(), + glob: tool({ + description: "Stub glob tool", + inputSchema: z.object({ + pattern: z.string(), + path: z.string().optional(), + }), + execute: async () => ({ output: "stub" }), }), - execute: async () => ({ output: "stub" }), - }), + }, }, - }) + ctx, + ) const capture = await request const body = capture.body @@ -1245,8 +1266,8 @@ describe("session.llm.stream", () => { await WithInstance.provide({ directory: tmp.path, - fn: async () => { - const resolved = await getModel(ProviderID.make(providerID), ModelID.make(model.id)) + fn: async (ctx) => { + const resolved = await getModel(ProviderID.make(providerID), ModelID.make(model.id), ctx) const sessionID = SessionID.make("session-test-4") const agent = { name: "test", @@ -1266,15 +1287,18 @@ describe("session.llm.stream", () => { model: { providerID: ProviderID.make(providerID), modelID: resolved.id }, } satisfies MessageV2.User - await drain({ - user, - sessionID, - model: resolved, - agent, - system: ["You are a helpful assistant."], - messages: [{ role: "user", content: "Hello" }], - tools: {}, - }) + await drain( + { + user, + sessionID, + model: resolved, + agent, + system: ["You are a helpful assistant."], + messages: [{ role: "user", content: "Hello" }], + tools: {}, + }, + ctx, + ) const capture = await request const body = capture.body diff --git a/packages/opencode/test/tool/external-directory.test.ts b/packages/opencode/test/tool/external-directory.test.ts index 04ef5c5d012..e59caaa7208 100644 --- a/packages/opencode/test/tool/external-directory.test.ts +++ b/packages/opencode/test/tool/external-directory.test.ts @@ -48,7 +48,7 @@ describe("tool.assertExternalDirectory", () => { }), ) - it.live("no-ops for paths inside Instance.directory", () => + it.live("no-ops for paths inside the instance directory", () => provideInstance("/tmp/project")( Effect.gen(function* () { const { requests, ctx } = makeCtx() diff --git a/packages/opencode/test/tool/lsp.test.ts b/packages/opencode/test/tool/lsp.test.ts index 875af8e010f..875edc1c05f 100644 --- a/packages/opencode/test/tool/lsp.test.ts +++ b/packages/opencode/test/tool/lsp.test.ts @@ -6,7 +6,6 @@ import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { AppFileSystem } from "@opencode-ai/core/filesystem" import { LSP } from "@/lsp/lsp" import { Permission } from "../../src/permission" -import { Instance } from "../../src/project/instance" import { MessageID, SessionID } from "../../src/session/schema" import { Tool } from "@/tool/tool" import { Truncate } from "@/tool/truncate" diff --git a/packages/opencode/test/tool/read.test.ts b/packages/opencode/test/tool/read.test.ts index fcbd10bb4d1..bbfc4c4843d 100644 --- a/packages/opencode/test/tool/read.test.ts +++ b/packages/opencode/test/tool/read.test.ts @@ -10,7 +10,6 @@ import { RuntimeFlags } from "@/effect/runtime-flags" import { Git } from "@/git" import { LSP } from "@/lsp/lsp" import { Permission } from "../../src/permission" -import { Instance } from "../../src/project/instance" import { SessionID, MessageID } from "../../src/session/schema" import { Instruction } from "../../src/session/instruction" import { ReadTool } from "../../src/tool/read" diff --git a/packages/opencode/test/tool/skill.test.ts b/packages/opencode/test/tool/skill.test.ts index c58d1a190d0..d1538756e12 100644 --- a/packages/opencode/test/tool/skill.test.ts +++ b/packages/opencode/test/tool/skill.test.ts @@ -5,7 +5,6 @@ import path from "path" import { pathToFileURL } from "url" import type { Permission } from "../../src/permission" import type { Tool } from "@/tool/tool" -import { Instance } from "../../src/project/instance" import { SkillTool } from "../../src/tool/skill" import { ToolRegistry } from "@/tool/registry" import { disposeAllInstances, provideTmpdirInstance } from "../fixture/fixture" diff --git a/packages/opencode/test/tool/write.test.ts b/packages/opencode/test/tool/write.test.ts index f6ac57a8ce1..8a5545e717d 100644 --- a/packages/opencode/test/tool/write.test.ts +++ b/packages/opencode/test/tool/write.test.ts @@ -3,7 +3,6 @@ import { Effect, Layer } from "effect" import path from "path" import fs from "fs/promises" import { WriteTool } from "../../src/tool/write" -import { Instance } from "../../src/project/instance" import { LSP } from "@/lsp/lsp" import { AppFileSystem } from "@opencode-ai/core/filesystem" import { Bus } from "../../src/bus" From 48293c52718b26f0933236f0d11a5983c017312c Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Fri, 15 May 2026 17:37:07 +0000 Subject: [PATCH 043/164] chore: generate --- .../src/control-plane/adapters/worktree.ts | 22 +++++++++++++------ packages/opencode/src/effect/bridge.ts | 7 +++++- packages/opencode/src/lsp/client.ts | 6 ++--- 3 files changed, 24 insertions(+), 11 deletions(-) diff --git a/packages/opencode/src/control-plane/adapters/worktree.ts b/packages/opencode/src/control-plane/adapters/worktree.ts index b85ee7ae25a..87e30e1139b 100644 --- a/packages/opencode/src/control-plane/adapters/worktree.ts +++ b/packages/opencode/src/control-plane/adapters/worktree.ts @@ -10,10 +10,7 @@ const WorktreeConfig = Schema.Struct({ const decodeWorktreeConfig = Schema.decodeUnknownSync(WorktreeConfig) async function loadWorktree() { - const [{ AppRuntime }, { Worktree }] = await Promise.all([ - import("@/effect/app-runtime"), - import("@/worktree"), - ]) + const [{ AppRuntime }, { Worktree }] = await Promise.all([import("@/effect/app-runtime"), import("@/worktree")]) return { AppRuntime, Worktree } } @@ -34,7 +31,10 @@ export const WorktreeAdapter: WorkspaceAdapter = { async configure(info, context) { const { AppRuntime, Worktree } = await loadWorktree() const next = await AppRuntime.runPromise( - provideContext(Worktree.Service.use((svc) => svc.makeWorktreeInfo({ detached: true })), context), + provideContext( + Worktree.Service.use((svc) => svc.makeWorktreeInfo({ detached: true })), + context, + ), ) return { ...info, @@ -62,7 +62,12 @@ export const WorktreeAdapter: WorkspaceAdapter = { const { AppRuntime, Worktree } = await loadWorktree() const ctx = requireInstance(context) return ( - await AppRuntime.runPromise(provideContext(Worktree.Service.use((svc) => svc.list()), context)) + await AppRuntime.runPromise( + provideContext( + Worktree.Service.use((svc) => svc.list()), + context, + ), + ) ).map((info) => ({ type: "worktree", name: info.name, @@ -75,7 +80,10 @@ export const WorktreeAdapter: WorkspaceAdapter = { const { AppRuntime, Worktree } = await loadWorktree() const config = decodeWorktreeConfig(info) await AppRuntime.runPromise( - provideContext(Worktree.Service.use((svc) => svc.remove({ directory: config.directory })), context), + provideContext( + Worktree.Service.use((svc) => svc.remove({ directory: config.directory })), + context, + ), ) }, target(info) { diff --git a/packages/opencode/src/effect/bridge.ts b/packages/opencode/src/effect/bridge.ts index 590fb2d7ef2..99f16f43712 100644 --- a/packages/opencode/src/effect/bridge.ts +++ b/packages/opencode/src/effect/bridge.ts @@ -28,7 +28,12 @@ export const bind = (fn: (...args: Args const captured = captureSync() return (...args: Args) => restoreWorkspace(captured.workspace, () => - Effect.runSync(attachWith(Effect.sync(() => fn(...args)), captured)), + Effect.runSync( + attachWith( + Effect.sync(() => fn(...args)), + captured, + ), + ), ) } diff --git a/packages/opencode/src/lsp/client.ts b/packages/opencode/src/lsp/client.ts index 6b7f0c06037..9b687c70278 100644 --- a/packages/opencode/src/lsp/client.ts +++ b/packages/opencode/src/lsp/client.ts @@ -174,9 +174,9 @@ export async function create(input: { const updatePushDiagnostics = (filePath: string, next: Diagnostic[]) => { pushDiagnostics.set(filePath, next) void busRuntime.runPromise((svc) => - svc.publish(Event.Diagnostics, { path: filePath, serverID: input.serverID }).pipe( - Effect.provideService(InstanceRef, instance), - ), + svc + .publish(Event.Diagnostics, { path: filePath, serverID: input.serverID }) + .pipe(Effect.provideService(InstanceRef, instance)), ) } const updatePullDiagnostics = (filePath: string, next: Diagnostic[]) => { From 2fdee50b3b0277482bb781c624db60e16fe16835 Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Fri, 15 May 2026 23:58:52 +0530 Subject: [PATCH 044/164] refactor(acp): extract runtime reentry (#27769) --- packages/opencode/src/acp/agent.ts | 18 ++++-------------- packages/opencode/src/acp/runtime.ts | 22 ++++++++++++++++++++++ 2 files changed, 26 insertions(+), 14 deletions(-) create mode 100644 packages/opencode/src/acp/runtime.ts diff --git a/packages/opencode/src/acp/agent.ts b/packages/opencode/src/acp/agent.ts index 8cc1750ca03..665e3712be8 100644 --- a/packages/opencode/src/acp/agent.ts +++ b/packages/opencode/src/acp/agent.ts @@ -39,29 +39,19 @@ import { Filesystem } from "@/util/filesystem" import { Hash } from "@opencode-ai/core/util/hash" import { ACPSessionManager } from "./session" import type { ACPConfig } from "./types" +import { ACPRuntime } from "./runtime" import { Provider } from "@/provider/provider" import { ModelID, ProviderID } from "../provider/schema" -import { Agent as AgentModule } from "../agent/agent" -import { AppRuntime } from "@/effect/app-runtime" -import { InstanceRef } from "@/effect/instance-ref" -import { InstanceRuntime } from "@/project/instance-runtime" import { Installation } from "@/installation" import { MessageV2 } from "@/session/message-v2" import { Config } from "@/config/config" import { ConfigMCP } from "@/config/mcp" import { Todo } from "@/session/todo" -import { Effect, Result, Schema } from "effect" +import { Result, Schema } from "effect" import { LoadAPIKeyError } from "ai" import type { AssistantMessage, Event, OpencodeClient, SessionMessageResponse, ToolPart } from "@opencode-ai/sdk/v2" import { applyPatch } from "diff" import { InstallationVersion } from "@opencode-ai/core/installation/version" - -const defaultAgentInfo = async (directory: string) => { - const ctx = await InstanceRuntime.load({ directory }) - return AppRuntime.runPromise( - AgentModule.Service.use((svc) => svc.defaultInfo()).pipe(Effect.provideService(InstanceRef, ctx)), - ) -} import { ShellID } from "@/tool/shell/id" type ModeOption = { id: string; name: string; description?: string } @@ -1103,7 +1093,7 @@ export class Agent implements ACPAgent { const currentModeId = await (async () => { if (!availableModes.length) return undefined - const defaultAgent = await defaultAgentInfo(directory) + const defaultAgent = await ACPRuntime.defaultAgentInfo(directory) const resolvedModeId = availableModes.find((mode) => mode.name === defaultAgent.name)?.id ?? availableModes[0].id this.sessionManager.setMode(sessionId, resolvedModeId) return resolvedModeId @@ -1337,7 +1327,7 @@ export class Agent implements ACPAgent { if (!current) { this.sessionManager.setModel(session.id, model) } - const agent = session.modeId ?? (await defaultAgentInfo(directory)).name + const agent = session.modeId ?? (await ACPRuntime.defaultAgentInfo(directory)).name const parts: Array< | { type: "text"; text: string; synthetic?: boolean; ignored?: boolean } diff --git a/packages/opencode/src/acp/runtime.ts b/packages/opencode/src/acp/runtime.ts new file mode 100644 index 00000000000..b08c73cf2e2 --- /dev/null +++ b/packages/opencode/src/acp/runtime.ts @@ -0,0 +1,22 @@ +import { Agent } from "@/agent/agent" +import { AppRuntime, type AppServices } from "@/effect/app-runtime" +import { InstanceRef } from "@/effect/instance-ref" +import { InstanceRuntime } from "@/project/instance-runtime" +import { Effect } from "effect" + +// Global ACP Effect re-entry: no project InstanceRef is provided. +export const runGlobal = AppRuntime.runPromise + +// Directory-scoped ACP Effect re-entry: load the project instance and provide InstanceRef. +export async function runDirectory(input: { directory: string; effect: Effect.Effect }) { + const ctx = await InstanceRuntime.load({ directory: input.directory }) + return AppRuntime.runPromise(input.effect.pipe(Effect.provideService(InstanceRef, ctx))) +} + +export const defaultAgentInfo = (directory: string) => + runDirectory({ + directory, + effect: Agent.Service.use((svc) => svc.defaultInfo()), + }) + +export * as ACPRuntime from "./runtime" From 2b0e72ab79ad1f1075846073cb6a3f0fb7ea2c69 Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Fri, 15 May 2026 23:59:01 +0530 Subject: [PATCH 045/164] refactor(workspace): centralize adapter invocation (#27768) --- .../workspace-adapter-runtime.ts | 51 ++++++++++++ .../opencode/src/control-plane/workspace.ts | 82 ++++--------------- .../httpapi/middleware/workspace-routing.ts | 6 +- 3 files changed, 71 insertions(+), 68 deletions(-) create mode 100644 packages/opencode/src/control-plane/workspace-adapter-runtime.ts diff --git a/packages/opencode/src/control-plane/workspace-adapter-runtime.ts b/packages/opencode/src/control-plane/workspace-adapter-runtime.ts new file mode 100644 index 00000000000..235edc9d22b --- /dev/null +++ b/packages/opencode/src/control-plane/workspace-adapter-runtime.ts @@ -0,0 +1,51 @@ +import { Effect } from "effect" +import { EffectBridge } from "@/effect/bridge" +import { InstanceRef, WorkspaceRef } from "@/effect/instance-ref" +import { getAdapter } from "./adapters" +import type { WorkspaceAdapter, WorkspaceInfo } from "./types" + +const context = Effect.gen(function* () { + return { + instance: yield* InstanceRef, + workspaceID: yield* WorkspaceRef, + } +}) + +export const target = (info: WorkspaceInfo) => + Effect.gen(function* () { + const adapter = getAdapter(info.projectID, info.type) + const ctx = yield* context + return yield* EffectBridge.fromPromise(() => adapter.target(info, ctx)) + }) + +export const configure = (adapter: WorkspaceAdapter, info: WorkspaceInfo) => + Effect.gen(function* () { + const ctx = yield* context + return yield* EffectBridge.fromPromise(() => adapter.configure(info, ctx)) + }) + +export const create = ( + adapter: WorkspaceAdapter, + info: WorkspaceInfo, + env: Record, + from?: WorkspaceInfo, +) => + Effect.gen(function* () { + const ctx = yield* context + return yield* EffectBridge.fromPromise(() => adapter.create(info, env, from, ctx)) + }) + +export const list = (adapter: WorkspaceAdapter) => + Effect.gen(function* () { + const ctx = yield* context + return yield* EffectBridge.fromPromise(() => Promise.resolve(adapter.list?.(ctx) ?? [])) + }) + +export const remove = (info: WorkspaceInfo) => + Effect.gen(function* () { + const adapter = getAdapter(info.projectID, info.type) + const ctx = yield* context + return yield* EffectBridge.fromPromise(() => adapter.remove(info, ctx)) + }) + +export * as WorkspaceAdapterRuntime from "./workspace-adapter-runtime" diff --git a/packages/opencode/src/control-plane/workspace.ts b/packages/opencode/src/control-plane/workspace.ts index 7bbe4aa3257..a50df578f9e 100644 --- a/packages/opencode/src/control-plane/workspace.ts +++ b/packages/opencode/src/control-plane/workspace.ts @@ -26,11 +26,11 @@ import { SessionID } from "@/session/schema" import { NotFoundError } from "@/storage/storage" import { errorData } from "@/util/error" import { waitEvent } from "./util" -import { EffectBridge } from "@/effect/bridge" -import { InstanceRef, WorkspaceRef } from "@/effect/instance-ref" +import { WorkspaceRef } from "@/effect/instance-ref" import { Vcs } from "@/project/vcs" import { InstanceStore } from "@/project/instance-store" import { InstanceBootstrap } from "@/project/bootstrap" +import { WorkspaceAdapterRuntime } from "./workspace-adapter-runtime" export const Info = Schema.Struct({ ...WorkspaceInfoSchema.fields, @@ -196,50 +196,6 @@ export const layer = Layer.effect( }) } - const adapterContext = Effect.gen(function* () { - return { - instance: yield* InstanceRef, - workspaceID: yield* WorkspaceRef, - } - }) - - const adapterTarget = (workspace: Info) => - Effect.gen(function* () { - const adapter = getAdapter(workspace.projectID, workspace.type) - const context = yield* adapterContext - return yield* EffectBridge.fromPromise(() => adapter.target(workspace, context)) - }) - - const adapterConfigure = (adapter: ReturnType, info: WorkspaceInfo) => - Effect.gen(function* () { - const context = yield* adapterContext - return yield* EffectBridge.fromPromise(() => adapter.configure(info, context)) - }) - - const adapterCreate = ( - adapter: ReturnType, - info: WorkspaceInfo, - env: Record, - from?: WorkspaceInfo, - ) => - Effect.gen(function* () { - const context = yield* adapterContext - return yield* EffectBridge.fromPromise(() => adapter.create(info, env, from, context)) - }) - - const adapterList = (adapter: ReturnType) => - Effect.gen(function* () { - const context = yield* adapterContext - return yield* EffectBridge.fromPromise(() => Promise.resolve(adapter.list?.(context) ?? [])) - }) - - const adapterRemove = (info: Info, type: string) => - Effect.gen(function* () { - const adapter = getAdapter(info.projectID, type) - const context = yield* adapterContext - return yield* EffectBridge.fromPromise(() => adapter.remove(info, context)) - }) - const connectSSE = Effect.fn("Workspace.connectSSE")(function* ( url: URL | string, headers: HeadersInit | undefined, @@ -325,7 +281,7 @@ export const layer = Layer.effect( const workspace = yield* get(input.workspaceID) if (!workspace) return input.fallback - const target = yield* adapterTarget(workspace) + const target = yield* WorkspaceAdapterRuntime.target(workspace) if (target.type === "local") { const store = yield* InstanceStore.Service @@ -438,7 +394,7 @@ export const layer = Layer.effect( }) const syncWorkspaceLoop = Effect.fn("Workspace.syncWorkspaceLoop")(function* (space: Info) { - const target = yield* adapterTarget(space) + const target = yield* WorkspaceAdapterRuntime.target(space) if (target.type === "local") return @@ -521,7 +477,7 @@ export const layer = Layer.effect( const startSync = Effect.fn("Workspace.startSync")(function* (space: Info) { if (!flags.experimentalWorkspaces) return - const target = yield* adapterTarget(space).pipe( + const target = yield* WorkspaceAdapterRuntime.target(space).pipe( Effect.catch((error) => Effect.sync(() => { setStatus(space.id, "error") @@ -572,7 +528,7 @@ export const layer = Layer.effect( const create = Effect.fn("Workspace.create")(function* (input: CreateInput) { const id = WorkspaceID.ascending(input.id) const adapter = getAdapter(input.projectID, input.type) - const config = yield* adapterConfigure(adapter, { + const config = yield* WorkspaceAdapterRuntime.configure(adapter, { ...input, id, name: Slug.create(), @@ -615,7 +571,7 @@ export const layer = Layer.effect( OTEL_RESOURCE_ATTRIBUTES: process.env.OTEL_RESOURCE_ATTRIBUTES, } - yield* adapterCreate(adapter, config, env) + yield* WorkspaceAdapterRuntime.create(adapter, config, env) yield* Effect.all( [ waitEvent({ @@ -654,7 +610,7 @@ export const layer = Layer.effect( if (current?.workspaceID) { const previous = yield* get(current.workspaceID) if (previous) { - const target = yield* adapterTarget(previous) + const target = yield* WorkspaceAdapterRuntime.target(previous) if (target.type === "remote") { yield* syncHistory(previous, target.url, target.headers).pipe( @@ -732,7 +688,7 @@ export const layer = Layer.effect( workspaceID, }) - const target = yield* adapterTarget(space) + const target = yield* WorkspaceAdapterRuntime.target(space) if (target.type === "local") { yield* sync.run(Session.Event.Updated, { @@ -885,16 +841,14 @@ export const layer = Layer.effect( const discovered = yield* Effect.forEach( registeredAdapters(project.id), ([type, adapter]) => - adapter.list - ? adapterList(adapter).pipe( - Effect.catchCause((error) => - Effect.sync(() => { - log.warn("workspace adapter list failed", { type, error }) - return [] - }), - ), - ) - : Effect.succeed([]), + WorkspaceAdapterRuntime.list(adapter).pipe( + Effect.catchCause((error) => + Effect.sync(() => { + log.warn("workspace adapter list failed", { type, error }) + return [] + }), + ), + ), { concurrency: "unbounded" }, ).pipe(Effect.map((items) => items.flat())) @@ -967,7 +921,7 @@ export const layer = Layer.effect( const info = fromRow(row) yield* Effect.catchCause( Effect.gen(function* () { - yield* adapterRemove(info, row.type) + yield* WorkspaceAdapterRuntime.remove(info) }), () => Effect.sync(() => { diff --git a/packages/opencode/src/server/routes/instance/httpapi/middleware/workspace-routing.ts b/packages/opencode/src/server/routes/instance/httpapi/middleware/workspace-routing.ts index 1d665fd5c95..cd9376f7f9d 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/middleware/workspace-routing.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/middleware/workspace-routing.ts @@ -1,8 +1,7 @@ -import { getAdapter } from "@/control-plane/adapters" import { WorkspaceID } from "@/control-plane/schema" import type { Target } from "@/control-plane/types" import { Workspace } from "@/control-plane/workspace" -import { EffectBridge } from "@/effect/bridge" +import { WorkspaceAdapterRuntime } from "@/control-plane/workspace-adapter-runtime" import { Session } from "@/session/session" import { HttpApiProxy } from "./proxy" import * as Fence from "@/server/shared/fence" @@ -93,8 +92,7 @@ function missingWorkspaceResponse(id: WorkspaceID): HttpServerResponse.HttpServe } function resolveTarget(workspace: Workspace.Info): Effect.Effect { - const adapter = getAdapter(workspace.projectID, workspace.type) - return EffectBridge.fromPromise(() => adapter.target(workspace)) + return WorkspaceAdapterRuntime.target(workspace) } function proxyRemote( From f99339e525c3ff2f9aa063dfe855ec1d1e7e4e11 Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Sat, 16 May 2026 00:14:07 +0530 Subject: [PATCH 046/164] fix(tui): keep session switching pinned-only (#27775) --- packages/opencode/src/cli/cmd/tui/app.tsx | 42 ++----- .../cmd/tui/component/dialog-session-list.tsx | 26 +---- .../src/cli/cmd/tui/config/keybind.ts | 6 - .../src/cli/cmd/tui/context/local.tsx | 104 +----------------- .../tui/feature-plugins/home/tips-view.tsx | 14 +-- 5 files changed, 14 insertions(+), 178 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index 29cca133bbf..7d58782101d 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -76,8 +76,6 @@ const appBindingCommands = [ "command.palette.show", "session.list", "session.new", - "session.cycle_recent", - "session.cycle_recent_reverse", "session.quick_switch.1", "session.quick_switch.2", "session.quick_switch.3", @@ -482,35 +480,15 @@ function App(props: { onSnapshot?: () => Promise }) { }, }, ...(Flag.OPENCODE_EXPERIMENTAL_SESSION_SWITCHING - ? [ - { - name: "session.cycle_recent", - title: "Cycle to previous recent session", - category: "Session", - hidden: true, - run: () => { - local.session.cycleRecent(1) - }, + ? Array.from({ length: 9 }, (_, i) => ({ + name: `session.quick_switch.${i + 1}`, + title: `Switch to session in quick slot ${i + 1}`, + category: "Session", + hidden: true, + run: () => { + local.session.quickSwitch(i + 1) }, - { - name: "session.cycle_recent_reverse", - title: "Cycle to next recent session", - category: "Session", - hidden: true, - run: () => { - local.session.cycleRecent(-1) - }, - }, - ...Array.from({ length: 9 }, (_, i) => ({ - name: `session.quick_switch.${i + 1}`, - title: `Switch to session in quick slot ${i + 1}`, - category: "Session", - hidden: true, - run: () => { - local.session.quickSwitch(i + 1) - }, - })), - ] + })) : []), { name: "model.list", @@ -830,9 +808,7 @@ function App(props: { onSnapshot?: () => Promise }) { "app", Flag.OPENCODE_EXPERIMENTAL_SESSION_SWITCHING ? appBindingCommands - : appBindingCommands.filter( - (c) => !c.startsWith("session.cycle_recent") && !c.startsWith("session.quick_switch"), - ), + : appBindingCommands.filter((c) => !c.startsWith("session.quick_switch")), ), })) diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx index 1dd33106dec..68c4a1d07fa 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx @@ -130,8 +130,6 @@ export function DialogSessionList() { const [browseOrder] = createSignal(orderByRecency(sync.data.session)) - const RECENT_LIMIT = 5 - const options = createMemo(() => { const enabled = Flag.OPENCODE_EXPERIMENTAL_SESSION_SWITCHING const today = new Date().toDateString() @@ -144,18 +142,12 @@ export function DialogSessionList() { const searchResult = searchResults() const displayOrder = searchResult ? orderByRecency(searchResult) : browseOrder() - const dismissed = enabled ? new Set(local.session.dismissedRecent()) : new Set() const pinned = enabled ? local.session.pinned().filter((id) => sessionMap.has(id)) : [] const pinnedSet = new Set(pinned) const slotByID = enabled ? new Map(local.session.slots().map((id, i) => [id, i + 1])) : new Map() - const recent = enabled - ? displayOrder.filter((id) => !pinnedSet.has(id) && !dismissed.has(id)).slice(0, RECENT_LIMIT) - : [] - const recentSet = new Set(recent) - function buildOption(id: string, category: string) { const x = sessionMap.get(id) if (!x) return undefined @@ -198,7 +190,7 @@ export function DialogSessionList() { } const remaining = displayOrder - .filter((id) => !pinnedSet.has(id) && !recentSet.has(id)) + .filter((id) => !pinnedSet.has(id)) .map((id) => { const x = sessionMap.get(id) if (!x) return undefined @@ -209,7 +201,6 @@ export function DialogSessionList() { return [ ...pinned.map((id) => buildOption(id, "Pinned")).filter((x) => x !== undefined), - ...recent.map((id) => buildOption(id, "Recent")).filter((x) => x !== undefined), ...remaining, ] }) @@ -245,21 +236,6 @@ export function DialogSessionList() { local.session.togglePin(option.value) }, }, - { - command: "session.toggle.recent", - title: "toggle recent", - onTrigger: (option: { value: string }) => { - if (local.session.isPinned(option.value)) { - toast.show({ - variant: "info", - message: "Unpin the session first to toggle it in Recent", - duration: 3000, - }) - return - } - local.session.toggleRecent(option.value) - }, - }, ] : []), { diff --git a/packages/opencode/src/cli/cmd/tui/config/keybind.ts b/packages/opencode/src/cli/cmd/tui/config/keybind.ts index 46238931616..bd26cd5d95e 100644 --- a/packages/opencode/src/cli/cmd/tui/config/keybind.ts +++ b/packages/opencode/src/cli/cmd/tui/config/keybind.ts @@ -87,9 +87,6 @@ export const Definitions = { session_child_cycle_reverse: keybind("left", "Go to previous child session"), session_parent: keybind("up", "Go to parent session"), session_pin_toggle: keybind("ctrl+f", "Pin or unpin session in the session list"), - session_toggle_recent: keybind("ctrl+h", "Show or hide session in the Recent group"), - session_cycle_recent: keybind("]", "Cycle to the previous recent session"), - session_cycle_recent_reverse: keybind("[", "Cycle to the next recent session"), session_quick_switch_1: keybind("1", "Switch to session in quick slot 1"), session_quick_switch_2: keybind("2", "Switch to session in quick slot 2"), session_quick_switch_3: keybind("3", "Switch to session in quick slot 3"), @@ -273,9 +270,6 @@ export const CommandMap = { session_child_cycle_reverse: "session.child.previous", session_parent: "session.parent", session_pin_toggle: "session.pin.toggle", - session_toggle_recent: "session.toggle.recent", - session_cycle_recent: "session.cycle_recent", - session_cycle_recent_reverse: "session.cycle_recent_reverse", session_quick_switch_1: "session.quick_switch.1", session_quick_switch_2: "session.quick_switch.2", session_quick_switch_3: "session.quick_switch.3", diff --git a/packages/opencode/src/cli/cmd/tui/context/local.tsx b/packages/opencode/src/cli/cmd/tui/context/local.tsx index fc222631519..e33a54b34e5 100644 --- a/packages/opencode/src/cli/cmd/tui/context/local.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/local.tsx @@ -1,6 +1,6 @@ import { createStore } from "solid-js/store" import { createSimpleContext } from "./helper" -import { batch, createEffect, createMemo, on } from "solid-js" +import { batch, createEffect, createMemo } from "solid-js" import { useSync } from "@tui/context/sync" import { useTheme } from "@tui/context/theme" import { useRoute } from "@tui/context/route" @@ -8,7 +8,6 @@ import { useEvent } from "@tui/context/event" import { uniqueBy } from "remeda" import path from "path" import { Global } from "@opencode-ai/core/global" -import { Flag } from "@opencode-ai/core/flag/flag" import { iife } from "@/util/iife" import { useToast } from "../ui/toast" import { useArgs } from "./args" @@ -387,13 +386,9 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ const [sessionStore, setSessionStore] = createStore<{ ready: boolean pinned: string[] - dismissedRecent: string[] - recentOrder: string[] }>({ ready: false, pinned: [], - dismissedRecent: [], - recentOrder: [], }) const filePath = path.join(Global.Path.state, "session.json") @@ -409,16 +404,12 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ state.pending = false void Filesystem.writeJson(filePath, { pinned: sessionStore.pinned, - dismissedRecent: sessionStore.dismissedRecent, - recentOrder: sessionStore.recentOrder, }) } Filesystem.readJson(filePath) .then((x: any) => { if (Array.isArray(x.pinned)) setSessionStore("pinned", x.pinned) - if (Array.isArray(x.dismissedRecent)) setSessionStore("dismissedRecent", x.dismissedRecent) - if (Array.isArray(x.recentOrder)) setSessionStore("recentOrder", x.recentOrder) }) .catch(() => {}) .finally(() => { @@ -428,19 +419,10 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ const route = useRoute() const event = useEvent() - let cycling = false const slots = createMemo(() => { - const rootSessions = sync.data.session.filter((x) => x.parentID === undefined) - const existing = new Set(rootSessions.map((x) => x.id)) - const dismissed = new Set(sessionStore.dismissedRecent) - const pins = sessionStore.pinned.filter((id) => existing.has(id)) - const pinnedSet = new Set(pins) - const recent = rootSessions - .filter((x) => !pinnedSet.has(x.id) && !dismissed.has(x.id)) - .toSorted((a, b) => b.time.updated - a.time.updated) - .map((x) => x.id) - return [...pins, ...recent].slice(0, 9) + const existing = new Set(sync.data.session.filter((x) => x.parentID === undefined).map((x) => x.id)) + return sessionStore.pinned.filter((id) => existing.has(id)).slice(0, 9) }) function prune(sessionID: string) { @@ -451,18 +433,6 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ sessionStore.pinned.filter((x) => x !== sessionID), ) } - if (sessionStore.dismissedRecent.includes(sessionID)) { - setSessionStore( - "dismissedRecent", - sessionStore.dismissedRecent.filter((x) => x !== sessionID), - ) - } - if (sessionStore.recentOrder.includes(sessionID)) { - setSessionStore( - "recentOrder", - sessionStore.recentOrder.filter((x) => x !== sessionID), - ) - } save() }) } @@ -471,25 +441,6 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ prune(evt.properties.info.id) }) - if (Flag.OPENCODE_EXPERIMENTAL_SESSION_SWITCHING) { - createEffect( - on( - () => (sessionStore.ready && route.data.type === "session" ? route.data.sessionID : undefined), - (sessionID) => { - if (!sessionID) return - if (cycling) { - cycling = false - return - } - const filtered = sessionStore.recentOrder.filter((x) => x !== sessionID) - const next = [sessionID, ...filtered].slice(0, 20) - setSessionStore("recentOrder", next) - save() - }, - ), - ) - } - return { get ready() { return sessionStore.ready @@ -497,19 +448,10 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ pinned() { return sessionStore.pinned }, - dismissedRecent() { - return sessionStore.dismissedRecent - }, - recentOrder() { - return sessionStore.recentOrder - }, slots, isPinned(sessionID: string) { return sessionStore.pinned.includes(sessionID) }, - isDismissed(sessionID: string) { - return sessionStore.dismissedRecent.includes(sessionID) - }, togglePin(sessionID: string) { batch(() => { const exists = sessionStore.pinned.includes(sessionID) @@ -520,52 +462,12 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ save() }) }, - toggleRecent(sessionID: string) { - batch(() => { - const exists = sessionStore.dismissedRecent.includes(sessionID) - const next = exists - ? sessionStore.dismissedRecent.filter((x) => x !== sessionID) - : [sessionID, ...sessionStore.dismissedRecent] - setSessionStore("dismissedRecent", next) - save() - }) - }, quickSwitch(slot: number) { const target = slots()[slot - 1] if (!target) return if (route.data.type === "session" && route.data.sessionID === target) return route.navigate({ type: "session", sessionID: target }) }, - cycleRecent(direction: 1 | -1) { - if (route.data.type !== "session") { - toast.show({ - variant: "info", - message: "Open a session first to cycle between recent sessions", - duration: 3000, - }) - return - } - const current = route.data.sessionID - const order = sessionStore.recentOrder.filter((id) => - sync.data.session.some((s) => s.id === id && s.parentID === undefined), - ) - if (order.length < 2) { - toast.show({ - variant: "info", - message: "No other recent sessions to cycle to", - duration: 3000, - }) - return - } - const index = order.indexOf(current) - if (index === -1) return - const next = index + direction - if (next < 0 || next >= order.length) return - const target = order[next] - if (!target || target === current) return - cycling = true - route.navigate({ type: "session", sessionID: target }) - }, } }) diff --git a/packages/opencode/src/cli/cmd/tui/feature-plugins/home/tips-view.tsx b/packages/opencode/src/cli/cmd/tui/feature-plugins/home/tips-view.tsx index 8c50914df37..f2a5f97f1e6 100644 --- a/packages/opencode/src/cli/cmd/tui/feature-plugins/home/tips-view.tsx +++ b/packages/opencode/src/cli/cmd/tui/feature-plugins/home/tips-view.tsx @@ -29,8 +29,6 @@ type Shortcuts = { messagesToggleConceal: TipShortcut modelCycleRecent: TipShortcut modelList: TipShortcut - sessionCycleRecent: TipShortcut - sessionCycleRecentReverse: TipShortcut sessionExport: TipShortcut sessionInterrupt: TipShortcut sessionList: TipShortcut @@ -41,7 +39,6 @@ type Shortcuts = { sessionQuickSwitch9: TipShortcut sessionSidebarToggle: TipShortcut sessionTimeline: TipShortcut - sessionToggleRecent: TipShortcut statusView: TipShortcut terminalSuspend: TipShortcut themeList: TipShortcut @@ -121,8 +118,6 @@ export function Tips(props: { api: TuiPluginApi; connected?: boolean }) { messagesToggleConceal: configShortcut(props.api, "session.toggle.conceal"), modelCycleRecent: useCommandShortcut("model.cycle_recent"), modelList: useCommandShortcut("model.list"), - sessionCycleRecent: useCommandShortcut("session.cycle_recent"), - sessionCycleRecentReverse: useCommandShortcut("session.cycle_recent_reverse"), sessionExport: configShortcut(props.api, "session.export"), sessionInterrupt: configShortcut(props.api, "session.interrupt"), sessionList: useCommandShortcut("session.list"), @@ -133,7 +128,6 @@ export function Tips(props: { api: TuiPluginApi; connected?: boolean }) { sessionQuickSwitch9: useCommandShortcut("session.quick_switch.9"), sessionSidebarToggle: configShortcut(props.api, "session.sidebar.toggle"), sessionTimeline: configShortcut(props.api, "session.timeline"), - sessionToggleRecent: configShortcut(props.api, "session.toggle.recent"), statusView: useCommandShortcut("opencode.status"), terminalSuspend: useCommandShortcut("terminal.suspend"), themeList: useCommandShortcut("theme.switch"), @@ -183,14 +177,8 @@ const TIPS: Tip[] = [ press(shortcuts.sessionPinToggle(), "in the session list to pin a session so it stays at the top"), (shortcuts) => shortcuts.sessionQuickSwitch1() && shortcuts.sessionQuickSwitch9() - ? `Pinned and recent sessions are bound to ${shortcutText(shortcuts.sessionQuickSwitch1())} through ${shortcutText(shortcuts.sessionQuickSwitch9())} for one-press switching` + ? `Pinned sessions are bound to ${shortcutText(shortcuts.sessionQuickSwitch1())} through ${shortcutText(shortcuts.sessionQuickSwitch9())} for one-press switching` : undefined, - (shortcuts) => - shortcuts.sessionCycleRecent() && shortcuts.sessionCycleRecentReverse() - ? `Press ${shortcutText(shortcuts.sessionCycleRecent())} / ${shortcutText(shortcuts.sessionCycleRecentReverse())} to cycle through recently visited sessions` - : undefined, - (shortcuts) => - press(shortcuts.sessionToggleRecent(), "in the session list to show or hide a session in the Recent group"), ] satisfies Tip[]) : []), "Run {highlight}/compact{/highlight} to summarize long sessions near context limits", From d44bef21079707286c38f2fbc852a537ab0639e3 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Fri, 15 May 2026 18:45:24 +0000 Subject: [PATCH 047/164] chore: generate --- .../src/cli/cmd/tui/component/dialog-session-list.tsx | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx index 68c4a1d07fa..9bc1d79e309 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx @@ -199,10 +199,7 @@ export function DialogSessionList() { }) .filter((x) => x !== undefined) - return [ - ...pinned.map((id) => buildOption(id, "Pinned")).filter((x) => x !== undefined), - ...remaining, - ] + return [...pinned.map((id) => buildOption(id, "Pinned")).filter((x) => x !== undefined), ...remaining] }) onMount(() => { From a24abd2b112c99cd820eacbddc0ebd4b761e9cbc Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Sat, 16 May 2026 00:46:27 +0530 Subject: [PATCH 048/164] refactor(lsp): require explicit instance context (#27767) --- packages/opencode/src/lsp/client.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/opencode/src/lsp/client.ts b/packages/opencode/src/lsp/client.ts index 9b687c70278..205cba6f29e 100644 --- a/packages/opencode/src/lsp/client.ts +++ b/packages/opencode/src/lsp/client.ts @@ -13,7 +13,7 @@ import { withTimeout } from "../util/timeout" import { Filesystem } from "@/util/filesystem" import { InstanceRef } from "@/effect/instance-ref" import { makeRuntime } from "@/effect/run-service" -import { context, type InstanceContext } from "@/project/instance-context" +import type { InstanceContext } from "@/project/instance-context" const DIAGNOSTICS_DEBOUNCE_MS = 150 const DIAGNOSTICS_DOCUMENT_WAIT_TIMEOUT_MS = 5_000 @@ -143,11 +143,11 @@ export async function create(input: { server: LSPServer.Handle root: string directory: string - instance?: InstanceContext + instance: InstanceContext }) { const logger = log.clone().tag("serverID", input.serverID) logger.info("starting client") - const instance = input.instance ?? context.use() + const instance = input.instance const connection = createMessageConnection( new StreamMessageReader(input.server.process.stdout as any), From f33b4455a189bf0f60c8bae329ddf12892895a73 Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Sat, 16 May 2026 01:10:16 +0530 Subject: [PATCH 049/164] feat(tui): enable pinned session switching (#27780) --- packages/core/src/flag/flag.ts | 1 - packages/opencode/src/cli/cmd/tui/app.tsx | 27 +++++------ .../cmd/tui/component/dialog-session-list.tsx | 45 ++++++++++++------- .../tui/feature-plugins/home/tips-view.tsx | 18 +++----- .../src/cli/cmd/tui/ui/dialog-select.tsx | 12 +++-- 5 files changed, 54 insertions(+), 49 deletions(-) diff --git a/packages/core/src/flag/flag.ts b/packages/core/src/flag/flag.ts index 4b1d3d20aec..3ed67bb785d 100644 --- a/packages/core/src/flag/flag.ts +++ b/packages/core/src/flag/flag.ts @@ -44,7 +44,6 @@ export const Flag = { OPENCODE_WORKSPACE_ID: process.env["OPENCODE_WORKSPACE_ID"], OPENCODE_EXPERIMENTAL_WORKSPACES: OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_WORKSPACES"), - OPENCODE_EXPERIMENTAL_SESSION_SWITCHING: OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_SESSION_SWITCHING"), // Evaluated at access time (not module load) because tests, the CLI, and // external tooling set these env vars at runtime. diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index 7d58782101d..af9df4d42f4 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -479,17 +479,15 @@ function App(props: { onSnapshot?: () => Promise }) { dialog.clear() }, }, - ...(Flag.OPENCODE_EXPERIMENTAL_SESSION_SWITCHING - ? Array.from({ length: 9 }, (_, i) => ({ - name: `session.quick_switch.${i + 1}`, - title: `Switch to session in quick slot ${i + 1}`, - category: "Session", - hidden: true, - run: () => { - local.session.quickSwitch(i + 1) - }, - })) - : []), + ...Array.from({ length: 9 }, (_, i) => ({ + name: `session.quick_switch.${i + 1}`, + title: `Switch to session in quick slot ${i + 1}`, + category: "Session", + hidden: true, + run: () => { + local.session.quickSwitch(i + 1) + }, + })), { name: "model.list", title: "Switch model", @@ -804,12 +802,7 @@ function App(props: { onSnapshot?: () => Promise }) { useBindings(() => ({ enabled: command.matcher, - bindings: tuiConfig.keybinds.gather( - "app", - Flag.OPENCODE_EXPERIMENTAL_SESSION_SWITCHING - ? appBindingCommands - : appBindingCommands.filter((c) => !c.startsWith("session.quick_switch")), - ), + bindings: tuiConfig.keybinds.gather("app", appBindingCommands), })) useBindings(() => ({ diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx index 9bc1d79e309..17653af6b9a 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx @@ -31,6 +31,8 @@ export function DialogSessionList() { const [toDelete, setToDelete] = createSignal() const [search, setSearch] = createDebouncedSignal("", 150) const deleteHint = useCommandShortcut("session.delete") + const quickSwitch1 = useCommandShortcut("session.quick_switch.1") + const quickSwitch9 = useCommandShortcut("session.quick_switch.9") const [searchResults, { refetch }] = createResource( () => ({ query: search(), filter: sync.session.query() }), @@ -130,8 +132,18 @@ export function DialogSessionList() { const [browseOrder] = createSignal(orderByRecency(sync.data.session)) + const quickSwitchHint = createMemo(() => { + const first = quickSwitch1() + const last = quickSwitch9() + if (!first || !last) return undefined + return quickSwitchRange(first, last) + }) + const quickSwitchFooterHints = createMemo(() => { + const hint = quickSwitchHint() + return hint && local.session.slots().length > 0 ? [{ title: "switch", label: hint }] : [] + }) + const options = createMemo(() => { - const enabled = Flag.OPENCODE_EXPERIMENTAL_SESSION_SWITCHING const today = new Date().toDateString() const sessionMap = new Map( sessions() @@ -142,11 +154,9 @@ export function DialogSessionList() { const searchResult = searchResults() const displayOrder = searchResult ? orderByRecency(searchResult) : browseOrder() - const pinned = enabled ? local.session.pinned().filter((id) => sessionMap.has(id)) : [] + const pinned = local.session.pinned().filter((id) => sessionMap.has(id)) const pinnedSet = new Set(pinned) - const slotByID = enabled - ? new Map(local.session.slots().map((id, i) => [id, i + 1])) - : new Map() + const slotByID = new Map(local.session.slots().map((id, i) => [id, i + 1])) function buildOption(id: string, category: string) { const x = sessionMap.get(id) @@ -224,17 +234,13 @@ export function DialogSessionList() { dialog.clear() }} actions={[ - ...(Flag.OPENCODE_EXPERIMENTAL_SESSION_SWITCHING - ? [ - { - command: "session.pin.toggle", - title: "pin/unpin", - onTrigger: (option: { value: string }) => { - local.session.togglePin(option.value) - }, - }, - ] - : []), + { + command: "session.pin.toggle", + title: "pin/unpin", + onTrigger: (option: { value: string }) => { + local.session.togglePin(option.value) + }, + }, { command: "session.delete", title: "delete", @@ -291,6 +297,13 @@ export function DialogSessionList() { }, }, ]} + footerHints={quickSwitchFooterHints()} /> ) } + +function quickSwitchRange(first: string, last: string) { + const prefix = first.slice(0, -1) + if (first.endsWith("1") && last === `${prefix}9`) return `${prefix}1-9` + return `${first} through ${last}` +} diff --git a/packages/opencode/src/cli/cmd/tui/feature-plugins/home/tips-view.tsx b/packages/opencode/src/cli/cmd/tui/feature-plugins/home/tips-view.tsx index f2a5f97f1e6..d3b880325d0 100644 --- a/packages/opencode/src/cli/cmd/tui/feature-plugins/home/tips-view.tsx +++ b/packages/opencode/src/cli/cmd/tui/feature-plugins/home/tips-view.tsx @@ -1,7 +1,6 @@ import type { TuiPluginApi } from "@opencode-ai/plugin/tui" import { createMemo, For, type Accessor } from "solid-js" import { DEFAULT_THEMES, useTheme } from "@tui/context/theme" -import { Flag } from "@opencode-ai/core/flag/flag" import { useCommandShortcut } from "../../keymap" const themeCount = Object.keys(DEFAULT_THEMES).length @@ -170,17 +169,12 @@ const TIPS: Tip[] = [ (shortcuts) => `Use ${commandText("/models", shortcuts.modelList())} to see and switch between available AI models`, (shortcuts) => `Use ${commandText("/themes", shortcuts.themeList())} to switch between ${themeCount} built-in themes`, (shortcuts) => `Use ${commandText("/new", shortcuts.sessionNew())} to start a fresh conversation session`, - (shortcuts) => `Use ${commandText("/sessions", shortcuts.sessionList())} to list and continue previous conversations`, - ...(Flag.OPENCODE_EXPERIMENTAL_SESSION_SWITCHING - ? ([ - (shortcuts) => - press(shortcuts.sessionPinToggle(), "in the session list to pin a session so it stays at the top"), - (shortcuts) => - shortcuts.sessionQuickSwitch1() && shortcuts.sessionQuickSwitch9() - ? `Pinned sessions are bound to ${shortcutText(shortcuts.sessionQuickSwitch1())} through ${shortcutText(shortcuts.sessionQuickSwitch9())} for one-press switching` - : undefined, - ] satisfies Tip[]) - : []), + (shortcuts) => `Use ${commandText("/sessions", shortcuts.sessionList())} to list, pin, and continue sessions`, + (shortcuts) => press(shortcuts.sessionPinToggle(), "in the session list to pin a session so it stays at the top"), + (shortcuts) => + shortcuts.sessionQuickSwitch1() && shortcuts.sessionQuickSwitch9() + ? `Pinned sessions are assigned quick slots; use ${shortcutText(shortcuts.sessionQuickSwitch1())} through ${shortcutText(shortcuts.sessionQuickSwitch9())} to switch` + : undefined, "Run {highlight}/compact{/highlight} to summarize long sessions near context limits", (shortcuts) => `Use ${commandText("/export", shortcuts.sessionExport())} to save the conversation as Markdown`, (shortcuts) => press(shortcuts.messagesCopy(), "to copy the assistant's last message to clipboard"), diff --git a/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx b/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx index a791aebc307..700735d38cb 100644 --- a/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx +++ b/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx @@ -38,6 +38,11 @@ export interface DialogSelectProps { disabled?: boolean onTrigger: (option: DialogSelectOption) => void }[] + footerHints?: { + title: string + label: string + side?: "left" | "right" + }[] bindings?: readonly Binding[] current?: T } @@ -334,11 +339,12 @@ export function DialogSelect(props: DialogSelectProps) { } props.ref?.(ref) - const visibleActions = createMemo(() => - actions() + const visibleActions = createMemo(() => [ + ...actions() .map((item) => ({ ...item, label: actionLabels().get(item.command) ?? "" })) .filter((item) => !item.disabled && item.label), - ) + ...(props.footerHints ?? []), + ]) const left = createMemo(() => visibleActions().filter((item) => item.side !== "right")) const right = createMemo(() => visibleActions().filter((item) => item.side === "right")) From 499e8e4b78ea0829c721fb0f4baf23b325a0e0bb Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Sat, 16 May 2026 01:17:37 +0530 Subject: [PATCH 050/164] test(instance): add effect-native fixture helpers (#27781) --- .../test/effect/instance-state.test.ts | 44 ++++++++++--------- packages/opencode/test/fixture/fixture.ts | 19 +++++--- 2 files changed, 37 insertions(+), 26 deletions(-) diff --git a/packages/opencode/test/effect/instance-state.test.ts b/packages/opencode/test/effect/instance-state.test.ts index 23cd51d7f35..b47b740cd46 100644 --- a/packages/opencode/test/effect/instance-state.test.ts +++ b/packages/opencode/test/effect/instance-state.test.ts @@ -1,15 +1,21 @@ -import { afterEach, expect } from "bun:test" +import { expect } from "bun:test" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { $ } from "bun" import { Context, Deferred, Duration, Effect, Exit, Fiber, Layer } from "effect" import { InstanceState } from "@/effect/instance-state" -import { disposeAllInstances, provideInstance, reloadTestInstance, tmpdirScoped } from "../fixture/fixture" +import { + disposeAllInstancesEffect, + provideInstanceEffect, + reloadInstance, + testInstanceStoreLayer, + tmpdirScoped, +} from "../fixture/fixture" import { testEffect } from "../lib/effect" -const it = testEffect(CrossSpawnSpawner.defaultLayer) +const it = testEffect(Layer.mergeAll(CrossSpawnSpawner.defaultLayer, testInstanceStoreLayer)) const access = (state: InstanceState.InstanceState, dir: string) => - InstanceState.get(state).pipe(provideInstance(dir)) + InstanceState.get(state).pipe(provideInstanceEffect(dir)) const tmpdirGitScoped = Effect.gen(function* () { const dir = yield* tmpdirScoped({ git: true }) @@ -17,10 +23,6 @@ const tmpdirGitScoped = Effect.gen(function* () { return dir }) -afterEach(async () => { - await disposeAllInstances() -}) - it.live("InstanceState caches values per directory", () => Effect.gen(function* () { const dir = yield* tmpdirScoped() @@ -68,7 +70,7 @@ it.live("InstanceState invalidates on reload", () => ) const a = yield* access(state, dir) - yield* Effect.promise(() => reloadTestInstance({ directory: dir })) + yield* reloadInstance({ directory: dir }) const b = yield* access(state, dir) expect(a).not.toBe(b) @@ -93,7 +95,7 @@ it.live("InstanceState invalidates on disposeAll", () => yield* access(state, one) yield* access(state, two) - yield* Effect.promise(disposeAllInstances) + yield* disposeAllInstancesEffect expect(seen.sort()).toEqual([one, two].sort()) }), @@ -125,8 +127,8 @@ it.live("InstanceState.get reads the current directory lazily", () => } yield* Effect.gen(function* () { - const a = yield* Test.use((svc) => svc.get()).pipe(provideInstance(one)) - const b = yield* Test.use((svc) => svc.get()).pipe(provideInstance(two)) + const a = yield* Test.use((svc) => svc.get()).pipe(provideInstanceEffect(one)) + const b = yield* Test.use((svc) => svc.get()).pipe(provideInstanceEffect(two)) expect(a).toBe(one) expect(b).toBe(two) @@ -177,7 +179,7 @@ it.live("InstanceState preserves directory across async boundaries", () => yield* Effect.gen(function* () { const [a, b, c] = yield* Effect.all( - [one, two, three].map((dir) => Test.use((svc) => svc.get()).pipe(provideInstance(dir))), + [one, two, three].map((dir) => Test.use((svc) => svc.get()).pipe(provideInstanceEffect(dir))), { concurrency: "unbounded" }, ) @@ -224,7 +226,7 @@ it.live("InstanceState survives high-contention concurrent access", () => yield* Effect.gen(function* () { const results = yield* Effect.all( - dirs.map((dir) => Test.use((svc) => svc.get()).pipe(provideInstance(dir))), + dirs.map((dir) => Test.use((svc) => svc.get()).pipe(provideInstanceEffect(dir))), { concurrency: "unbounded" }, ) @@ -263,19 +265,19 @@ it.live("InstanceState correct after interleaved init and dispose", () => } yield* Effect.gen(function* () { - const a = yield* Test.use((svc) => svc.get()).pipe(provideInstance(one)) + const a = yield* Test.use((svc) => svc.get()).pipe(provideInstanceEffect(one)) expect(a).toBe(one) const [, b] = yield* Effect.all( [ - Effect.promise(() => reloadTestInstance({ directory: one })), - Test.use((svc) => svc.get()).pipe(provideInstance(two)), + reloadInstance({ directory: one }), + Test.use((svc) => svc.get()).pipe(provideInstanceEffect(two)), ], { concurrency: "unbounded" }, ) expect(b).toBe(two) - const c = yield* Test.use((svc) => svc.get()).pipe(provideInstance(one)) + const c = yield* Test.use((svc) => svc.get()).pipe(provideInstanceEffect(one)) expect(c).toBe(one) }).pipe(Effect.provide(Test.layer)) }), @@ -343,9 +345,9 @@ it.live("InstanceState survives deferred resume from the same instance context", yield* Effect.gen(function* () { const gate = yield* Deferred.make() - const fiber = yield* Test.use((svc) => svc.get(gate)).pipe(provideInstance(dir), Effect.forkScoped) + const fiber = yield* Test.use((svc) => svc.get(gate)).pipe(provideInstanceEffect(dir), Effect.forkScoped) - yield* Deferred.succeed(gate, undefined).pipe(provideInstance(dir)) + yield* Deferred.succeed(gate, undefined).pipe(provideInstanceEffect(dir)) const exit = yield* Fiber.await(fiber) expect(Exit.isSuccess(exit)).toBe(true) @@ -380,7 +382,7 @@ it.live("InstanceState survives deferred resume outside ALS when InstanceRef is yield* Effect.gen(function* () { const gate = yield* Deferred.make() - const fiber = yield* Test.use((svc) => svc.get(gate)).pipe(provideInstance(dir), Effect.forkScoped) + const fiber = yield* Test.use((svc) => svc.get(gate)).pipe(provideInstanceEffect(dir), Effect.forkScoped) yield* Deferred.succeed(gate, undefined) const exit = yield* Fiber.await(fiber) diff --git a/packages/opencode/test/fixture/fixture.ts b/packages/opencode/test/fixture/fixture.ts index 51b62949759..9653e5888d9 100644 --- a/packages/opencode/test/fixture/fixture.ts +++ b/packages/opencode/test/fixture/fixture.ts @@ -17,8 +17,9 @@ import { InstanceStore } from "../../src/project/instance-store" import { TestLLMServer } from "../lib/llm-server" const noopBootstrap = Layer.succeed(InstanceBootstrap.Service, InstanceBootstrap.Service.of({ run: Effect.void })) +export const testInstanceStoreLayer = InstanceStore.defaultLayer.pipe(Layer.provide(noopBootstrap)) const testInstanceRuntime = ManagedRuntime.make( - InstanceStore.defaultLayer.pipe(Layer.provide(noopBootstrap), Layer.provideMerge(Observability.layer)), + testInstanceStoreLayer.pipe(Layer.provideMerge(Observability.layer)), ) const runTestInstanceStore = (fn: (store: InstanceStore.Interface) => Effect.Effect) => @@ -165,6 +166,16 @@ export const provideInstance = }), ) +export const provideInstanceEffect = + (directory: string) => + (self: Effect.Effect): Effect.Effect => + InstanceStore.Service.use((store) => store.provide({ directory }, self)) + +export const reloadInstance = (input: InstanceStore.LoadInput) => + InstanceStore.Service.use((store) => store.reload(input)) + +export const disposeAllInstancesEffect = InstanceStore.Service.use((store) => store.disposeAll()) + export function provideTmpdirInstance( self: (path: string) => Effect.Effect, options?: { git?: boolean; config?: Partial }, @@ -195,11 +206,9 @@ export const withTmpdirInstance = (self: Effect.Effect) => Effect.gen(function* () { const directory = yield* tmpdirScoped(options) - return yield* InstanceStore.Service.use((store) => - store.provide({ directory }, self.pipe(Effect.provideService(TestInstance, { directory }))), - ) + return yield* self.pipe(Effect.provideService(TestInstance, { directory }), provideInstanceEffect(directory)) }).pipe( - Effect.provide(InstanceStore.defaultLayer.pipe(Layer.provide(noopBootstrap))), + Effect.provide(testInstanceStoreLayer), Effect.provide(CrossSpawnSpawner.defaultLayer), ) From 0df2f5b45f341b29495fdd6a4e71540e2f129871 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Fri, 15 May 2026 19:48:45 +0000 Subject: [PATCH 051/164] chore: generate --- packages/opencode/test/effect/instance-state.test.ts | 5 +---- packages/opencode/test/fixture/fixture.ts | 9 ++------- 2 files changed, 3 insertions(+), 11 deletions(-) diff --git a/packages/opencode/test/effect/instance-state.test.ts b/packages/opencode/test/effect/instance-state.test.ts index b47b740cd46..d983de89a85 100644 --- a/packages/opencode/test/effect/instance-state.test.ts +++ b/packages/opencode/test/effect/instance-state.test.ts @@ -269,10 +269,7 @@ it.live("InstanceState correct after interleaved init and dispose", () => expect(a).toBe(one) const [, b] = yield* Effect.all( - [ - reloadInstance({ directory: one }), - Test.use((svc) => svc.get()).pipe(provideInstanceEffect(two)), - ], + [reloadInstance({ directory: one }), Test.use((svc) => svc.get()).pipe(provideInstanceEffect(two))], { concurrency: "unbounded" }, ) expect(b).toBe(two) diff --git a/packages/opencode/test/fixture/fixture.ts b/packages/opencode/test/fixture/fixture.ts index 9653e5888d9..0ca559a2c66 100644 --- a/packages/opencode/test/fixture/fixture.ts +++ b/packages/opencode/test/fixture/fixture.ts @@ -18,9 +18,7 @@ import { TestLLMServer } from "../lib/llm-server" const noopBootstrap = Layer.succeed(InstanceBootstrap.Service, InstanceBootstrap.Service.of({ run: Effect.void })) export const testInstanceStoreLayer = InstanceStore.defaultLayer.pipe(Layer.provide(noopBootstrap)) -const testInstanceRuntime = ManagedRuntime.make( - testInstanceStoreLayer.pipe(Layer.provideMerge(Observability.layer)), -) +const testInstanceRuntime = ManagedRuntime.make(testInstanceStoreLayer.pipe(Layer.provideMerge(Observability.layer))) const runTestInstanceStore = (fn: (store: InstanceStore.Interface) => Effect.Effect) => testInstanceRuntime.runPromise(InstanceStore.Service.use(fn)) @@ -207,10 +205,7 @@ export const withTmpdirInstance = Effect.gen(function* () { const directory = yield* tmpdirScoped(options) return yield* self.pipe(Effect.provideService(TestInstance, { directory }), provideInstanceEffect(directory)) - }).pipe( - Effect.provide(testInstanceStoreLayer), - Effect.provide(CrossSpawnSpawner.defaultLayer), - ) + }).pipe(Effect.provide(testInstanceStoreLayer), Effect.provide(CrossSpawnSpawner.defaultLayer)) export function provideTmpdirServer( self: (input: { dir: string; llm: TestLLMServer["Service"] }) => Effect.Effect, From 48122b31cc3dcdb38609e442c459534ac7c0999a Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Fri, 15 May 2026 14:50:21 -0500 Subject: [PATCH 052/164] fix(tool): bridge custom tool zod metadata (#27770) --- packages/opencode/src/tool/registry.ts | 37 +++++++++++++++----- packages/opencode/test/tool/registry.test.ts | 4 +-- packages/plugin/src/tool.ts | 8 +---- 3 files changed, 31 insertions(+), 18 deletions(-) diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index 8d4dd5440a7..879855c366c 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -145,14 +145,7 @@ export const layer: Layer.Layer< const entries = Object.entries(def.args) const allZod = entries.every((entry) => isZodType(entry[1])) const zodParams = allZod ? z.object(def.args) : undefined - // Newer @opencode-ai/plugin versions precompute JSON Schema with the - // Zod instance that owns arg metadata. Fall back for older/manual - // custom tools that only expose raw Zod args. - const jsonSchema = zodParams - ? isJsonSchemaDefinition(def.jsonSchema) - ? (def.jsonSchema as JSONSchema7) - : zodJsonSchema(zodParams) - : legacyJsonSchema(entries) + const jsonSchema = zodParams ? zodJsonSchema(zodParams) : legacyJsonSchema(entries) const parameters = zodParams ? Schema.declare((u): u is unknown => zodParams.safeParse(u).success) : Schema.Unknown @@ -424,7 +417,7 @@ function legacyJsonSchema(entries: [string, unknown][]): JSONSchema7 { } function zodJsonSchema(schema: z.ZodType): JSONSchema7 { - const result = normalizeZodJsonSchema(z.toJSONSchema(schema, { io: "input" })) + const result = normalizeZodJsonSchema(z.toJSONSchema(schema, { io: "input", metadata: zodMetadataRegistry(schema) })) if (!isJsonSchemaObject(result)) throw new Error("plugin tool Zod schema produced a non-object JSON Schema") const { $defs, ...rest } = result return ( @@ -432,6 +425,32 @@ function zodJsonSchema(schema: z.ZodType): JSONSchema7 { ) as JSONSchema7 } +function zodMetadataRegistry(schema: z.ZodType) { + const registry = z.registry>() + const seen = new WeakSet() + const collect = (value: unknown) => { + if (typeof value !== "object" || value === null) return + if (seen.has(value)) return + seen.add(value) + + if (isZodType(value)) { + const metadata = typeof value.meta === "function" ? value.meta() : undefined + const description = typeof value.description === "string" ? value.description : undefined + const merged = { + ...(metadata && typeof metadata === "object" ? metadata : {}), + ...(description ? { description } : {}), + } + if (Object.keys(merged).length) registry.add(value, merged) + collect(value._zod.def) + return + } + + for (const item of Object.values(value)) collect(item) + } + collect(schema) + return registry +} + function normalizeZodJsonSchema(value: unknown): unknown { if (Array.isArray(value)) return value.map((item) => normalizeZodJsonSchema(item)) if (typeof value !== "object" || value === null) return value diff --git a/packages/opencode/test/tool/registry.test.ts b/packages/opencode/test/tool/registry.test.ts index ce7f89b35b6..c4267042953 100644 --- a/packages/opencode/test/tool/registry.test.ts +++ b/packages/opencode/test/tool/registry.test.ts @@ -264,7 +264,7 @@ describe("tool.registry", () => { ) it.instance( - "preserves Zod arg descriptions from config-scoped plugin packages", + "preserves Zod arg descriptions from older config-scoped plugin packages", () => Effect.gen(function* () { const test = yield* TestInstance @@ -291,7 +291,7 @@ describe("tool.registry", () => { [ "import { z } from 'zod'", "export function tool(input) {", - " return { ...input, jsonSchema: z.toJSONSchema(z.object(input.args), { target: 'draft-7', io: 'input' }) }", + " return input", "}", "tool.schema = z", "", diff --git a/packages/plugin/src/tool.ts b/packages/plugin/src/tool.ts index daf6b0bbd70..b8a634c7967 100644 --- a/packages/plugin/src/tool.ts +++ b/packages/plugin/src/tool.ts @@ -48,13 +48,7 @@ export function tool(input: { args: Args execute(args: z.infer>, context: ToolContext): Promise }) { - return { - ...input, - // Generate JSON Schema here with the same Zod instance that created - // `tool.schema` args. Zod metadata such as `.describe()` is stored in a - // module-local registry, so converting later from opencode can lose it. - jsonSchema: z.toJSONSchema(z.object(input.args), { target: "draft-7", io: "input" }), - } + return input } tool.schema = z From 65f96a585147bdc36937d8aa8249ad05f2e1e404 Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Sat, 16 May 2026 01:30:07 +0530 Subject: [PATCH 053/164] refactor(instance): retire WithInstance adapter (#27782) --- packages/opencode/src/cli/cmd/tui/worker.ts | 9 +- .../opencode/src/project/with-instance.ts | 12 -- .../opencode/test/EFFECT_TEST_MIGRATION.md | 2 +- packages/opencode/test/config/config.test.ts | 111 +++++++------ packages/opencode/test/fixture/fixture.ts | 4 + packages/opencode/test/lsp/client.test.ts | 27 ++- .../test/provider/amazon-bedrock.test.ts | 23 ++- .../opencode/test/provider/gitlab-duo.test.ts | 29 ++-- .../opencode/test/provider/provider.test.ts | 155 +++++++++--------- packages/opencode/test/session/llm.test.ts | 19 +-- 10 files changed, 186 insertions(+), 205 deletions(-) delete mode 100644 packages/opencode/src/project/with-instance.ts diff --git a/packages/opencode/src/cli/cmd/tui/worker.ts b/packages/opencode/src/cli/cmd/tui/worker.ts index 90ff2b4d4f5..31ead18cf84 100644 --- a/packages/opencode/src/cli/cmd/tui/worker.ts +++ b/packages/opencode/src/cli/cmd/tui/worker.ts @@ -2,7 +2,6 @@ import { Installation } from "@/installation" import { Server } from "@/server/server" import * as Log from "@opencode-ai/core/util/log" import { InstanceRuntime } from "@/project/instance-runtime" -import { WithInstance } from "@/project/with-instance" import { Rpc } from "@/util/rpc" import { upgrade } from "@/cli/upgrade" import { Config } from "@/config/config" @@ -77,12 +76,8 @@ export const rpc = { return { url: server.url.toString() } }, async checkUpgrade(input: { directory: string }) { - await WithInstance.provide({ - directory: input.directory, - fn: async () => { - await upgrade().catch(() => {}) - }, - }) + await InstanceRuntime.load({ directory: input.directory }) + await upgrade().catch(() => {}) }, async reload() { await AppRuntime.runPromise( diff --git a/packages/opencode/src/project/with-instance.ts b/packages/opencode/src/project/with-instance.ts deleted file mode 100644 index 27360736a9a..00000000000 --- a/packages/opencode/src/project/with-instance.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { AppRuntime } from "@/effect/app-runtime" -import type { InstanceContext } from "./instance-context" -import { InstanceStore } from "./instance-store" - -export async function provide(input: { directory: string; fn: (ctx: InstanceContext) => R }): Promise { - const ctx = await AppRuntime.runPromise( - InstanceStore.Service.use((store) => store.load({ directory: input.directory })), - ) - return input.fn(ctx) -} - -export * as WithInstance from "./with-instance" diff --git a/packages/opencode/test/EFFECT_TEST_MIGRATION.md b/packages/opencode/test/EFFECT_TEST_MIGRATION.md index 73acea91489..893aa922b76 100644 --- a/packages/opencode/test/EFFECT_TEST_MIGRATION.md +++ b/packages/opencode/test/EFFECT_TEST_MIGRATION.md @@ -149,7 +149,7 @@ Do not maintain a long file checklist here. It goes stale quickly. When looking for the next target, search for current anti-patterns: ```bash -git grep -n "Effect.runPromise\|ManagedRuntime\|Promise.withResolvers\|Bun.sleep\|WithInstance" -- packages/opencode/test +git grep -n "Effect.runPromise\|ManagedRuntime\|Promise.withResolvers\|Bun.sleep\|withTestInstance" -- packages/opencode/test ``` Then choose one file or one small cluster, keep the PR focused, and mention diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts index e270b1e3633..4d3ed45d2fc 100644 --- a/packages/opencode/test/config/config.test.ts +++ b/packages/opencode/test/config/config.test.ts @@ -8,13 +8,12 @@ import { EffectFlock } from "@opencode-ai/core/util/effect-flock" import { InstanceRef } from "../../src/effect/instance-ref" import type { InstanceContext } from "../../src/project/instance-context" -import { WithInstance } from "../../src/project/with-instance" import { Auth } from "../../src/auth" import { Account } from "../../src/account/account" import { AccessToken, AccountID, OrgID } from "../../src/account/schema" import { AppFileSystem } from "@opencode-ai/core/filesystem" import { Env } from "../../src/env" -import { provideTestInstance, provideTmpdirInstance } from "../fixture/fixture" +import { provideTestInstance, provideTmpdirInstance, withTestInstance } from "../fixture/fixture" import { tmpdir } from "../fixture/fixture" import { InstanceRuntime } from "@/project/instance-runtime" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" @@ -136,7 +135,7 @@ async function check(map: (dir: string) => string) { $schema: "https://opencode.ai/config.json", snapshot: false, }) - await WithInstance.provide({ + await withTestInstance({ directory: map(tmp.path), fn: async (ctx) => { const cfg = await load(ctx) @@ -154,7 +153,7 @@ async function check(map: (dir: string) => string) { test("loads config with defaults when no files exist", async () => { await using tmp = await tmpdir() - await WithInstance.provide({ + await withTestInstance({ directory: tmp.path, fn: async (ctx) => { const config = await load(ctx) @@ -170,7 +169,7 @@ test("creates global jsonc config with schema when no global configs exist", asy await clear(true) try { - await WithInstance.provide({ + await withTestInstance({ directory: tmp.path, fn: async (ctx) => { await load(ctx) @@ -195,7 +194,7 @@ test("does not create global config when OPENCODE_CONFIG_DIR is set", async () = await clear(true) try { - await WithInstance.provide({ + await withTestInstance({ directory: tmp.path, fn: async (ctx) => { await load(ctx) @@ -221,7 +220,7 @@ test("loads JSON config file", async () => { }) }, }) - await WithInstance.provide({ + await withTestInstance({ directory: tmp.path, fn: async (ctx) => { const config = await load(ctx) @@ -240,7 +239,7 @@ test("loads shell config field", async () => { }) }, }) - await WithInstance.provide({ + await withTestInstance({ directory: tmp.path, fn: async (ctx) => { const config = await load(ctx) @@ -262,7 +261,7 @@ test("updates config and preserves empty shell sentinel", async () => { ) }, }) - await WithInstance.provide({ + await withTestInstance({ directory: tmp.path, fn: async (ctx) => { await save({ shell: "" }, ctx) @@ -340,7 +339,7 @@ test("loads formatter boolean config", async () => { }) }, }) - await WithInstance.provide({ + await withTestInstance({ directory: tmp.path, fn: async (ctx) => { const config = await load(ctx) @@ -358,7 +357,7 @@ test("loads lsp boolean config", async () => { }) }, }) - await WithInstance.provide({ + await withTestInstance({ directory: tmp.path, fn: async (ctx) => { const config = await load(ctx) @@ -395,7 +394,7 @@ test("ignores legacy tui keys in opencode config", async () => { }) }, }) - await WithInstance.provide({ + await withTestInstance({ directory: tmp.path, fn: async (ctx) => { const config = await load(ctx) @@ -420,7 +419,7 @@ test("loads JSONC config file", async () => { ) }, }) - await WithInstance.provide({ + await withTestInstance({ directory: tmp.path, fn: async (ctx) => { const config = await load(ctx) @@ -448,7 +447,7 @@ test("jsonc overrides json in the same directory", async () => { }) }, }) - await WithInstance.provide({ + await withTestInstance({ directory: tmp.path, fn: async (ctx) => { const config = await load(ctx) @@ -471,7 +470,7 @@ test("handles environment variable substitution", async () => { }) }, }) - await WithInstance.provide({ + await withTestInstance({ directory: tmp.path, fn: async (ctx) => { const config = await load(ctx) @@ -503,7 +502,7 @@ test("preserves env variables when adding $schema to config", async () => { ) }, }) - await WithInstance.provide({ + await withTestInstance({ directory: tmp.path, fn: async (ctx) => { const config = await load(ctx) @@ -600,7 +599,7 @@ test("handles file inclusion substitution", async () => { }) }, }) - await WithInstance.provide({ + await withTestInstance({ directory: tmp.path, fn: async (ctx) => { const config = await load(ctx) @@ -619,7 +618,7 @@ test("handles file inclusion with replacement tokens", async () => { }) }, }) - await WithInstance.provide({ + await withTestInstance({ directory: tmp.path, fn: async (ctx) => { const config = await load(ctx) @@ -675,7 +674,7 @@ test("handles agent configuration", async () => { }) }, }) - await WithInstance.provide({ + await withTestInstance({ directory: tmp.path, fn: async (ctx) => { const config = await load(ctx) @@ -706,7 +705,7 @@ test("treats agent variant as model-scoped setting (not provider option)", async }, }) - await WithInstance.provide({ + await withTestInstance({ directory: tmp.path, fn: async (ctx) => { const config = await load(ctx) @@ -736,7 +735,7 @@ test("handles command configuration", async () => { }) }, }) - await WithInstance.provide({ + await withTestInstance({ directory: tmp.path, fn: async (ctx) => { const config = await load(ctx) @@ -761,7 +760,7 @@ test("migrates autoshare to share field", async () => { ) }, }) - await WithInstance.provide({ + await withTestInstance({ directory: tmp.path, fn: async (ctx) => { const config = await load(ctx) @@ -788,7 +787,7 @@ test("migrates mode field to agent field", async () => { ) }, }) - await WithInstance.provide({ + await withTestInstance({ directory: tmp.path, fn: async (ctx) => { const config = await load(ctx) @@ -820,7 +819,7 @@ Test agent prompt`, ) }, }) - await WithInstance.provide({ + await withTestInstance({ directory: tmp.path, fn: async (ctx) => { const config = await load(ctx) @@ -853,7 +852,7 @@ Ordered permissions`, ) }, }) - await WithInstance.provide({ + await withTestInstance({ directory: tmp.path, fn: async (ctx) => { const config = await load(ctx) @@ -891,7 +890,7 @@ Nested agent prompt`, }, }) - await WithInstance.provide({ + await withTestInstance({ directory: tmp.path, fn: async (ctx) => { const config = await load(ctx) @@ -940,7 +939,7 @@ Nested command template`, }, }) - await WithInstance.provide({ + await withTestInstance({ directory: tmp.path, fn: async (ctx) => { const config = await load(ctx) @@ -985,7 +984,7 @@ Nested command template`, }, }) - await WithInstance.provide({ + await withTestInstance({ directory: tmp.path, fn: async (ctx) => { const config = await load(ctx) @@ -1005,7 +1004,7 @@ Nested command template`, test("updates config and writes to file", async () => { await using tmp = await tmpdir() - await WithInstance.provide({ + await withTestInstance({ directory: tmp.path, fn: async (ctx) => { const newConfig = { model: "updated/model" } @@ -1019,7 +1018,7 @@ test("updates config and writes to file", async () => { test("gets config directories", async () => { await using tmp = await tmpdir() - await WithInstance.provide({ + await withTestInstance({ directory: tmp.path, fn: async (ctx) => { const dirs = await listDirs(ctx) @@ -1049,7 +1048,7 @@ test("does not try to install dependencies in read-only OPENCODE_CONFIG_DIR", as process.env.OPENCODE_CONFIG_DIR = tmp.extra try { - await WithInstance.provide({ + await withTestInstance({ directory: tmp.path, fn: async (ctx) => { await load(ctx) @@ -1084,7 +1083,7 @@ test("installs dependencies in writable OPENCODE_CONFIG_DIR", async () => { ) try { - await WithInstance.provide({ + await withTestInstance({ directory: tmp.path, fn: async (ctx) => { await Effect.runPromise( @@ -1225,7 +1224,7 @@ Helper subagent prompt`, ) }, }) - await WithInstance.provide({ + await withTestInstance({ directory: tmp.path, fn: async (ctx) => { const config = await load(ctx) @@ -1264,7 +1263,7 @@ test("merges instructions arrays from global and local configs", async () => { }, }) - await WithInstance.provide({ + await withTestInstance({ directory: path.join(tmp.path, "project"), fn: async (ctx) => { const config = await load(ctx) @@ -1303,7 +1302,7 @@ test("deduplicates duplicate instructions from global and local configs", async }, }) - await WithInstance.provide({ + await withTestInstance({ directory: path.join(tmp.path, "project"), fn: async (ctx) => { const config = await load(ctx) @@ -1438,7 +1437,7 @@ test("migrates legacy tools config to permissions - allow", async () => { ) }, }) - await WithInstance.provide({ + await withTestInstance({ directory: tmp.path, fn: async (ctx) => { const config = await load(ctx) @@ -1469,7 +1468,7 @@ test("migrates legacy tools config to permissions - deny", async () => { ) }, }) - await WithInstance.provide({ + await withTestInstance({ directory: tmp.path, fn: async (ctx) => { const config = await load(ctx) @@ -1499,7 +1498,7 @@ test("migrates legacy write tool to edit permission", async () => { ) }, }) - await WithInstance.provide({ + await withTestInstance({ directory: tmp.path, fn: async (ctx) => { const config = await load(ctx) @@ -1531,7 +1530,7 @@ test("managed settings override user settings", async () => { share: "disabled", }) - await WithInstance.provide({ + await withTestInstance({ directory: tmp.path, fn: async (ctx) => { const config = await load(ctx) @@ -1559,7 +1558,7 @@ test("managed settings override project settings", async () => { disabled_providers: ["openai"], }) - await WithInstance.provide({ + await withTestInstance({ directory: tmp.path, fn: async (ctx) => { const config = await load(ctx) @@ -1579,7 +1578,7 @@ test("missing managed settings file is not an error", async () => { }, }) - await WithInstance.provide({ + await withTestInstance({ directory: tmp.path, fn: async (ctx) => { const config = await load(ctx) @@ -1606,7 +1605,7 @@ test("migrates legacy edit tool to edit permission", async () => { ) }, }) - await WithInstance.provide({ + await withTestInstance({ directory: tmp.path, fn: async (ctx) => { const config = await load(ctx) @@ -1635,7 +1634,7 @@ test("migrates legacy patch tool to edit permission", async () => { ) }, }) - await WithInstance.provide({ + await withTestInstance({ directory: tmp.path, fn: async (ctx) => { const config = await load(ctx) @@ -1667,7 +1666,7 @@ test("migrates mixed legacy tools config", async () => { ) }, }) - await WithInstance.provide({ + await withTestInstance({ directory: tmp.path, fn: async (ctx) => { const config = await load(ctx) @@ -1702,7 +1701,7 @@ test("merges legacy tools with existing permission config", async () => { ) }, }) - await WithInstance.provide({ + await withTestInstance({ directory: tmp.path, fn: async (ctx) => { const config = await load(ctx) @@ -1739,7 +1738,7 @@ test("permission config preserves user key order", async () => { ) }, }) - await WithInstance.provide({ + await withTestInstance({ directory: tmp.path, fn: async (ctx) => { const config = await load(ctx) @@ -1822,7 +1821,7 @@ test("project config can override MCP server enabled status", async () => { ) }, }) - await WithInstance.provide({ + await withTestInstance({ directory: tmp.path, fn: async (ctx) => { const config = await load(ctx) @@ -1878,7 +1877,7 @@ test("MCP config deep merges preserving base config properties", async () => { ) }, }) - await WithInstance.provide({ + await withTestInstance({ directory: tmp.path, fn: async (ctx) => { const config = await load(ctx) @@ -1929,7 +1928,7 @@ test("local .opencode config can override MCP from project config", async () => ) }, }) - await WithInstance.provide({ + await withTestInstance({ directory: tmp.path, fn: async (ctx) => { const config = await load(ctx) @@ -2295,7 +2294,7 @@ describe("OPENCODE_DISABLE_PROJECT_CONFIG", () => { ) }, }) - await WithInstance.provide({ + await withTestInstance({ directory: tmp.path, fn: async (ctx) => { const config = await load(ctx) @@ -2326,7 +2325,7 @@ describe("OPENCODE_DISABLE_PROJECT_CONFIG", () => { await Filesystem.write(path.join(opencodeDir, "test-cmd.md"), "# Test Command\nThis is a test command.") }, }) - await WithInstance.provide({ + await withTestInstance({ directory: tmp.path, fn: async (ctx) => { const directories = await listDirs(ctx) @@ -2350,7 +2349,7 @@ describe("OPENCODE_DISABLE_PROJECT_CONFIG", () => { try { await using tmp = await tmpdir() - await WithInstance.provide({ + await withTestInstance({ directory: tmp.path, fn: async (ctx) => { // Should still get default config (from global or defaults) @@ -2392,7 +2391,7 @@ describe("OPENCODE_DISABLE_PROJECT_CONFIG", () => { }, }) - await WithInstance.provide({ + await withTestInstance({ directory: tmp.path, fn: async (ctx) => { // The relative instruction should be skipped without error @@ -2452,7 +2451,7 @@ describe("OPENCODE_DISABLE_PROJECT_CONFIG", () => { process.env["OPENCODE_DISABLE_PROJECT_CONFIG"] = "true" process.env["OPENCODE_CONFIG_DIR"] = configDirTmp.path - await WithInstance.provide({ + await withTestInstance({ directory: projectTmp.path, fn: async (ctx) => { const config = await load(ctx) @@ -2487,7 +2486,7 @@ describe("OPENCODE_CONFIG_CONTENT token substitution", () => { try { await using tmp = await tmpdir() - await WithInstance.provide({ + await withTestInstance({ directory: tmp.path, fn: async (ctx) => { const config = await load(ctx) @@ -2521,7 +2520,7 @@ describe("OPENCODE_CONFIG_CONTENT token substitution", () => { }) }, }) - await WithInstance.provide({ + await withTestInstance({ directory: tmp.path, fn: async (ctx) => { const config = await load(ctx) diff --git a/packages/opencode/test/fixture/fixture.ts b/packages/opencode/test/fixture/fixture.ts index 0ca559a2c66..28b74a9189a 100644 --- a/packages/opencode/test/fixture/fixture.ts +++ b/packages/opencode/test/fixture/fixture.ts @@ -37,6 +37,10 @@ export async function provideTestInstance(input: { } } +export async function withTestInstance(input: { directory: string; fn: (ctx: InstanceContext) => R }) { + return input.fn(await runTestInstanceStore((store) => store.load({ directory: input.directory }))) +} + export async function reloadTestInstance(input: { directory: string }) { return runTestInstanceStore((store) => store.reload(input)) } diff --git a/packages/opencode/test/lsp/client.test.ts b/packages/opencode/test/lsp/client.test.ts index 1897e6537d6..93844d942fc 100644 --- a/packages/opencode/test/lsp/client.test.ts +++ b/packages/opencode/test/lsp/client.test.ts @@ -1,10 +1,9 @@ import { beforeEach, describe, expect, test } from "bun:test" import path from "path" import { pathToFileURL } from "url" -import { tmpdir } from "../fixture/fixture" +import { tmpdir, withTestInstance } from "../fixture/fixture" import { LSPClient } from "@/lsp/client" import * as LSPServer from "@/lsp/server" -import { WithInstance } from "../../src/project/with-instance" import * as Log from "@opencode-ai/core/util/log" function spawnFakeServer() { @@ -25,7 +24,7 @@ describe("LSPClient interop", () => { test("handles workspace/workspaceFolders request", async () => { const handle = spawnFakeServer() as any - const client = await WithInstance.provide({ + const client = await withTestInstance({ directory: process.cwd(), fn: (ctx) => LSPClient.create({ @@ -49,7 +48,7 @@ describe("LSPClient interop", () => { test("handles client/registerCapability request", async () => { const handle = spawnFakeServer() as any - const client = await WithInstance.provide({ + const client = await withTestInstance({ directory: process.cwd(), fn: (ctx) => LSPClient.create({ @@ -73,7 +72,7 @@ describe("LSPClient interop", () => { test("handles client/unregisterCapability request", async () => { const handle = spawnFakeServer() as any - const client = await WithInstance.provide({ + const client = await withTestInstance({ directory: process.cwd(), fn: (ctx) => LSPClient.create({ @@ -97,7 +96,7 @@ describe("LSPClient interop", () => { test("initialize does not overclaim unsupported diagnostics capabilities", async () => { const handle = spawnFakeServer() as any - const client = await WithInstance.provide({ + const client = await withTestInstance({ directory: process.cwd(), fn: (ctx) => LSPClient.create({ @@ -125,7 +124,7 @@ describe("LSPClient interop", () => { gamma: true, } - const client = await WithInstance.provide({ + const client = await withTestInstance({ directory: process.cwd(), fn: (ctx) => LSPClient.create({ @@ -155,7 +154,7 @@ describe("LSPClient interop", () => { const file = path.join(tmp.path, "client.ts") await Bun.write(file, "first\n") - await WithInstance.provide({ + await withTestInstance({ directory: tmp.path, fn: async (ctx) => { const client = await LSPClient.create({ @@ -199,7 +198,7 @@ describe("LSPClient interop", () => { const file = path.join(tmp.path, "client.ts") await Bun.write(file, "const x = 1\n") - await WithInstance.provide({ + await withTestInstance({ directory: tmp.path, fn: async (ctx) => { const client = await LSPClient.create({ @@ -246,7 +245,7 @@ describe("LSPClient interop", () => { const file = path.join(tmp.path, "client.ts") await Bun.write(file, "const x = 1\n") - await WithInstance.provide({ + await withTestInstance({ directory: tmp.path, fn: async (ctx) => { const client = await LSPClient.create({ @@ -294,7 +293,7 @@ describe("LSPClient interop", () => { const file = path.join(tmp.path, "client.cs") await Bun.write(file, "class C {}\n") - await WithInstance.provide({ + await withTestInstance({ directory: tmp.path, fn: async (ctx) => { const client = await LSPClient.create({ @@ -343,7 +342,7 @@ describe("LSPClient interop", () => { const file = path.join(tmp.path, "client.cs") await Bun.write(file, "class C {}\n") - await WithInstance.provide({ + await withTestInstance({ directory: tmp.path, fn: async (ctx) => { const client = await LSPClient.create({ @@ -397,7 +396,7 @@ describe("LSPClient interop", () => { await Bun.write(file, "class C {}\n") await Bun.write(related, "class D {}\n") - await WithInstance.provide({ + await withTestInstance({ directory: tmp.path, fn: async (ctx) => { const client = await LSPClient.create({ @@ -462,7 +461,7 @@ describe("LSPClient interop", () => { const file = path.join(tmp.path, "client.cs") await Bun.write(file, "class C {}\n") - await WithInstance.provide({ + await withTestInstance({ directory: tmp.path, fn: async (ctx) => { const client = await LSPClient.create({ diff --git a/packages/opencode/test/provider/amazon-bedrock.test.ts b/packages/opencode/test/provider/amazon-bedrock.test.ts index 35824fb2f7b..26bb520fc19 100644 --- a/packages/opencode/test/provider/amazon-bedrock.test.ts +++ b/packages/opencode/test/provider/amazon-bedrock.test.ts @@ -3,9 +3,8 @@ import path from "path" import { unlink } from "fs/promises" import { ProviderID } from "../../src/provider/schema" -import { disposeAllInstances, tmpdir } from "../fixture/fixture" +import { disposeAllInstances, tmpdir, withTestInstance } from "../fixture/fixture" import type { InstanceContext } from "../../src/project/instance-context" -import { WithInstance } from "../../src/project/with-instance" import { Provider } from "@/provider/provider" import { Env } from "../../src/env" import { Global } from "@opencode-ai/core/global" @@ -64,7 +63,7 @@ test("Bedrock: config region takes precedence over AWS_REGION env var", async () ) }, }) - await WithInstance.provide({ + await withTestInstance({ directory: tmp.path, fn: async (ctx) => { set(ctx, "AWS_REGION", "us-east-1") @@ -87,7 +86,7 @@ test("Bedrock: falls back to AWS_REGION env var when no config region", async () ) }, }) - await WithInstance.provide({ + await withTestInstance({ directory: tmp.path, fn: async (ctx) => { set(ctx, "AWS_REGION", "eu-west-1") @@ -140,7 +139,7 @@ test("Bedrock: loads when bearer token from auth.json is present", async () => { }), ) - await WithInstance.provide({ + await withTestInstance({ directory: tmp.path, fn: async (ctx) => { set(ctx, "AWS_PROFILE", "") @@ -184,7 +183,7 @@ test("Bedrock: config profile takes precedence over AWS_PROFILE env var", async ) }, }) - await WithInstance.provide({ + await withTestInstance({ directory: tmp.path, fn: async (ctx) => { set(ctx, "AWS_PROFILE", "default") @@ -214,7 +213,7 @@ test("Bedrock: includes custom endpoint in options when specified", async () => ) }, }) - await WithInstance.provide({ + await withTestInstance({ directory: tmp.path, fn: async (ctx) => { set(ctx, "AWS_PROFILE", "default") @@ -245,7 +244,7 @@ test("Bedrock: autoloads when AWS_WEB_IDENTITY_TOKEN_FILE is present", async () ) }, }) - await WithInstance.provide({ + await withTestInstance({ directory: tmp.path, fn: async (ctx) => { set(ctx, "AWS_WEB_IDENTITY_TOKEN_FILE", "/var/run/secrets/eks.amazonaws.com/serviceaccount/token") @@ -286,7 +285,7 @@ test("Bedrock: model with us. prefix should not be double-prefixed", async () => ) }, }) - await WithInstance.provide({ + await withTestInstance({ directory: tmp.path, fn: async (ctx) => { set(ctx, "AWS_PROFILE", "default") @@ -321,7 +320,7 @@ test("Bedrock: model with global. prefix should not be prefixed", async () => { ) }, }) - await WithInstance.provide({ + await withTestInstance({ directory: tmp.path, fn: async (ctx) => { set(ctx, "AWS_PROFILE", "default") @@ -355,7 +354,7 @@ test("Bedrock: model with eu. prefix should not be double-prefixed", async () => ) }, }) - await WithInstance.provide({ + await withTestInstance({ directory: tmp.path, fn: async (ctx) => { set(ctx, "AWS_PROFILE", "default") @@ -389,7 +388,7 @@ test("Bedrock: model without prefix in US region should get us. prefix added", a ) }, }) - await WithInstance.provide({ + await withTestInstance({ directory: tmp.path, fn: async (ctx) => { set(ctx, "AWS_PROFILE", "default") diff --git a/packages/opencode/test/provider/gitlab-duo.test.ts b/packages/opencode/test/provider/gitlab-duo.test.ts index 4dd762f67cf..4ac62cf69de 100644 --- a/packages/opencode/test/provider/gitlab-duo.test.ts +++ b/packages/opencode/test/provider/gitlab-duo.test.ts @@ -7,8 +7,7 @@ export {} // import path from "path" // import { ProviderID, ModelID } from "../../src/provider/schema" -// import { tmpdir } from "../fixture/fixture" -import { WithInstance } from "../../src/project/with-instance" +// import { tmpdir, withTestInstance } from "../fixture/fixture" // import { Provider } from "@/provider/provider" // import { Env } from "../../src/env" // import { Global } from "@opencode-ai/core/global" @@ -25,7 +24,7 @@ import { WithInstance } from "../../src/project/with-instance" // ) // }, // }) -// await WithInstance.provide({ +// await withTestInstance({ // directory: tmp.path, // init: async () => { // Env.set("GITLAB_TOKEN", "test-gitlab-token") @@ -56,7 +55,7 @@ import { WithInstance } from "../../src/project/with-instance" // ) // }, // }) -// await WithInstance.provide({ +// await withTestInstance({ // directory: tmp.path, // init: async () => { // Env.set("GITLAB_TOKEN", "test-token") @@ -95,7 +94,7 @@ import { WithInstance } from "../../src/project/with-instance" // }), // ) -// await WithInstance.provide({ +// await withTestInstance({ // directory: tmp.path, // init: async () => { // Env.set("GITLAB_TOKEN", "") @@ -130,7 +129,7 @@ import { WithInstance } from "../../src/project/with-instance" // }), // ) -// await WithInstance.provide({ +// await withTestInstance({ // directory: tmp.path, // init: async () => { // Env.set("GITLAB_TOKEN", "") @@ -162,7 +161,7 @@ import { WithInstance } from "../../src/project/with-instance" // ) // }, // }) -// await WithInstance.provide({ +// await withTestInstance({ // directory: tmp.path, // init: async () => { // Env.set("GITLAB_INSTANCE_URL", "https://gitlab.company.internal") @@ -193,7 +192,7 @@ import { WithInstance } from "../../src/project/with-instance" // ) // }, // }) -// await WithInstance.provide({ +// await withTestInstance({ // directory: tmp.path, // init: async () => { // Env.set("GITLAB_TOKEN", "env-token") @@ -216,7 +215,7 @@ import { WithInstance } from "../../src/project/with-instance" // ) // }, // }) -// await WithInstance.provide({ +// await withTestInstance({ // directory: tmp.path, // init: async () => { // Env.set("GITLAB_TOKEN", "test-token") @@ -252,7 +251,7 @@ import { WithInstance } from "../../src/project/with-instance" // ) // }, // }) -// await WithInstance.provide({ +// await withTestInstance({ // directory: tmp.path, // init: async () => { // Env.set("GITLAB_TOKEN", "test-token") @@ -277,7 +276,7 @@ import { WithInstance } from "../../src/project/with-instance" // ) // }, // }) -// await WithInstance.provide({ +// await withTestInstance({ // directory: tmp.path, // init: async () => { // Env.set("GITLAB_TOKEN", "test-token") @@ -301,7 +300,7 @@ import { WithInstance } from "../../src/project/with-instance" // await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ $schema: "https://opencode.ai/config.json" })) // }, // }) -// await WithInstance.provide({ +// await withTestInstance({ // directory: tmp.path, // init: async () => { // Env.set("GITLAB_TOKEN", "test-token") @@ -349,7 +348,7 @@ import { WithInstance } from "../../src/project/with-instance" // await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ $schema: "https://opencode.ai/config.json" })) // }, // }) -// await WithInstance.provide({ +// await withTestInstance({ // directory: tmp.path, // init: async () => { // Env.set("GITLAB_TOKEN", "test-token") @@ -372,7 +371,7 @@ import { WithInstance } from "../../src/project/with-instance" // await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ $schema: "https://opencode.ai/config.json" })) // }, // }) -// await WithInstance.provide({ +// await withTestInstance({ // directory: tmp.path, // init: async () => { // Env.set("GITLAB_TOKEN", "test-token") @@ -396,7 +395,7 @@ import { WithInstance } from "../../src/project/with-instance" // await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ $schema: "https://opencode.ai/config.json" })) // }, // }) -// await WithInstance.provide({ +// await withTestInstance({ // directory: tmp.path, // init: async () => { // Env.set("GITLAB_TOKEN", "test-token") diff --git a/packages/opencode/test/provider/provider.test.ts b/packages/opencode/test/provider/provider.test.ts index 579867b2a2e..7fd2dd657fe 100644 --- a/packages/opencode/test/provider/provider.test.ts +++ b/packages/opencode/test/provider/provider.test.ts @@ -2,10 +2,9 @@ import { afterEach, test, expect } from "bun:test" import { mkdir, unlink } from "fs/promises" import path from "path" -import { disposeAllInstances, tmpdir } from "../fixture/fixture" +import { disposeAllInstances, tmpdir, withTestInstance } from "../fixture/fixture" import { Global } from "@opencode-ai/core/global" import type { InstanceContext } from "../../src/project/instance-context" -import { WithInstance } from "../../src/project/with-instance" import { Plugin } from "../../src/plugin/index" import { ModelsDev } from "@opencode-ai/core/models" import { Provider } from "@/provider/provider" @@ -147,7 +146,7 @@ test("provider loaded from env variable", async () => { ) }, }) - await WithInstance.provide({ + await withTestInstance({ directory: tmp.path, fn: async (ctx) => { set(ctx, "ANTHROPIC_API_KEY", "test-api-key") @@ -179,7 +178,7 @@ test("provider loaded from config with apiKey option", async () => { ) }, }) - await WithInstance.provide({ + await withTestInstance({ directory: tmp.path, fn: async (ctx) => { const providers = await list(ctx) @@ -200,7 +199,7 @@ test("disabled_providers excludes provider", async () => { ) }, }) - await WithInstance.provide({ + await withTestInstance({ directory: tmp.path, fn: async (ctx) => { set(ctx, "ANTHROPIC_API_KEY", "test-api-key") @@ -222,7 +221,7 @@ test("enabled_providers restricts to only listed providers", async () => { ) }, }) - await WithInstance.provide({ + await withTestInstance({ directory: tmp.path, fn: async (ctx) => { set(ctx, "ANTHROPIC_API_KEY", "test-api-key") @@ -250,7 +249,7 @@ test("model whitelist filters models for provider", async () => { ) }, }) - await WithInstance.provide({ + await withTestInstance({ directory: tmp.path, fn: async (ctx) => { set(ctx, "ANTHROPIC_API_KEY", "test-api-key") @@ -279,7 +278,7 @@ test("model blacklist excludes specific models", async () => { ) }, }) - await WithInstance.provide({ + await withTestInstance({ directory: tmp.path, fn: async (ctx) => { set(ctx, "ANTHROPIC_API_KEY", "test-api-key") @@ -312,7 +311,7 @@ test("custom model alias via config", async () => { ) }, }) - await WithInstance.provide({ + await withTestInstance({ directory: tmp.path, fn: async (ctx) => { set(ctx, "ANTHROPIC_API_KEY", "test-api-key") @@ -356,7 +355,7 @@ test("custom provider with npm package", async () => { ) }, }) - await WithInstance.provide({ + await withTestInstance({ directory: tmp.path, fn: async (ctx) => { const providers = await list(ctx) @@ -433,7 +432,7 @@ test("custom DeepSeek openai-compatible model defaults interleaved reasoning fie ) }, }) - await WithInstance.provide({ + await withTestInstance({ directory: tmp.path, fn: async (ctx) => { const providers = await list(ctx) @@ -467,7 +466,7 @@ test("env variable takes precedence, config merges options", async () => { ) }, }) - await WithInstance.provide({ + await withTestInstance({ directory: tmp.path, fn: async (ctx) => { set(ctx, "ANTHROPIC_API_KEY", "env-api-key") @@ -491,7 +490,7 @@ test("getModel returns model for valid provider/model", async () => { ) }, }) - await WithInstance.provide({ + await withTestInstance({ directory: tmp.path, fn: async (ctx) => { set(ctx, "ANTHROPIC_API_KEY", "test-api-key") @@ -516,7 +515,7 @@ test("getModel throws ModelNotFoundError for invalid model", async () => { ) }, }) - await WithInstance.provide({ + await withTestInstance({ directory: tmp.path, fn: async (ctx) => { set(ctx, "ANTHROPIC_API_KEY", "test-api-key") @@ -536,7 +535,7 @@ test("getModel throws ModelNotFoundError for invalid provider", async () => { ) }, }) - await WithInstance.provide({ + await withTestInstance({ directory: tmp.path, fn: async (ctx) => { expect(getModel(ProviderID.make("nonexistent-provider"), ModelID.make("some-model"), ctx)).rejects.toThrow() @@ -567,7 +566,7 @@ test("defaultModel returns first available model when no config set", async () = ) }, }) - await WithInstance.provide({ + await withTestInstance({ directory: tmp.path, fn: async (ctx) => { set(ctx, "ANTHROPIC_API_KEY", "test-api-key") @@ -590,7 +589,7 @@ test("defaultModel respects config model setting", async () => { ) }, }) - await WithInstance.provide({ + await withTestInstance({ directory: tmp.path, fn: async (ctx) => { set(ctx, "ANTHROPIC_API_KEY", "test-api-key") @@ -723,7 +722,7 @@ test("closest finds model by partial match", async () => { ) }, }) - await WithInstance.provide({ + await withTestInstance({ directory: tmp.path, fn: async (ctx) => { set(ctx, "ANTHROPIC_API_KEY", "test-api-key") @@ -746,7 +745,7 @@ test("closest returns undefined for nonexistent provider", async () => { ) }, }) - await WithInstance.provide({ + await withTestInstance({ directory: tmp.path, fn: async (ctx) => { const result = await closest(ProviderID.make("nonexistent"), ["model"], ctx) @@ -776,7 +775,7 @@ test("getModel uses realIdByKey for aliased models", async () => { ) }, }) - await WithInstance.provide({ + await withTestInstance({ directory: tmp.path, fn: async (ctx) => { set(ctx, "ANTHROPIC_API_KEY", "test-api-key") @@ -820,7 +819,7 @@ test("provider api field sets model api.url", async () => { ) }, }) - await WithInstance.provide({ + await withTestInstance({ directory: tmp.path, fn: async (ctx) => { const providers = await list(ctx) @@ -860,7 +859,7 @@ test("explicit baseURL overrides api field", async () => { ) }, }) - await WithInstance.provide({ + await withTestInstance({ directory: tmp.path, fn: async (ctx) => { const providers = await list(ctx) @@ -889,7 +888,7 @@ test("model inherits properties from existing database model", async () => { ) }, }) - await WithInstance.provide({ + await withTestInstance({ directory: tmp.path, fn: async (ctx) => { set(ctx, "ANTHROPIC_API_KEY", "test-api-key") @@ -915,7 +914,7 @@ test("disabled_providers prevents loading even with env var", async () => { ) }, }) - await WithInstance.provide({ + await withTestInstance({ directory: tmp.path, fn: async (ctx) => { set(ctx, "OPENAI_API_KEY", "test-openai-key") @@ -937,7 +936,7 @@ test("enabled_providers with empty array allows no providers", async () => { ) }, }) - await WithInstance.provide({ + await withTestInstance({ directory: tmp.path, fn: async (ctx) => { set(ctx, "ANTHROPIC_API_KEY", "test-api-key") @@ -965,7 +964,7 @@ test("whitelist and blacklist can be combined", async () => { ) }, }) - await WithInstance.provide({ + await withTestInstance({ directory: tmp.path, fn: async (ctx) => { set(ctx, "ANTHROPIC_API_KEY", "test-api-key") @@ -1005,7 +1004,7 @@ test("model modalities default correctly", async () => { ) }, }) - await WithInstance.provide({ + await withTestInstance({ directory: tmp.path, fn: async (ctx) => { const providers = await list(ctx) @@ -1048,7 +1047,7 @@ test("model with custom cost values", async () => { ) }, }) - await WithInstance.provide({ + await withTestInstance({ directory: tmp.path, fn: async (ctx) => { const providers = await list(ctx) @@ -1072,7 +1071,7 @@ test("getSmallModel returns appropriate small model", async () => { ) }, }) - await WithInstance.provide({ + await withTestInstance({ directory: tmp.path, fn: async (ctx) => { set(ctx, "ANTHROPIC_API_KEY", "test-api-key") @@ -1095,7 +1094,7 @@ test("getSmallModel respects config small_model override", async () => { ) }, }) - await WithInstance.provide({ + await withTestInstance({ directory: tmp.path, fn: async (ctx) => { set(ctx, "ANTHROPIC_API_KEY", "test-api-key") @@ -1119,7 +1118,7 @@ test("getSmallModel ignores invalid config small_model", async () => { ) }, }) - await WithInstance.provide({ + await withTestInstance({ directory: tmp.path, fn: async (ctx) => { set(ctx, "ANTHROPIC_API_KEY", "test-api-key") @@ -1162,7 +1161,7 @@ test("multiple providers can be configured simultaneously", async () => { ) }, }) - await WithInstance.provide({ + await withTestInstance({ directory: tmp.path, fn: async (ctx) => { set(ctx, "ANTHROPIC_API_KEY", "test-anthropic-key") @@ -1205,7 +1204,7 @@ test("provider with custom npm package", async () => { ) }, }) - await WithInstance.provide({ + await withTestInstance({ directory: tmp.path, fn: async (ctx) => { const providers = await list(ctx) @@ -1239,7 +1238,7 @@ test("model alias name defaults to alias key when id differs", async () => { ) }, }) - await WithInstance.provide({ + await withTestInstance({ directory: tmp.path, fn: async (ctx) => { set(ctx, "ANTHROPIC_API_KEY", "test-api-key") @@ -1277,7 +1276,7 @@ test("provider with multiple env var options only includes apiKey when single en ) }, }) - await WithInstance.provide({ + await withTestInstance({ directory: tmp.path, fn: async (ctx) => { set(ctx, "MULTI_ENV_KEY_1", "test-key") @@ -1317,7 +1316,7 @@ test("provider with single env var includes apiKey automatically", async () => { ) }, }) - await WithInstance.provide({ + await withTestInstance({ directory: tmp.path, fn: async (ctx) => { set(ctx, "SINGLE_ENV_KEY", "my-api-key") @@ -1352,7 +1351,7 @@ test("model cost overrides existing cost values", async () => { ) }, }) - await WithInstance.provide({ + await withTestInstance({ directory: tmp.path, fn: async (ctx) => { set(ctx, "ANTHROPIC_API_KEY", "test-api-key") @@ -1400,7 +1399,7 @@ test("completely new provider not in database can be configured", async () => { ) }, }) - await WithInstance.provide({ + await withTestInstance({ directory: tmp.path, fn: async (ctx) => { const providers = await list(ctx) @@ -1429,7 +1428,7 @@ test("disabled_providers and enabled_providers interaction", async () => { ) }, }) - await WithInstance.provide({ + await withTestInstance({ directory: tmp.path, fn: async (ctx) => { set(ctx, "ANTHROPIC_API_KEY", "test-anthropic") @@ -1472,7 +1471,7 @@ test("model with tool_call false", async () => { ) }, }) - await WithInstance.provide({ + await withTestInstance({ directory: tmp.path, fn: async (ctx) => { const providers = await list(ctx) @@ -1507,7 +1506,7 @@ test("model defaults tool_call to true when not specified", async () => { ) }, }) - await WithInstance.provide({ + await withTestInstance({ directory: tmp.path, fn: async (ctx) => { const providers = await list(ctx) @@ -1546,7 +1545,7 @@ test("model headers are preserved", async () => { ) }, }) - await WithInstance.provide({ + await withTestInstance({ directory: tmp.path, fn: async (ctx) => { const providers = await list(ctx) @@ -1585,7 +1584,7 @@ test("provider env fallback - second env var used if first missing", async () => ) }, }) - await WithInstance.provide({ + await withTestInstance({ directory: tmp.path, fn: async (ctx) => { // Only set fallback, not primary @@ -1608,7 +1607,7 @@ test("getModel returns consistent results", async () => { ) }, }) - await WithInstance.provide({ + await withTestInstance({ directory: tmp.path, fn: async (ctx) => { set(ctx, "ANTHROPIC_API_KEY", "test-api-key") @@ -1647,7 +1646,7 @@ test("provider name defaults to id when not in database", async () => { ) }, }) - await WithInstance.provide({ + await withTestInstance({ directory: tmp.path, fn: async (ctx) => { const providers = await list(ctx) @@ -1667,7 +1666,7 @@ test("ModelNotFoundError includes suggestions for typos", async () => { ) }, }) - await WithInstance.provide({ + await withTestInstance({ directory: tmp.path, fn: async (ctx) => { set(ctx, "ANTHROPIC_API_KEY", "test-api-key") @@ -1693,7 +1692,7 @@ test("ModelNotFoundError for provider includes suggestions", async () => { ) }, }) - await WithInstance.provide({ + await withTestInstance({ directory: tmp.path, fn: async (ctx) => { set(ctx, "ANTHROPIC_API_KEY", "test-api-key") @@ -1719,7 +1718,7 @@ test("ModelNotFoundError suggests catalog models for unloaded providers", async ) }, }) - await WithInstance.provide({ + await withTestInstance({ directory: tmp.path, fn: async (ctx) => { remove(ctx, "OPENCODE_API_KEY") @@ -1745,7 +1744,7 @@ test("getProvider returns undefined for nonexistent provider", async () => { ) }, }) - await WithInstance.provide({ + await withTestInstance({ directory: tmp.path, fn: async (ctx) => { const provider = await getProvider(ProviderID.make("nonexistent"), ctx) @@ -1765,7 +1764,7 @@ test("getProvider returns provider info", async () => { ) }, }) - await WithInstance.provide({ + await withTestInstance({ directory: tmp.path, fn: async (ctx) => { set(ctx, "ANTHROPIC_API_KEY", "test-api-key") @@ -1787,7 +1786,7 @@ test("closest returns undefined when no partial match found", async () => { ) }, }) - await WithInstance.provide({ + await withTestInstance({ directory: tmp.path, fn: async (ctx) => { set(ctx, "ANTHROPIC_API_KEY", "test-api-key") @@ -1808,7 +1807,7 @@ test("closest checks multiple query terms in order", async () => { ) }, }) - await WithInstance.provide({ + await withTestInstance({ directory: tmp.path, fn: async (ctx) => { set(ctx, "ANTHROPIC_API_KEY", "test-api-key") @@ -1846,7 +1845,7 @@ test("model limit defaults to zero when not specified", async () => { ) }, }) - await WithInstance.provide({ + await withTestInstance({ directory: tmp.path, fn: async (ctx) => { const providers = await list(ctx) @@ -1878,7 +1877,7 @@ test("provider options are deeply merged", async () => { ) }, }) - await WithInstance.provide({ + await withTestInstance({ directory: tmp.path, fn: async (ctx) => { set(ctx, "ANTHROPIC_API_KEY", "test-api-key") @@ -1910,7 +1909,7 @@ test("hosted nvidia provider adds billing origin header", async () => { ) }, }) - await WithInstance.provide({ + await withTestInstance({ directory: tmp.path, fn: async (ctx) => { const providers = await list(ctx) @@ -1942,7 +1941,7 @@ test("custom nvidia baseURL adds billing origin header", async () => { ) }, }) - await WithInstance.provide({ + await withTestInstance({ directory: tmp.path, fn: async (ctx) => { const providers = await list(ctx) @@ -1977,7 +1976,7 @@ test("explicit nvidia billing origin header is preserved", async () => { ) }, }) - await WithInstance.provide({ + await withTestInstance({ directory: tmp.path, fn: async (ctx) => { const providers = await list(ctx) @@ -2008,7 +2007,7 @@ test("custom model inherits npm package from models.dev provider config", async ) }, }) - await WithInstance.provide({ + await withTestInstance({ directory: tmp.path, fn: async (ctx) => { set(ctx, "OPENAI_API_KEY", "test-api-key") @@ -2041,7 +2040,7 @@ test("custom model inherits api.url from models.dev provider", async () => { ) }, }) - await WithInstance.provide({ + await withTestInstance({ directory: tmp.path, fn: async (ctx) => { set(ctx, "OPENROUTER_API_KEY", "test-api-key") @@ -2172,7 +2171,7 @@ test("model variants are generated for reasoning models", async () => { ) }, }) - await WithInstance.provide({ + await withTestInstance({ directory: tmp.path, fn: async (ctx) => { set(ctx, "ANTHROPIC_API_KEY", "test-api-key") @@ -2208,7 +2207,7 @@ test("model variants can be disabled via config", async () => { ) }, }) - await WithInstance.provide({ + await withTestInstance({ directory: tmp.path, fn: async (ctx) => { set(ctx, "ANTHROPIC_API_KEY", "test-api-key") @@ -2249,7 +2248,7 @@ test("model variants can be customized via config", async () => { ) }, }) - await WithInstance.provide({ + await withTestInstance({ directory: tmp.path, fn: async (ctx) => { set(ctx, "ANTHROPIC_API_KEY", "test-api-key") @@ -2286,7 +2285,7 @@ test("disabled key is stripped from variant config", async () => { ) }, }) - await WithInstance.provide({ + await withTestInstance({ directory: tmp.path, fn: async (ctx) => { set(ctx, "ANTHROPIC_API_KEY", "test-api-key") @@ -2322,7 +2321,7 @@ test("all variants can be disabled via config", async () => { ) }, }) - await WithInstance.provide({ + await withTestInstance({ directory: tmp.path, fn: async (ctx) => { set(ctx, "ANTHROPIC_API_KEY", "test-api-key") @@ -2358,7 +2357,7 @@ test("variant config merges with generated variants", async () => { ) }, }) - await WithInstance.provide({ + await withTestInstance({ directory: tmp.path, fn: async (ctx) => { set(ctx, "ANTHROPIC_API_KEY", "test-api-key") @@ -2394,7 +2393,7 @@ test("variants filtered in second pass for database models", async () => { ) }, }) - await WithInstance.provide({ + await withTestInstance({ directory: tmp.path, fn: async (ctx) => { set(ctx, "OPENAI_API_KEY", "test-api-key") @@ -2441,7 +2440,7 @@ test("custom model with variants enabled and disabled", async () => { ) }, }) - await WithInstance.provide({ + await withTestInstance({ directory: tmp.path, fn: async (ctx) => { const providers = await list(ctx) @@ -2496,7 +2495,7 @@ test("Google Vertex: retains baseURL for custom proxy", async () => { }, }) - await WithInstance.provide({ + await withTestInstance({ directory: tmp.path, fn: async (ctx) => { set(ctx, "GOOGLE_APPLICATION_CREDENTIALS", "test-creds") @@ -2539,7 +2538,7 @@ test("Google Vertex: supports OpenAI compatible models", async () => { }, }) - await WithInstance.provide({ + await withTestInstance({ directory: tmp.path, fn: async (ctx) => { set(ctx, "GOOGLE_APPLICATION_CREDENTIALS", "test-creds") @@ -2563,7 +2562,7 @@ test("cloudflare-ai-gateway loads with env variables", async () => { ) }, }) - await WithInstance.provide({ + await withTestInstance({ directory: tmp.path, fn: async (ctx) => { set(ctx, "CLOUDFLARE_ACCOUNT_ID", "test-account") @@ -2593,7 +2592,7 @@ test("cloudflare-ai-gateway forwards config metadata options", async () => { ) }, }) - await WithInstance.provide({ + await withTestInstance({ directory: tmp.path, fn: async (ctx) => { set(ctx, "CLOUDFLARE_ACCOUNT_ID", "test-account") @@ -2646,7 +2645,7 @@ test("plugin config providers persist after instance dispose", async () => { }, }) - const first = await WithInstance.provide({ + const first = await withTestInstance({ directory: tmp.path, fn: async (ctx) => AppRuntime.runPromise( @@ -2663,7 +2662,7 @@ test("plugin config providers persist after instance dispose", async () => { await disposeAllInstances() - const second = await WithInstance.provide({ + const second = await withTestInstance({ directory: tmp.path, fn: async (ctx) => list(ctx), }) @@ -2694,7 +2693,7 @@ test("plugin config enabled and disabled providers are honored", async () => { }, }) - await WithInstance.provide({ + await withTestInstance({ directory: tmp.path, fn: async (ctx) => { set(ctx, "ANTHROPIC_API_KEY", "test-anthropic-key") @@ -2718,7 +2717,7 @@ test("opencode loader keeps paid models when config apiKey is present", async () }, }) - const none = await WithInstance.provide({ + const none = await withTestInstance({ directory: base.path, fn: async (ctx) => paid(await list(ctx)), }) @@ -2741,7 +2740,7 @@ test("opencode loader keeps paid models when config apiKey is present", async () }, }) - const keyedCount = await WithInstance.provide({ + const keyedCount = await withTestInstance({ directory: keyed.path, fn: async (ctx) => paid(await list(ctx)), }) @@ -2762,7 +2761,7 @@ test("opencode loader keeps paid models when auth exists", async () => { }, }) - const none = await WithInstance.provide({ + const none = await withTestInstance({ directory: base.path, fn: async (ctx) => paid(await list(ctx)), }) @@ -2796,7 +2795,7 @@ test("opencode loader keeps paid models when auth exists", async () => { }), ) - const keyedCount = await WithInstance.provide({ + const keyedCount = await withTestInstance({ directory: keyed.path, fn: async (ctx) => paid(await list(ctx)), }) diff --git a/packages/opencode/test/session/llm.test.ts b/packages/opencode/test/session/llm.test.ts index baeda4257c2..a0227c55b80 100644 --- a/packages/opencode/test/session/llm.test.ts +++ b/packages/opencode/test/session/llm.test.ts @@ -7,13 +7,12 @@ import { makeRuntime } from "../../src/effect/run-service" import { InstanceRef } from "../../src/effect/instance-ref" import { LLM } from "../../src/session/llm" import type { InstanceContext } from "../../src/project/instance-context" -import { WithInstance } from "../../src/project/with-instance" import { Provider } from "@/provider/provider" import { ProviderTransform } from "@/provider/transform" import { ModelsDev } from "@opencode-ai/core/models" import { ProviderID, ModelID } from "../../src/provider/schema" import { Filesystem } from "@/util/filesystem" -import { tmpdir } from "../fixture/fixture" +import { tmpdir, withTestInstance } from "../fixture/fixture" import type { Agent } from "../../src/agent/agent" import { MessageV2 } from "../../src/session/message-v2" import { SessionID, MessageID } from "../../src/session/schema" @@ -361,7 +360,7 @@ describe("session.llm.stream", () => { }, }) - await WithInstance.provide({ + await withTestInstance({ directory: tmp.path, fn: async (ctx) => { const resolved = await getModel(ProviderID.make(providerID), ModelID.make(model.id), ctx) @@ -451,7 +450,7 @@ describe("session.llm.stream", () => { }, }) - await WithInstance.provide({ + await withTestInstance({ directory: tmp.path, fn: async (ctx) => { const resolved = await getModel(ProviderID.make(providerID), ModelID.make(model.id), ctx) @@ -541,7 +540,7 @@ describe("session.llm.stream", () => { }, }) - await WithInstance.provide({ + await withTestInstance({ directory: tmp.path, fn: async (ctx) => { const resolved = await getModel(ProviderID.make(providerID), ModelID.make(model.id), ctx) @@ -658,7 +657,7 @@ describe("session.llm.stream", () => { }, }) - await WithInstance.provide({ + await withTestInstance({ directory: tmp.path, fn: async (ctx) => { const resolved = await getModel(ProviderID.openai, ModelID.make(model.id), ctx) @@ -777,7 +776,7 @@ describe("session.llm.stream", () => { }, }) - await WithInstance.provide({ + await withTestInstance({ directory: tmp.path, fn: async (ctx) => { const resolved = await getModel(ProviderID.openai, ModelID.make(model.id), ctx) @@ -899,7 +898,7 @@ describe("session.llm.stream", () => { }, }) - await WithInstance.provide({ + await withTestInstance({ directory: tmp.path, fn: async (ctx) => { const resolved = await getModel(ProviderID.make(providerID), ModelID.make(model.id), ctx) @@ -1020,7 +1019,7 @@ describe("session.llm.stream", () => { }, }) - await WithInstance.provide({ + await withTestInstance({ directory: tmp.path, fn: async (ctx) => { const resolved = await getModel(ProviderID.make("anthropic"), ModelID.make(model.id), ctx) @@ -1264,7 +1263,7 @@ describe("session.llm.stream", () => { }, }) - await WithInstance.provide({ + await withTestInstance({ directory: tmp.path, fn: async (ctx) => { const resolved = await getModel(ProviderID.make(providerID), ModelID.make(model.id), ctx) From f21c582db9e0abb9f2c2ef769567a44787fcfa17 Mon Sep 17 00:00:00 2001 From: vimtor Date: Fri, 15 May 2026 22:18:37 +0200 Subject: [PATCH 054/164] chore: reduce alerting noise --- infra/monitoring.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/infra/monitoring.ts b/infra/monitoring.ts index a2b48109938..06513c58cf4 100644 --- a/infra/monitoring.ts +++ b/infra/monitoring.ts @@ -73,7 +73,7 @@ const modelHttpErrorsQuery = (product: "go" | "zen") => { const providerHttpErrorsQuery = () => { const filters = [ { column: "provider", op: "exists" }, - { column: "provider", op: "!=", value: "fireworks-go-glm-5.1" }, + { column: "status", op: "!=", value: "404" }, { column: "user_agent", op: "contains", value: "opencode" }, ] const successHttpStatus = calculatedField({ @@ -105,7 +105,7 @@ const providerHttpErrorsQuery = () => { }, ], formulas: [ - { name: "ERROR", expression: "IF(GTE(SUM($SUCCESS, $FAILED), 50), DIV($FAILED, SUM($SUCCESS, $FAILED)), 0)" }, + { name: "ERROR", expression: "IF(GTE(SUM($SUCCESS, $FAILED), 100), DIV($FAILED, SUM($SUCCESS, $FAILED)), 0)" }, ], timeRange: 900, }).json From f060874b293a0e8880f1968751aff3a1b32aeb58 Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Sat, 16 May 2026 02:09:58 +0530 Subject: [PATCH 055/164] feat(tui): add minimal thinking mode with click-to-expand (#27623) --- packages/core/src/flag/flag.ts | 1 + .../src/cli/cmd/tui/context/thinking.ts | 67 ++++++++++ .../tui/feature-plugins/system/session-v2.tsx | 83 +++++++++---- .../src/cli/cmd/tui/routes/session/index.tsx | 115 ++++++++++++++---- 4 files changed, 219 insertions(+), 47 deletions(-) create mode 100644 packages/opencode/src/cli/cmd/tui/context/thinking.ts diff --git a/packages/core/src/flag/flag.ts b/packages/core/src/flag/flag.ts index 3ed67bb785d..88270e3c209 100644 --- a/packages/core/src/flag/flag.ts +++ b/packages/core/src/flag/flag.ts @@ -38,6 +38,7 @@ export const Flag = { ), OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT: copy === undefined ? process.platform === "win32" : truthy("OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT"), + OPENCODE_EXPERIMENTAL_MINIMAL_THINKING: truthy("OPENCODE_EXPERIMENTAL_MINIMAL_THINKING"), OPENCODE_MODELS_URL: process.env["OPENCODE_MODELS_URL"], OPENCODE_MODELS_PATH: process.env["OPENCODE_MODELS_PATH"], OPENCODE_DB: process.env["OPENCODE_DB"], diff --git a/packages/opencode/src/cli/cmd/tui/context/thinking.ts b/packages/opencode/src/cli/cmd/tui/context/thinking.ts new file mode 100644 index 00000000000..c5cae734b98 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/context/thinking.ts @@ -0,0 +1,67 @@ +import { createMemo, type Setter } from "solid-js" +import { Flag } from "@opencode-ai/core/flag/flag" +import { useKV } from "./kv" + +export type ThinkingMode = "show" | "minimal" | "hide" + +const MODES: readonly ThinkingMode[] = ["show", "minimal", "hide"] as const + +// OpenAI's Responses API surfaces reasoning summaries that start with a bolded +// title line: "**Inspecting PR workflow**\n\n". GitHub Copilot routes +// through the same shape, and the opencode provider relays it too. Pull the +// title out for a nicer label; return null for providers that don't follow +// this convention so the caller can fall back to a generic "Thinking" string. +export function reasoningTitle(text: string): string | null { + const match = text.trimStart().match(/^\*\*([^*\n]+)\*\*/) + return match ? match[1].trim() : null +} + +export function isThinkingMode(value: unknown): value is ThinkingMode { + return typeof value === "string" && (MODES as readonly string[]).includes(value) +} + +// Cycle order matches the slash command: show → minimal → hide → show. +export function nextThinkingMode(current: ThinkingMode): ThinkingMode { + const idx = MODES.indexOf(current) + return MODES[(idx + 1) % MODES.length] ?? "show" +} + +export function useThinkingMode() { + const kv = useKV() + // Capture pre-state before `kv.signal` seeds a default, so we can detect + // first-time users with a legacy `thinking_visibility` boolean and migrate. + // The KVProvider only renders children once kv.ready, so reads here are safe. + const hadStored = kv.get("thinking_mode") !== undefined + const legacy = kv.get("thinking_visibility") + const [stored, setStored] = kv.signal("thinking_mode", "minimal") + + // The kv signal exposes its setter typed as `Setter` which carries Solid's + // overload set; passing an updater fn through a property access loses the + // bivariance trick the existing `setX((prev) => ...)` callsites rely on. + // Wrap it in a sane shape so consumers can just call `set(next)` or pass + // an updater. + const set = (next: ThinkingMode | ((prev: ThinkingMode) => ThinkingMode)) => { + if (typeof next === "function") setStored(next as Setter) + else setStored(() => next) + } + + // Preserve previous experience for users who had explicitly toggled the + // legacy `thinking_visibility` boolean. First-time users (no legacy key) + // get the new "minimal" default. + if (!hadStored) { + if (legacy === true) set("show") + else if (legacy === false) set("hide") + } + + const mode = createMemo(() => { + if (Flag.OPENCODE_EXPERIMENTAL_MINIMAL_THINKING) return "minimal" + const value = stored() + return isThinkingMode(value) ? value : "minimal" + }) + + return { + mode, + set, + locked: () => Flag.OPENCODE_EXPERIMENTAL_MINIMAL_THINKING === true, + } +} diff --git a/packages/opencode/src/cli/cmd/tui/feature-plugins/system/session-v2.tsx b/packages/opencode/src/cli/cmd/tui/feature-plugins/system/session-v2.tsx index bcf3032ea35..dda6309d930 100644 --- a/packages/opencode/src/cli/cmd/tui/feature-plugins/system/session-v2.tsx +++ b/packages/opencode/src/cli/cmd/tui/feature-plugins/system/session-v2.tsx @@ -5,6 +5,7 @@ import { SplitBorder } from "@tui/component/border" import { Spinner } from "@tui/component/spinner" import { useTheme } from "@tui/context/theme" import { useLocal } from "@tui/context/local" +import { reasoningTitle, useThinkingMode } from "@tui/context/thinking" import { useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid" import { TextAttributes, type BoxRenderable, type SyntaxStyle } from "@opentui/core" import { useBindings } from "../../keymap" @@ -317,7 +318,11 @@ function AssistantMessage(props: { - + props.message.time.completed} + /> @@ -378,30 +383,64 @@ function AssistantText(props: { part: SessionMessageAssistantText; syntax: Synta ) } -function AssistantReasoning(props: { part: SessionMessageAssistantReasoning; subtleSyntax: SyntaxStyle }) { +function AssistantReasoning(props: { + part: SessionMessageAssistantReasoning + subtleSyntax: SyntaxStyle + completedAt: () => number | undefined +}) { const { theme } = useTheme() + const thinking = useThinkingMode() + const [expanded, setExpanded] = createSignal(false) const content = createMemo(() => props.part.text.replace("[REDACTED]", "").trim()) + const inMinimal = createMemo(() => thinking.mode() === "minimal") + // v2 reasoning parts have no per-part `time.end` (see SessionMessageAssistantReasoning + // in the v2 SDK); we settle on parent-message completion instead. + const isDone = createMemo(() => props.completedAt() !== undefined) + const title = createMemo(() => reasoningTitle(content())) + + const toggle = () => { + if (!inMinimal()) return + setExpanded((prev) => !prev) + } + return ( - - - - + + + + + + + + + + + {title() ? "▶ Thought: " + title() : "▶ Thought"} + + + + + + {title() ? "Thinking: " + title() : "Thinking"} + + + ) } diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index e1922bfed54..376fed0d7dd 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -82,6 +82,7 @@ import * as Model from "../../util/model" import { formatTranscript } from "../../util/transcript" import { UI } from "@/cli/ui.ts" import { useTuiConfig } from "../../context/tui-config" +import { nextThinkingMode, reasoningTitle, useThinkingMode, type ThinkingMode } from "../../context/thinking" import { getScrollAcceleration } from "../../util/scroll" import { TuiPluginRuntime } from "@/cli/cmd/tui/plugin/runtime" import { DialogRetryAction } from "../../component/dialog-retry-action" @@ -157,6 +158,7 @@ const context = createContext<{ width: number sessionID: string conceal: () => boolean + thinkingMode: () => ThinkingMode showThinking: () => boolean showTimestamps: () => boolean showDetails: () => boolean @@ -214,7 +216,9 @@ export function Session() { const [sidebar, setSidebar] = kv.signal<"auto" | "hide">("sidebar", "auto") const [sidebarOpen, setSidebarOpen] = createSignal(false) const [conceal, setConceal] = createSignal(true) - const [showThinking, setShowThinking] = kv.signal("thinking_visibility", true) + const thinking = useThinkingMode() + const thinkingMode = thinking.mode + const showThinking = createMemo(() => thinkingMode() !== "hide") const [timestamps, setTimestamps] = kv.signal<"hide" | "show">("timestamps", "hide") const [showDetails, setShowDetails] = kv.signal("tool_details_visibility", true) const [showAssistantMetadata, _setShowAssistantMetadata] = kv.signal("assistant_metadata_visibility", true) @@ -683,7 +687,12 @@ export function Session() { }, }, { - title: showThinking() ? "Hide thinking" : "Show thinking", + title: (() => { + const next = nextThinkingMode(thinkingMode()) + if (next === "minimal") return "Switch thinking to minimal" + if (next === "hide") return "Hide thinking" + return "Show thinking" + })(), value: "session.toggle.thinking", category: "Session", slash: { @@ -691,7 +700,17 @@ export function Session() { aliases: ["toggle-thinking"], }, run: () => { - setShowThinking((prev) => !prev) + // Env override forces minimal for the process. Updating KV here would + // silently diverge from what's rendered; tell the user instead. + if (thinking.locked()) { + toast.show({ + message: "Thinking mode is locked to minimal by OPENCODE_EXPERIMENTAL_MINIMAL_THINKING", + variant: "info", + }) + dialog.clear() + return + } + thinking.set(nextThinkingMode(thinkingMode())) dialog.clear() }, }, @@ -1086,6 +1105,7 @@ export function Session() { }, sessionID: route.sessionID, conceal, + thinkingMode, showThinking, showTimestamps, showDetails, @@ -1492,32 +1512,77 @@ const PART_MAPPING = { function ReasoningPart(props: { last: boolean; part: ReasoningPart; message: AssistantMessage }) { const { theme, subtleSyntax } = useTheme() const ctx = use() + // Collapsed by default in minimal mode: a single line throughout, so the + // layout never shifts. Click to open the full markdown block, click to close. + const [expanded, setExpanded] = createSignal(false) + const content = createMemo(() => { - // Filter out redacted reasoning chunks from OpenRouter - // OpenRouter sends encrypted reasoning data that appears as [REDACTED] + // OpenRouter encrypts some reasoning blocks; drop the placeholder. return props.part.text.replace("[REDACTED]", "").trim() }) + // Reasoning is finalized when the server sets `time.end` (see processor.ts). + // Flips independently of the parent message completing. + const isDone = createMemo(() => props.part.time.end !== undefined) + const inMinimal = createMemo(() => ctx.thinkingMode() === "minimal") + const duration = createMemo(() => { + const end = props.part.time.end + return end === undefined ? 0 : Math.max(0, end - props.part.time.start) + }) + // OpenAI / Copilot / opencode-via-OpenAI emit `**Title**\n\n` summary + // blocks. Surface the title both while streaming and after settling so the + // collapsed line carries real signal, not just a duration. + const title = createMemo(() => reasoningTitle(content())) + + const toggle = () => { + if (!inMinimal()) return + setExpanded((prev) => !prev) + } + return ( - - - - + + + + {/* Full markdown block: `show` mode, or `minimal` after the user opens it. */} + + + + + + {/* Settled: ▶ at the start as the click-to-expand cue. */} + + + {"▶ " + + (title() + ? "Thought: " + title() + " · " + Locale.duration(duration()) + : "Thought for " + Locale.duration(duration()))} + + + + + {/* Streaming: leading animated spinner, no disclosure arrow yet — it + snaps in once reasoning settles, signalling "done, click to expand". */} + + {title() ? "Thinking: " + title() : "Thinking"} + + + ) } From aa07e219455adc42dfa8be8b9dae813de05ff76f Mon Sep 17 00:00:00 2001 From: Sebastian Date: Fri, 15 May 2026 23:04:00 +0200 Subject: [PATCH 056/164] handle undefined tips (#27635) --- .../src/cli/cmd/tui/feature-plugins/home/tips-view.tsx | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/feature-plugins/home/tips-view.tsx b/packages/opencode/src/cli/cmd/tui/feature-plugins/home/tips-view.tsx index d3b880325d0..a40798352ba 100644 --- a/packages/opencode/src/cli/cmd/tui/feature-plugins/home/tips-view.tsx +++ b/packages/opencode/src/cli/cmd/tui/feature-plugins/home/tips-view.tsx @@ -69,6 +69,7 @@ function parse(tip: string): TipPart[] { } const NO_MODELS_TIP = "Run {highlight}/connect{/highlight} to add an AI provider and start coding" +const NO_MODELS_PARTS = parse(NO_MODELS_TIP) function shortcutText(value: string) { return `{highlight}${value}{/highlight}` @@ -138,8 +139,13 @@ export function Tips(props: { api: TuiPluginApi; connected?: boolean }) { return value ? [value] : [] }) return tips[Math.floor(tipOffset * tips.length)] ?? NO_MODELS_TIP - }) - const parts = createMemo(() => parse(tip())) + }, NO_MODELS_TIP) + // Solid can expose a memo's initial value while a pure computation is pending. + const parts = createMemo(() => { + const value = tip() + if (typeof value === "string") return parse(value) + return NO_MODELS_PARTS + }, NO_MODELS_PARTS) return ( From 0f31fd631b22fa29eb62e3d188fe52818c645f20 Mon Sep 17 00:00:00 2001 From: Sebastian Date: Fri, 15 May 2026 23:04:20 +0200 Subject: [PATCH 057/164] Fix multiline mentions (#27649) --- .../opencode/src/cli/cmd/prompt-display.ts | 19 ++++++++++++++----- .../test/cli/run/prompt.shared.test.ts | 6 ++++++ 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/packages/opencode/src/cli/cmd/prompt-display.ts b/packages/opencode/src/cli/cmd/prompt-display.ts index 7ec4bc0af55..4e8cb9046ac 100644 --- a/packages/opencode/src/cli/cmd/prompt-display.ts +++ b/packages/opencode/src/cli/cmd/prompt-display.ts @@ -1,11 +1,20 @@ const graphemes = new Intl.Segmenter(undefined, { granularity: "grapheme" }) +function promptOffsetWidth(value: string) { + let width = 0 + for (const part of graphemes.segment(value)) { + // Textarea offsets count newlines as one position; Bun.stringWidth counts them as zero. + width += part.segment === "\n" ? 1 : Bun.stringWidth(part.segment) + } + return width +} + function displayOffsetIndex(value: string, offset: number) { if (offset <= 0) return 0 let width = 0 for (const part of graphemes.segment(value)) { - const next = width + Bun.stringWidth(part.segment) + const next = width + promptOffsetWidth(part.segment) if (next > offset) return part.index width = next } @@ -13,20 +22,20 @@ function displayOffsetIndex(value: string, offset: number) { return value.length } -export function displaySlice(value: string, start = 0, end = Bun.stringWidth(value)) { +export function displaySlice(value: string, start = 0, end = promptOffsetWidth(value)) { return value.slice(displayOffsetIndex(value, start), displayOffsetIndex(value, end)) } export function displayCharAt(value: string, offset: number) { let width = 0 for (const part of graphemes.segment(value)) { - const next = width + Bun.stringWidth(part.segment) + const next = width + promptOffsetWidth(part.segment) if (offset === width || offset < next) return part.segment width = next } } -export function mentionTriggerIndex(value: string, offset = Bun.stringWidth(value)) { +export function mentionTriggerIndex(value: string, offset = promptOffsetWidth(value)) { const text = displaySlice(value, 0, offset) const index = text.lastIndexOf("@") if (index === -1) return @@ -34,6 +43,6 @@ export function mentionTriggerIndex(value: string, offset = Bun.stringWidth(valu const before = index === 0 ? undefined : text[index - 1] const query = text.slice(index) if ((before === undefined || /\s/.test(before)) && !/\s/.test(query)) { - return Bun.stringWidth(text.slice(0, index)) + return promptOffsetWidth(text.slice(0, index)) } } diff --git a/packages/opencode/test/cli/run/prompt.shared.test.ts b/packages/opencode/test/cli/run/prompt.shared.test.ts index 299751eaa34..35b35ec3e7a 100644 --- a/packages/opencode/test/cli/run/prompt.shared.test.ts +++ b/packages/opencode/test/cli/run/prompt.shared.test.ts @@ -126,6 +126,12 @@ describe("run prompt shared", () => { expect(mentionTriggerIndex("👨‍👩‍👧‍👦 @src", Bun.stringWidth("👨‍👩‍👧‍👦 @src"))).toBe(3) expect(displayCharAt("👨‍👩‍👧‍👦 @src", Bun.stringWidth("👨‍👩‍👧‍👦 @"))).toBe("s") expect(displaySlice("👨‍👩‍👧‍👦 @src", 3, Bun.stringWidth("👨‍👩‍👧‍👦 @src"))).toBe("@src") + expect(mentionTriggerIndex("@file1\n@file2", 13)).toBe(7) + expect(displayCharAt("@file1\n@file2", 6)).toBe("\n") + expect(displaySlice("@file1\n@file2", 8, 13)).toBe("file2") + expect(mentionTriggerIndex("@file1\nfoo @file2", 17)).toBe(11) + expect(mentionTriggerIndex("中文 @one\n@two", 14)).toBe(10) + expect(displaySlice("中文 @one\n@two", 11, 14)).toBe("two") expect(mentionTriggerIndex("中文@")).toBeUndefined() expect(mentionTriggerIndex("こんにちは@")).toBeUndefined() expect(mentionTriggerIndex("한국어@")).toBeUndefined() From 85cd447910124d00fefe546f64b1b5dd14716d83 Mon Sep 17 00:00:00 2001 From: vimtor Date: Sat, 16 May 2026 00:07:53 +0200 Subject: [PATCH 058/164] chore: reduce alerts noise --- infra/monitoring.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/infra/monitoring.ts b/infra/monitoring.ts index 06513c58cf4..f70d4231b1f 100644 --- a/infra/monitoring.ts +++ b/infra/monitoring.ts @@ -73,7 +73,6 @@ const modelHttpErrorsQuery = (product: "go" | "zen") => { const providerHttpErrorsQuery = () => { const filters = [ { column: "provider", op: "exists" }, - { column: "status", op: "!=", value: "404" }, { column: "user_agent", op: "contains", value: "opencode" }, ] const successHttpStatus = calculatedField({ @@ -101,11 +100,11 @@ const providerHttpErrorsQuery = () => { name: "FAILED", column: failedProviderHttpStatus.name, filterCombination: "AND", - filters: [...filters, { column: "event_type", op: "=", value: "llm.error" }], + filters: [...filters, { column: "event_type", op: "=", value: "llm.error" }, { column: "llm.error.code", op: "!=", value: "404" }], }, ], formulas: [ - { name: "ERROR", expression: "IF(GTE(SUM($SUCCESS, $FAILED), 100), DIV($FAILED, SUM($SUCCESS, $FAILED)), 0)" }, + { name: "ERROR", expression: "IF(GTE(SUM($SUCCESS, $FAILED), 200), DIV($FAILED, SUM($SUCCESS, $FAILED)), 0)" }, ], timeRange: 900, }).json From da495fd2e0909b120f135341c1dc0852a05dbd39 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Fri, 15 May 2026 22:09:43 +0000 Subject: [PATCH 059/164] chore: generate --- infra/monitoring.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/infra/monitoring.ts b/infra/monitoring.ts index f70d4231b1f..314680bc33d 100644 --- a/infra/monitoring.ts +++ b/infra/monitoring.ts @@ -100,7 +100,11 @@ const providerHttpErrorsQuery = () => { name: "FAILED", column: failedProviderHttpStatus.name, filterCombination: "AND", - filters: [...filters, { column: "event_type", op: "=", value: "llm.error" }, { column: "llm.error.code", op: "!=", value: "404" }], + filters: [ + ...filters, + { column: "event_type", op: "=", value: "llm.error" }, + { column: "llm.error.code", op: "!=", value: "404" }, + ], }, ], formulas: [ From 09549661e111f768331e01cc278ffa2f2e32d9e5 Mon Sep 17 00:00:00 2001 From: Dax Date: Fri, 15 May 2026 18:43:37 -0400 Subject: [PATCH 060/164] Fix npm CLI binary installation (#27801) --- .github/workflows/publish.yml | 1 + packages/opencode/script/build.ts | 1 + packages/opencode/script/postinstall.mjs | 247 +++++++++++++++-------- packages/opencode/script/publish.ts | 16 +- 4 files changed, 182 insertions(+), 83 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 5f8bac973ad..9887cbe4dc6 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -7,6 +7,7 @@ on: - ci - dev - beta + - fix/npm-native-binary-install - snapshot-* workflow_dispatch: inputs: diff --git a/packages/opencode/script/build.ts b/packages/opencode/script/build.ts index 2f2edb4ff5a..bbbe6bcfcef 100755 --- a/packages/opencode/script/build.ts +++ b/packages/opencode/script/build.ts @@ -244,6 +244,7 @@ for (const item of targets) { { name, version: Script.version, + preferUnplugged: true, os: [item.os], cpu: [item.arch], }, diff --git a/packages/opencode/script/postinstall.mjs b/packages/opencode/script/postinstall.mjs index 7c6f85d2b1b..fa303b746b8 100644 --- a/packages/opencode/script/postinstall.mjs +++ b/packages/opencode/script/postinstall.mjs @@ -1,102 +1,189 @@ #!/usr/bin/env node +import childProcess from "child_process" import fs from "fs" -import path from "path" import os from "os" -import { fileURLToPath } from "url" +import path from "path" import { createRequire } from "module" +import { fileURLToPath } from "url" const __dirname = path.dirname(fileURLToPath(import.meta.url)) const require = createRequire(import.meta.url) +const packageJson = JSON.parse(fs.readFileSync(path.join(__dirname, "package.json"), "utf8")) -function detectPlatformAndArch() { - // Map platform names - let platform - switch (os.platform()) { - case "darwin": - platform = "darwin" - break - case "linux": - platform = "linux" - break - case "win32": - platform = "windows" - break - default: - platform = os.platform() - break - } - - // Map architecture names - let arch - switch (os.arch()) { - case "x64": - arch = "x64" - break - case "arm64": - arch = "arm64" - break - case "arm": - arch = "arm" - break - default: - arch = os.arch() - break - } - - return { platform, arch } +const platformMap = { + darwin: "darwin", + linux: "linux", + win32: "windows", +} +const archMap = { + x64: "x64", + arm64: "arm64", + arm: "arm", } -function findBinary() { - const { platform, arch } = detectPlatformAndArch() - const packageName = `opencode-${platform}-${arch}` - const binaryName = platform === "windows" ? "opencode.exe" : "opencode" +const platform = platformMap[os.platform()] ?? os.platform() +const arch = archMap[os.arch()] ?? os.arch() +const base = `opencode-${platform}-${arch}` +const sourceBinary = platform === "windows" ? "opencode.exe" : "opencode" +const targetBinary = path.join(__dirname, "bin", "opencode.exe") - try { - // Use require.resolve to find the package - const packageJsonPath = require.resolve(`${packageName}/package.json`) - const packageDir = path.dirname(packageJsonPath) - const binaryPath = path.join(packageDir, "bin", binaryName) +function supportsAvx2() { + if (arch !== "x64") return false - if (!fs.existsSync(binaryPath)) { - throw new Error(`Binary not found at ${binaryPath}`) - } - - return { binaryPath, binaryName } - } catch (error) { - throw new Error(`Could not find package ${packageName}: ${error.message}`, { cause: error }) - } -} - -async function main() { - try { - if (os.platform() === "win32") { - // On Windows, the .exe is already included in the package and bin field points to it - // No postinstall setup needed - console.log("Windows detected: binary setup not needed (using packaged .exe)") - return - } - - // On non-Windows platforms, just verify the binary package exists - // Don't replace the wrapper script - it handles binary execution - const { binaryPath } = findBinary() - const target = path.join(__dirname, "bin", ".opencode") - if (fs.existsSync(target)) fs.unlinkSync(target) + if (platform === "linux") { try { - fs.linkSync(binaryPath, target) + return /(^|\s)avx2(\s|$)/i.test(fs.readFileSync("/proc/cpuinfo", "utf8")) } catch { - fs.copyFileSync(binaryPath, target) + return false } - fs.chmodSync(target, 0o755) - } catch (error) { - console.error("Failed to setup opencode binary:", error.message) - process.exit(1) } + + if (platform === "darwin") { + try { + const result = childProcess.spawnSync("sysctl", ["-n", "hw.optional.avx2_0"], { + encoding: "utf8", + timeout: 1500, + }) + if (result.status !== 0) return false + return (result.stdout || "").trim() === "1" + } catch { + return false + } + } + + if (platform === "windows") { + const command = + '(Add-Type -MemberDefinition "[DllImport(""kernel32.dll"")] public static extern bool IsProcessorFeaturePresent(int ProcessorFeature);" -Name Kernel32 -Namespace Win32 -PassThru)::IsProcessorFeaturePresent(40)' + + for (const executable of ["powershell.exe", "pwsh.exe", "pwsh", "powershell"]) { + try { + const result = childProcess.spawnSync(executable, ["-NoProfile", "-NonInteractive", "-Command", command], { + encoding: "utf8", + timeout: 3000, + windowsHide: true, + }) + if (result.status !== 0) continue + const output = (result.stdout || "").trim().toLowerCase() + if (output === "true" || output === "1") return true + if (output === "false" || output === "0") return false + } catch { + continue + } + } + } + + return false +} + +function isMusl() { + if (platform !== "linux") return false + + try { + if (fs.existsSync("/etc/alpine-release")) return true + } catch { + // Ignore filesystem probes that are blocked by the host. + } + + try { + const result = childProcess.spawnSync("ldd", ["--version"], { encoding: "utf8" }) + return `${result.stdout || ""}${result.stderr || ""}`.toLowerCase().includes("musl") + } catch { + return false + } +} + +function packageNames() { + const baseline = arch === "x64" && !supportsAvx2() + + if (platform === "linux") { + if (isMusl()) { + if (arch === "x64") + return baseline + ? [`${base}-baseline-musl`, `${base}-musl`, `${base}-baseline`, base] + : [`${base}-musl`, `${base}-baseline-musl`, base, `${base}-baseline`] + return [`${base}-musl`, base] + } + + if (arch === "x64") + return baseline + ? [`${base}-baseline`, base, `${base}-baseline-musl`, `${base}-musl`] + : [base, `${base}-baseline`, `${base}-musl`, `${base}-baseline-musl`] + return [base, `${base}-musl`] + } + + if (arch === "x64") return baseline ? [`${base}-baseline`, base] : [base, `${base}-baseline`] + return [base] +} + +function resolveBinary(name) { + const packageJsonPath = require.resolve(`${name}/package.json`) + const binaryPath = path.join(path.dirname(packageJsonPath), "bin", sourceBinary) + if (!fs.existsSync(binaryPath)) throw new Error(`Binary not found at ${binaryPath}`) + return binaryPath +} + +function installPackage(name) { + const version = packageJson.optionalDependencies?.[name] + if (!version) return + + const temp = fs.mkdtempSync(path.join(os.tmpdir(), "opencode-install-")) + try { + const result = childProcess.spawnSync( + "npm", + ["install", "--ignore-scripts", "--no-save", "--loglevel=error", "--prefix", temp, `${name}@${version}`], + { stdio: "inherit", windowsHide: true }, + ) + if (result.status !== 0) return + const packageDir = path.join(temp, "node_modules", name) + copyBinary(path.join(packageDir, "bin", sourceBinary), targetBinary) + return true + } finally { + fs.rmSync(temp, { recursive: true, force: true }) + } +} + +function copyBinary(source, target) { + if (!fs.existsSync(source)) throw new Error(`Binary not found at ${source}`) + fs.mkdirSync(path.dirname(target), { recursive: true }) + if (fs.existsSync(target)) fs.unlinkSync(target) + try { + fs.linkSync(source, target) + } catch { + fs.copyFileSync(source, target) + } + fs.chmodSync(target, 0o755) +} + +function verifyBinary() { + const result = childProcess.spawnSync(targetBinary, ["--version"], { + encoding: "utf8", + stdio: "ignore", + windowsHide: true, + }) + return result.status === 0 +} + +function main() { + for (const name of packageNames()) { + try { + copyBinary(resolveBinary(name), targetBinary) + if (verifyBinary()) return + } catch { + if (installPackage(name) && verifyBinary()) return + } + } + + throw new Error( + `It seems your package manager failed to install the right opencode CLI package. Try manually installing ${packageNames() + .map((name) => JSON.stringify(name)) + .join(" or ")}.`, + ) } try { - void main() + main() } catch (error) { - console.error("Postinstall script error:", error.message) - process.exit(0) + console.error(error.message) + process.exit(1) } diff --git a/packages/opencode/script/publish.ts b/packages/opencode/script/publish.ts index eb485242281..e4c1732d38d 100755 --- a/packages/opencode/script/publish.ts +++ b/packages/opencode/script/publish.ts @@ -32,22 +32,32 @@ console.log("binaries", binaries) const version = Object.values(binaries)[0] await $`mkdir -p ./dist/${pkg.name}` -await $`cp -r ./bin ./dist/${pkg.name}/bin` +await $`mkdir -p ./dist/${pkg.name}/bin` await $`cp ./script/postinstall.mjs ./dist/${pkg.name}/postinstall.mjs` await Bun.file(`./dist/${pkg.name}/LICENSE`).write(await Bun.file("../../LICENSE").text()) +await Bun.file(`./dist/${pkg.name}/bin/${pkg.name}.exe`).write( + [ + "#!/usr/bin/env node", + "console.error('The opencode native binary was not installed. Run `node postinstall.mjs` from the opencode-ai package directory to finish setup.')", + "process.exit(1)", + "", + ].join("\n"), +) await Bun.file(`./dist/${pkg.name}/package.json`).write( JSON.stringify( { name: pkg.name + "-ai", bin: { - [pkg.name]: `./bin/${pkg.name}`, + [pkg.name]: `./bin/${pkg.name}.exe`, }, scripts: { - postinstall: "bun ./postinstall.mjs || node ./postinstall.mjs", + postinstall: "node ./postinstall.mjs", }, version: version, license: pkg.license, + os: ["darwin", "linux", "win32"], + cpu: ["arm64", "x64"], optionalDependencies: binaries, }, null, From 2385123f03ed4ae6820e2b235cb8d32919f70fb8 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Fri, 15 May 2026 20:27:36 -0400 Subject: [PATCH 061/164] Fix thinking toggle defaults --- packages/core/src/flag/flag.ts | 1 - .../src/cli/cmd/tui/context/thinking.ts | 17 ++++++------ .../tui/feature-plugins/system/session-v2.tsx | 4 +-- .../src/cli/cmd/tui/routes/session/index.tsx | 27 ++++++------------- 4 files changed, 18 insertions(+), 31 deletions(-) diff --git a/packages/core/src/flag/flag.ts b/packages/core/src/flag/flag.ts index 88270e3c209..3ed67bb785d 100644 --- a/packages/core/src/flag/flag.ts +++ b/packages/core/src/flag/flag.ts @@ -38,7 +38,6 @@ export const Flag = { ), OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT: copy === undefined ? process.platform === "win32" : truthy("OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT"), - OPENCODE_EXPERIMENTAL_MINIMAL_THINKING: truthy("OPENCODE_EXPERIMENTAL_MINIMAL_THINKING"), OPENCODE_MODELS_URL: process.env["OPENCODE_MODELS_URL"], OPENCODE_MODELS_PATH: process.env["OPENCODE_MODELS_PATH"], OPENCODE_DB: process.env["OPENCODE_DB"], diff --git a/packages/opencode/src/cli/cmd/tui/context/thinking.ts b/packages/opencode/src/cli/cmd/tui/context/thinking.ts index c5cae734b98..55e995df11e 100644 --- a/packages/opencode/src/cli/cmd/tui/context/thinking.ts +++ b/packages/opencode/src/cli/cmd/tui/context/thinking.ts @@ -1,10 +1,9 @@ import { createMemo, type Setter } from "solid-js" -import { Flag } from "@opencode-ai/core/flag/flag" import { useKV } from "./kv" -export type ThinkingMode = "show" | "minimal" | "hide" +export type ThinkingMode = "show" | "hide" -const MODES: readonly ThinkingMode[] = ["show", "minimal", "hide"] as const +const MODES: readonly ThinkingMode[] = ["show", "hide"] as const // OpenAI's Responses API surfaces reasoning summaries that start with a bolded // title line: "**Inspecting PR workflow**\n\n". GitHub Copilot routes @@ -20,7 +19,7 @@ export function isThinkingMode(value: unknown): value is ThinkingMode { return typeof value === "string" && (MODES as readonly string[]).includes(value) } -// Cycle order matches the slash command: show → minimal → hide → show. +// Cycle order matches the slash command: show → hide → show. export function nextThinkingMode(current: ThinkingMode): ThinkingMode { const idx = MODES.indexOf(current) return MODES[(idx + 1) % MODES.length] ?? "show" @@ -33,7 +32,7 @@ export function useThinkingMode() { // The KVProvider only renders children once kv.ready, so reads here are safe. const hadStored = kv.get("thinking_mode") !== undefined const legacy = kv.get("thinking_visibility") - const [stored, setStored] = kv.signal("thinking_mode", "minimal") + const [stored, setStored] = kv.signal("thinking_mode", "hide") // The kv signal exposes its setter typed as `Setter` which carries Solid's // overload set; passing an updater fn through a property access loses the @@ -47,21 +46,21 @@ export function useThinkingMode() { // Preserve previous experience for users who had explicitly toggled the // legacy `thinking_visibility` boolean. First-time users (no legacy key) - // get the new "minimal" default. + // get the new "hide" default (collapsed thinking). if (!hadStored) { if (legacy === true) set("show") else if (legacy === false) set("hide") } + if ((stored() as string) === "minimal") set("hide") + const mode = createMemo(() => { - if (Flag.OPENCODE_EXPERIMENTAL_MINIMAL_THINKING) return "minimal" const value = stored() - return isThinkingMode(value) ? value : "minimal" + return isThinkingMode(value) ? value : "hide" }) return { mode, set, - locked: () => Flag.OPENCODE_EXPERIMENTAL_MINIMAL_THINKING === true, } } diff --git a/packages/opencode/src/cli/cmd/tui/feature-plugins/system/session-v2.tsx b/packages/opencode/src/cli/cmd/tui/feature-plugins/system/session-v2.tsx index dda6309d930..5017b77b001 100644 --- a/packages/opencode/src/cli/cmd/tui/feature-plugins/system/session-v2.tsx +++ b/packages/opencode/src/cli/cmd/tui/feature-plugins/system/session-v2.tsx @@ -392,7 +392,7 @@ function AssistantReasoning(props: { const thinking = useThinkingMode() const [expanded, setExpanded] = createSignal(false) const content = createMemo(() => props.part.text.replace("[REDACTED]", "").trim()) - const inMinimal = createMemo(() => thinking.mode() === "minimal") + const inMinimal = createMemo(() => thinking.mode() === "hide") // v2 reasoning parts have no per-part `time.end` (see SessionMessageAssistantReasoning // in the v2 SDK); we settle on parent-message completion instead. const isDone = createMemo(() => props.completedAt() !== undefined) @@ -404,7 +404,7 @@ function AssistantReasoning(props: { } return ( - + thinkingMode() !== "hide") + const showThinking = createMemo(() => true) const [timestamps, setTimestamps] = kv.signal<"hide" | "show">("timestamps", "hide") const [showDetails, setShowDetails] = kv.signal("tool_details_visibility", true) const [showAssistantMetadata, _setShowAssistantMetadata] = kv.signal("assistant_metadata_visibility", true) @@ -689,9 +689,8 @@ export function Session() { { title: (() => { const next = nextThinkingMode(thinkingMode()) - if (next === "minimal") return "Switch thinking to minimal" - if (next === "hide") return "Hide thinking" - return "Show thinking" + if (next === "hide") return "Collapse thinking" + return "Expand thinking" })(), value: "session.toggle.thinking", category: "Session", @@ -700,16 +699,6 @@ export function Session() { aliases: ["toggle-thinking"], }, run: () => { - // Env override forces minimal for the process. Updating KV here would - // silently diverge from what's rendered; tell the user instead. - if (thinking.locked()) { - toast.show({ - message: "Thinking mode is locked to minimal by OPENCODE_EXPERIMENTAL_MINIMAL_THINKING", - variant: "info", - }) - dialog.clear() - return - } thinking.set(nextThinkingMode(thinkingMode())) dialog.clear() }, @@ -1512,7 +1501,7 @@ const PART_MAPPING = { function ReasoningPart(props: { last: boolean; part: ReasoningPart; message: AssistantMessage }) { const { theme, subtleSyntax } = useTheme() const ctx = use() - // Collapsed by default in minimal mode: a single line throughout, so the + // Collapsed by default in hide mode: a single line throughout, so the // layout never shifts. Click to open the full markdown block, click to close. const [expanded, setExpanded] = createSignal(false) @@ -1523,7 +1512,7 @@ function ReasoningPart(props: { last: boolean; part: ReasoningPart; message: Ass // Reasoning is finalized when the server sets `time.end` (see processor.ts). // Flips independently of the parent message completing. const isDone = createMemo(() => props.part.time.end !== undefined) - const inMinimal = createMemo(() => ctx.thinkingMode() === "minimal") + const inMinimal = createMemo(() => ctx.thinkingMode() === "hide") const duration = createMemo(() => { const end = props.part.time.end return end === undefined ? 0 : Math.max(0, end - props.part.time.start) @@ -1539,10 +1528,10 @@ function ReasoningPart(props: { last: boolean; part: ReasoningPart; message: Ass } return ( - + - {/* Full markdown block: `show` mode, or `minimal` after the user opens it. */} + {/* Full markdown block: `show` mode, or `hide` after the user opens it. */} From 5911bd532d7c80e8426783e5151dd00f92dd1e76 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Fri, 15 May 2026 20:42:56 -0400 Subject: [PATCH 062/164] fix(tui): show config error details on startup (#27803) --- packages/opencode/specs/effect/errors.md | 81 +++++++++++++++++++ .../cli/cmd/tui/context/aggregate-failures.ts | 19 ++++- packages/opencode/src/cli/error.ts | 29 ++++--- .../instance/httpapi/middleware/error.ts | 8 ++ .../cli/cmd/tui/aggregate-failures.test.ts | 44 +++++++++- .../server/httpapi-error-middleware.test.ts | 22 +++++ 6 files changed, 185 insertions(+), 18 deletions(-) diff --git a/packages/opencode/specs/effect/errors.md b/packages/opencode/specs/effect/errors.md index 69298bde5ca..310857dfd96 100644 --- a/packages/opencode/specs/effect/errors.md +++ b/packages/opencode/specs/effect/errors.md @@ -70,11 +70,51 @@ Endpoint definitions declare which public errors can be emitted. Public HTTP error schemas carry their response status with `httpApiStatus` or the equivalent HttpApi schema annotation. +Effect's own HttpApi examples follow this pattern: + +```ts +export class Unauthorized extends Schema.TaggedErrorClass()( + "Unauthorized", + { message: Schema.String }, + { httpApiStatus: 401 }, +) {} + +export class Authorization extends HttpApiMiddleware.Service()("app/Authorization", { + security: { bearer: HttpApiSecurity.bearer }, + error: Unauthorized, +}) {} +``` + +Endpoint-level errors use the same idea: + +```ts +export class ConfigApiError extends Schema.ErrorClass("ConfigApiError")( + { + name: Schema.Union(Schema.Literal("ConfigInvalidError"), Schema.Literal("ConfigJsonError")), + data: Schema.Struct({ message: Schema.optional(Schema.String), path: Schema.String }), + }, + { httpApiStatus: 400 }, +) {} + +HttpApiEndpoint.get("get", "/config", { + success: Config.Info, + error: ConfigApiError, +}) +``` + The service error and HTTP error may be the same class only when the wire shape is intentionally public. Use separate HTTP error schemas when the service error contains internals, low-level causes, retry hints, or data that should not be exposed to API clients. +Do not map every domain error into one universal HTTP error class. Prefer a +small public error vocabulary by route group: shared shapes like +`ApiNotFoundError`, route-specific shapes like `ConfigApiError`, and built-in +empty `HttpApiError.*` only when an empty/no-content body is the intended SDK +contract. + ## Mapping Guidance - Keep one-off translations inline in the handler. @@ -86,6 +126,35 @@ that should not be exposed to API clients. breaking API change. - Use built-in `HttpApiError.*` only when its generated body and SDK surface are intentionally the public contract. +- Prefer `Schema.ErrorClass` for public HTTP error bodies whose wire shape is + not the same as the internal domain error shape. +- Prefer `Schema.TaggedErrorClass` for service/domain errors and middleware + errors that are naturally tagged by `_tag`. +- If preserving a legacy `{ name, data }` body, model that shape explicitly in + the public API error schema instead of relying on `NamedError.toObject()` in + generic middleware. + +## User-Facing Rendering + +HTTP serialization and user rendering are separate boundaries. The server +should send structured public errors; CLI and TUI code should format those +structures through one shared formatter. + +For SDK calls using `{ throwOnError: true }`, the generated client may wrap the +decoded response body in an `Error`. The original body should remain available +under `error.cause.body`; `FormatError` is the right place to unwrap and render +that body. TUI aggregation helpers should call `FormatError` first, then fall +back to generic `Error.message` / string rendering. + +When several parallel startup requests fail from the same underlying issue, +group identical rendered messages and list the affected request names once. +For example: + +```text +Configuration is invalid at /path/to/opencode.json +↳ Expected object, got "not-object" provider.bad.options +Affected startup requests: config.providers, provider.list, app.agents, config.get +``` ## Middleware Guidance @@ -99,6 +168,15 @@ middleware should shrink. It should not gain new name checks. Unknown `500` responses should log full details server-side with `Cause.pretty(cause)` and return a safe public body. +The config startup regression in #27056 is the failure mode this rule is meant +to avoid: a user-authored invalid `opencode.json` crossed the HttpApi boundary +as a defect, so middleware replaced a useful `ConfigInvalidError` with a safe +generic `UnknownError`. The compatibility fix is to preserve config parse and +validation errors as client-visible `400`s. The target architecture is better: +config loading should fail on the typed error channel, config HTTP handlers +should map those errors to declared `ConfigApiError` responses, and the generic +middleware should never see them. + ## Migration Order Prefer small vertical slices: @@ -113,6 +191,9 @@ Prefer small vertical slices: Good early domains are storage not-found, worktree errors, and provider auth validation errors because they currently drive HTTP behavior. +Config parse and validation errors are also a good early slice because they +are startup-blocking and must be rendered clearly in both CLI and TUI flows. + ## Checklist For A PR - [ ] Expected failures are typed errors, not defects. diff --git a/packages/opencode/src/cli/cmd/tui/context/aggregate-failures.ts b/packages/opencode/src/cli/cmd/tui/context/aggregate-failures.ts index 63b3fb44876..8b652b6512d 100644 --- a/packages/opencode/src/cli/cmd/tui/context/aggregate-failures.ts +++ b/packages/opencode/src/cli/cmd/tui/context/aggregate-failures.ts @@ -1,3 +1,5 @@ +import { FormatError } from "@/cli/error" + /** * Aggregate Promise.allSettled results into a single Error that names every * failed endpoint, or return null when all fulfilled. Used at TUI bootstrap @@ -15,7 +17,19 @@ export function aggregateFailures(labeled: LabeledSettled[]): Error | null { ) if (failed.length === 0) return null - const reasons = failed.map((f) => `${f.name}: ${reasonMessage(f.result.reason)}`).join("; ") + const reasons = Array.from( + failed + .map((f) => ({ name: f.name, message: reasonMessage(f.result.reason) })) + .reduce((grouped, failure) => { + grouped.set(failure.message, [...(grouped.get(failure.message) ?? []), failure.name]) + return grouped + }, new Map()) + .entries(), + ) + .map(([message, names]) => + names.length === 1 ? `${names[0]}: ${message}` : `${message}\nAffected startup requests: ${names.join(", ")}`, + ) + .join("; ") const summary = `${failed.length} of ${labeled.length} requests failed: ${reasons}` const err = new Error(summary) err.cause = { failures: failed.map((f) => ({ name: f.name, reason: f.result.reason })) } @@ -23,6 +37,9 @@ export function aggregateFailures(labeled: LabeledSettled[]): Error | null { } function reasonMessage(reason: unknown): string { + const formatted = FormatError(reason) + if (formatted) return formatted + if (reason instanceof Error) return reason.message if (typeof reason === "string") return reason if (reason && typeof reason === "object") { diff --git a/packages/opencode/src/cli/error.ts b/packages/opencode/src/cli/error.ts index c92369b0af4..ef724bbbef5 100644 --- a/packages/opencode/src/cli/error.ts +++ b/packages/opencode/src/cli/error.ts @@ -2,16 +2,9 @@ import { NamedError } from "@opencode-ai/core/util/error" import { errorFormat } from "@/util/error" import { isRecord } from "@/util/record" -interface ErrorLike { - name?: string - _tag?: string - message?: string - data?: Record -} - type ConfigIssue = { message: string; path: string[] } -function isTaggedError(error: unknown, tag: string): boolean { +function isTaggedError(error: unknown, tag: string): error is Record { return isRecord(error) && error._tag === tag } @@ -39,22 +32,27 @@ function configIssues(input: Record): ConfigIssue[] { : [] } -export function FormatError(input: unknown) { +export function FormatError(input: unknown): string | undefined { + if (input instanceof Error && isRecord(input.cause) && "body" in input.cause) { + const formatted = FormatError(input.cause.body) + if (formatted) return formatted + } + // CliError: domain failure surfaced from an effectCmd handler via fail("...") if (isTaggedError(input, "CliError")) { - const data = input as ErrorLike & { exitCode?: number } - if (data.exitCode != null) process.exitCode = data.exitCode - return data.message ?? "" + if (typeof input.exitCode === "number") process.exitCode = input.exitCode + return stringField(input, "message") ?? "" } // MCPFailed: { name: string } if (NamedError.hasName(input, "MCPFailed")) { - return `MCP server "${(input as ErrorLike).data?.name}" failed. Note, opencode does not support MCP authentication yet.` + const data = isRecord(input) && isRecord(input.data) ? stringField(input.data, "name") : undefined + return `MCP server "${data}" failed. Note, opencode does not support MCP authentication yet.` } // AccountServiceError, AccountTransportError: TaggedErrorClass if (isTaggedError(input, "AccountServiceError") || isTaggedError(input, "AccountTransportError")) { - return (input as ErrorLike).message ?? "" + return stringField(input, "message") ?? "" } // ProviderModelNotFoundError: { providerID: string, modelID: string, suggestions?: string[] } @@ -64,7 +62,7 @@ export function FormatError(input: unknown) { ? providerModelNotFound.suggestions.filter((x) => typeof x === "string") : [] return [ - `Model not found: ${providerModelNotFound.providerID}/${providerModelNotFound.modelID}`, + `Model not found: ${stringField(providerModelNotFound, "providerID")}/${stringField(providerModelNotFound, "modelID")}`, ...(suggestions.length ? ["Did you mean: " + suggestions.join(", ")] : []), `Try: \`opencode models\` to list available models`, `Or check your config (opencode.json) provider/model names`, @@ -112,6 +110,7 @@ export function FormatError(input: unknown) { if (isTaggedError(input, "UICancelledError") || NamedError.hasName(input, "UICancelledError")) { return "" } + return undefined } export function FormatUnknownError(input: unknown): string { diff --git a/packages/opencode/src/server/routes/instance/httpapi/middleware/error.ts b/packages/opencode/src/server/routes/instance/httpapi/middleware/error.ts index 74c690ad6cd..7b5643fd68b 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/middleware/error.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/middleware/error.ts @@ -1,5 +1,6 @@ import { NamedError } from "@opencode-ai/core/util/error" import * as Log from "@opencode-ai/core/util/log" +import { ConfigError } from "@/config/error" import { Cause, Effect } from "effect" import { HttpRouter, HttpServerError, HttpServerRespondable, HttpServerResponse } from "effect/unstable/http" @@ -18,6 +19,13 @@ export const errorLayer = HttpRouter.middleware<{ handles: unknown }>()((effect) if (!defect) return Effect.failCause(cause) const error = defect.defect + if ( + error instanceof NamedError && + (ConfigError.InvalidError.isInstance(error) || ConfigError.JsonError.isInstance(error)) + ) { + return Effect.succeed(HttpServerResponse.jsonUnsafe(error.toObject(), { status: 400 })) + } + log.error("failed", { error, cause: Cause.pretty(cause) }) return Effect.succeed( diff --git a/packages/opencode/test/cli/cmd/tui/aggregate-failures.test.ts b/packages/opencode/test/cli/cmd/tui/aggregate-failures.test.ts index c9b3551d9a2..8256974f648 100644 --- a/packages/opencode/test/cli/cmd/tui/aggregate-failures.test.ts +++ b/packages/opencode/test/cli/cmd/tui/aggregate-failures.test.ts @@ -5,6 +5,7 @@ */ import { describe, expect, test } from "bun:test" import { aggregateFailures } from "@/cli/cmd/tui/context/aggregate-failures" +import { ConfigError } from "@/config/error" describe("aggregateFailures", () => { test("returns null when every result is fulfilled", () => { @@ -41,11 +42,50 @@ describe("aggregateFailures", () => { expect(err!.message).toContain("agents: boom") }) + test("formats structured config errors hidden inside SDK error causes", () => { + const configError = new ConfigError.InvalidError({ + path: "/tmp/opencode.json", + issues: [{ message: "Expected object", path: ["provider", "anthropic", "options"] }], + }) + const err = aggregateFailures([ + { + name: "config.get", + result: { + status: "rejected", + reason: new Error("ConfigInvalidError", { + cause: { + body: configError.toObject(), + }, + }), + }, + }, + ]) + + expect(err!.message).toContain("config.get: Configuration is invalid at /tmp/opencode.json") + expect(err!.message).toContain("Expected object provider.anthropic.options") + }) + + test("deduplicates identical failure messages across startup requests", () => { + const reason = new Error("same config problem") + const err = aggregateFailures([ + { name: "config.providers", result: { status: "rejected", reason } }, + { name: "provider.list", result: { status: "rejected", reason } }, + { name: "app.agents", result: { status: "rejected", reason } }, + { name: "config.get", result: { status: "rejected", reason } }, + { name: "project.sync", result: { status: "fulfilled", value: undefined } }, + ]) + + expect(err!.message).toContain("4 of 5 requests failed: same config problem") + expect(err!.message).toContain( + "Affected startup requests: config.providers, provider.list, app.agents, config.get", + ) + expect(err!.message.match(/same config problem/g)?.length).toBe(1) + }) + test("attaches structured failure list under .cause", () => { const reason = new Error("nope") const err = aggregateFailures([{ name: "providers", result: { status: "rejected", reason } }]) - const cause = err!.cause as { failures: Array<{ name: string; reason: unknown }> } - expect(cause.failures).toEqual([{ name: "providers", reason }]) + expect(err!.cause).toEqual({ failures: [{ name: "providers", reason }] }) }) test("falls back to String() for opaque reasons", () => { diff --git a/packages/opencode/test/server/httpapi-error-middleware.test.ts b/packages/opencode/test/server/httpapi-error-middleware.test.ts index 15f8aa20260..51d4cf9e0c0 100644 --- a/packages/opencode/test/server/httpapi-error-middleware.test.ts +++ b/packages/opencode/test/server/httpapi-error-middleware.test.ts @@ -1,6 +1,7 @@ import { NodeHttpServer, NodeServices } from "@effect/platform-node" import { NamedError } from "@opencode-ai/core/util/error" import { describe, expect } from "bun:test" +import { ConfigError } from "../../src/config/error" import { Effect, Layer } from "effect" import { HttpClient, HttpClientRequest, HttpRouter } from "effect/unstable/http" import { errorLayer } from "../../src/server/routes/instance/httpapi/middleware/error" @@ -50,6 +51,27 @@ describe("HttpApi error middleware", () => { }), ) + it.live("preserves config defects as client-visible bad requests", () => + Effect.gen(function* () { + const configError = new ConfigError.InvalidError({ + path: "/tmp/opencode.json", + issues: [{ message: "Expected object", path: ["provider", "anthropic", "options"] }], + }) + + yield* HttpRouter.add("GET", "/config-error", Effect.die(configError)).pipe( + Layer.provide(errorLayer), + HttpRouter.serve, + Layer.build, + ) + + const response = yield* HttpClientRequest.get("/config-error").pipe(HttpClient.execute) + const body = yield* response.json + + expect(response.status).toBe(400) + expect(JSON.stringify(body)).toBe(JSON.stringify(configError.toObject())) + }), + ) + it.live("does not map storage not-found defects to 404", () => Effect.gen(function* () { yield* HttpRouter.add( From d6b23fd8f65f1ff175e5e609c4b922e4d4d4a8b4 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Sat, 16 May 2026 00:44:10 +0000 Subject: [PATCH 063/164] chore: generate --- packages/opencode/specs/effect/errors.md | 9 ++++++--- .../opencode/test/cli/cmd/tui/aggregate-failures.test.ts | 4 +--- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/packages/opencode/specs/effect/errors.md b/packages/opencode/specs/effect/errors.md index 310857dfd96..fe526c2faa7 100644 --- a/packages/opencode/specs/effect/errors.md +++ b/packages/opencode/specs/effect/errors.md @@ -79,9 +79,12 @@ export class Unauthorized extends Schema.TaggedErrorClass()( { httpApiStatus: 401 }, ) {} -export class Authorization extends HttpApiMiddleware.Service()("app/Authorization", { +export class Authorization extends HttpApiMiddleware.Service< + Authorization, + { + provides: CurrentUser + } +>()("app/Authorization", { security: { bearer: HttpApiSecurity.bearer }, error: Unauthorized, }) {} diff --git a/packages/opencode/test/cli/cmd/tui/aggregate-failures.test.ts b/packages/opencode/test/cli/cmd/tui/aggregate-failures.test.ts index 8256974f648..c30d719252a 100644 --- a/packages/opencode/test/cli/cmd/tui/aggregate-failures.test.ts +++ b/packages/opencode/test/cli/cmd/tui/aggregate-failures.test.ts @@ -76,9 +76,7 @@ describe("aggregateFailures", () => { ]) expect(err!.message).toContain("4 of 5 requests failed: same config problem") - expect(err!.message).toContain( - "Affected startup requests: config.providers, provider.list, app.agents, config.get", - ) + expect(err!.message).toContain("Affected startup requests: config.providers, provider.list, app.agents, config.get") expect(err!.message.match(/same config problem/g)?.length).toBe(1) }) From ad79ad9ea855cb9e0d327f76fad765b952cb0ef1 Mon Sep 17 00:00:00 2001 From: Sebastian Date: Sat, 16 May 2026 03:05:54 +0200 Subject: [PATCH 064/164] upgrade opentui to 0.2.11 (#27808) --- bun.lock | 30 +++++++++++++++--------------- package.json | 6 +++--- packages/plugin/package.json | 6 +++--- 3 files changed, 21 insertions(+), 21 deletions(-) diff --git a/bun.lock b/bun.lock index 8fa8d02544a..b9318708a74 100644 --- a/bun.lock +++ b/bun.lock @@ -536,9 +536,9 @@ "typescript": "catalog:", }, "peerDependencies": { - "@opentui/core": ">=0.2.10", - "@opentui/keymap": ">=0.2.10", - "@opentui/solid": ">=0.2.10", + "@opentui/core": ">=0.2.11", + "@opentui/keymap": ">=0.2.11", + "@opentui/solid": ">=0.2.11", }, "optionalPeers": [ "@opentui/core", @@ -721,9 +721,9 @@ "@npmcli/arborist": "9.4.0", "@octokit/rest": "22.0.0", "@openauthjs/openauth": "0.0.0-20250322224806", - "@opentui/core": "0.2.10", - "@opentui/keymap": "0.2.10", - "@opentui/solid": "0.2.10", + "@opentui/core": "0.2.11", + "@opentui/keymap": "0.2.11", + "@opentui/solid": "0.2.11", "@pierre/diffs": "1.1.0-beta.18", "@playwright/test": "1.59.1", "@sentry/solid": "10.36.0", @@ -1590,23 +1590,23 @@ "@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.40.0", "", {}, "sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw=="], - "@opentui/core": ["@opentui/core@0.2.10", "", { "dependencies": { "bun-ffi-structs": "0.2.2", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.2.10", "@opentui/core-darwin-x64": "0.2.10", "@opentui/core-linux-arm64": "0.2.10", "@opentui/core-linux-x64": "0.2.10", "@opentui/core-win32-arm64": "0.2.10", "@opentui/core-win32-x64": "0.2.10" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-oviCtx0jYjc7F8X2b8+0IkQLg6WH47Nwl6CFeZo5dU0k6OpSbTbi07ZleObaiECAp+S1YLhAtVdgzHU7hBZlaw=="], + "@opentui/core": ["@opentui/core@0.2.11", "", { "dependencies": { "bun-ffi-structs": "0.2.2", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.2.11", "@opentui/core-darwin-x64": "0.2.11", "@opentui/core-linux-arm64": "0.2.11", "@opentui/core-linux-x64": "0.2.11", "@opentui/core-win32-arm64": "0.2.11", "@opentui/core-win32-x64": "0.2.11" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-X0zLmcDEvMrPzWYp769I7VEVb+og38vaete9tGZXu9HnJgu/paPUUplUT+6denBQccr2qx1rBYV6EtgbBpLEyw=="], - "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.2.10", "", { "os": "darwin", "cpu": "arm64" }, "sha512-+lbDDj42Og+UtTZEwlHhGXichmOlkxSqn0J+Jqjat5/Tt5oZykj1NZjFIQ7ZSz4Miz7EmZwgYKE2CyOmmm9MoQ=="], + "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.2.11", "", { "os": "darwin", "cpu": "arm64" }, "sha512-h2MXtE2Cu3XlKVoQMXthnbhleO68zGXkoh/r1Q5pCoZh6RuXqns5/94D/aZThXBWwzPuEoyarMlxxR9OqrpvHw=="], - "@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.2.10", "", { "os": "darwin", "cpu": "x64" }, "sha512-5iAoA0aqMWWAQ93nh8Bb0ipwt9h+tvEFc88+YO9St43uUJ+XrXcmMj3T8wtl6dSu/SN0UoDWNaUMHUmtykiPtg=="], + "@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.2.11", "", { "os": "darwin", "cpu": "x64" }, "sha512-Y0jbPClnOBTPSIy+2THG86MTqIG/jGFlOOKuw4JfCDqEjPBM3pLWIHnJb3WxHRi2LlvfyBxvrUTXWlW6JpI0QQ=="], - "@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.2.10", "", { "os": "linux", "cpu": "arm64" }, "sha512-EnrkxgH5K76Oi/Br1UHPZblXG5P60snmtySfnxuVaeECNZrbTkV6BV/A0WoBeWshJweGbx1D+eTF+sEEjQCi8w=="], + "@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.2.11", "", { "os": "linux", "cpu": "arm64" }, "sha512-blQyyuTaW4q/OQ3whs7Kt7GCXhBUR5EQHHDdjOqQAr0HYpohUa6sbHMbiBcX2Ehc9ZWwtiaOoWiyZ5YXy2SAvg=="], - "@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.2.10", "", { "os": "linux", "cpu": "x64" }, "sha512-fI+r3kCPqIxsWwPVGpKUQy4zHK8y+jkDRCwa3UbaUy48RQ44jMuf2RhVhmi4xmCvSc8UPJBbYsw1tLuh9kmXjg=="], + "@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.2.11", "", { "os": "linux", "cpu": "x64" }, "sha512-0nEB5+MgzQRYiVcQd1vHXPWNPWGh4JEmQTJKyG3OHnTzPaJ1FVSQ/V71ECyRSl3ymY3F+U0eW9cFgw1hCieK2w=="], - "@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.2.10", "", { "os": "win32", "cpu": "arm64" }, "sha512-8F4z2hIRgkVWcr6CMVeJ9N4+1rmURPt2Pq2GBPko8ch6rxHR+a//KD1MfphyuLTHBS1tJ4vfZSWSoiaESImtrA=="], + "@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.2.11", "", { "os": "win32", "cpu": "arm64" }, "sha512-+KKH77fzm0qF8py9G2pU32DzB1bAgDMfBajrs7gKL5NtSEnknrwfh7hIs/tq41aF6j9zvIzgtykByh26tcjFog=="], - "@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.2.10", "", { "os": "win32", "cpu": "x64" }, "sha512-Ki+qNBlIFW5K2wcG/RHrlPp7yEQKXeiNX3mlje25iwX62Ac5w391HBpOmUjbPoq20McPyDRnhbLfbXQSPtickg=="], + "@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.2.11", "", { "os": "win32", "cpu": "x64" }, "sha512-dMmb9DX0W0HWadLdgciMbonqIc1xdcKiVmaQSYxw5eGCzFRPZIOrKHByesP+2ipkMuLx85W/MJUFal/lW8XSNg=="], - "@opentui/keymap": ["@opentui/keymap@0.2.10", "", { "dependencies": { "@opentui/core": "0.2.10" }, "peerDependencies": { "@opentui/react": "0.2.10", "@opentui/solid": "0.2.10", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-80fU3Lr/98sNIpVYd8PApAeQw8A8D9BemyOGi6jGvTQCl0rxKgvaVBviDRGKxl1INTVjZy9By8UPncc2KJOuWQ=="], + "@opentui/keymap": ["@opentui/keymap@0.2.11", "", { "dependencies": { "@opentui/core": "0.2.11" }, "peerDependencies": { "@opentui/react": "0.2.11", "@opentui/solid": "0.2.11", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-pCrJrY3mTuXdDaaRneId1JsJCtGE+7prTtWihzOLZzVJTJYyYtT38gMI7MpyAoloVDfEL5cTe8C+v7wv+IYREw=="], - "@opentui/solid": ["@opentui/solid@0.2.10", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.2.10", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-+4/MB90yIQiPwg8Y4wY092yva9BvRTsJeeeEO3e2H7P8k8zxYk4G9bzuhqYLxA9mTVQ+zVDlrmFoPQhT7vpIRw=="], + "@opentui/solid": ["@opentui/solid@0.2.11", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.2.11", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-M3WHxBFORHVE0yqMJYpi9PfjXWlnRTw/LYuBhZaJv0HTo+zTs60P/ukGcwnHDWnMpTGf3BH9x0Yi2dIqjHRY6Q=="], "@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="], diff --git a/package.json b/package.json index a3400fbfb9a..fb44c2f8b76 100644 --- a/package.json +++ b/package.json @@ -35,9 +35,9 @@ "@types/cross-spawn": "6.0.6", "@octokit/rest": "22.0.0", "@hono/zod-validator": "0.4.2", - "@opentui/core": "0.2.10", - "@opentui/keymap": "0.2.10", - "@opentui/solid": "0.2.10", + "@opentui/core": "0.2.11", + "@opentui/keymap": "0.2.11", + "@opentui/solid": "0.2.11", "ulid": "3.0.1", "@kobalte/core": "0.13.11", "@types/luxon": "3.7.1", diff --git a/packages/plugin/package.json b/packages/plugin/package.json index 67055fdcd92..93882a77f5a 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -22,9 +22,9 @@ "zod": "catalog:" }, "peerDependencies": { - "@opentui/core": ">=0.2.10", - "@opentui/keymap": ">=0.2.10", - "@opentui/solid": ">=0.2.10" + "@opentui/core": ">=0.2.11", + "@opentui/keymap": ">=0.2.11", + "@opentui/solid": ">=0.2.11" }, "peerDependenciesMeta": { "@opentui/core": { From d441e931f995d6be39058dfd9b19e5603385af36 Mon Sep 17 00:00:00 2001 From: Sebastian Date: Sat, 16 May 2026 03:11:46 +0200 Subject: [PATCH 065/164] add dialog prompt submit keybind (#27807) --- .../src/cli/cmd/tui/config/keybind.ts | 1 + .../src/cli/cmd/tui/ui/dialog-prompt.tsx | 41 ++++- .../test/cli/tui/dialog-prompt.test.tsx | 146 ++++++++++++++++++ packages/opencode/test/config/tui.test.ts | 2 + packages/web/src/content/docs/keybinds.mdx | 1 + 5 files changed, 183 insertions(+), 8 deletions(-) create mode 100644 packages/opencode/test/cli/tui/dialog-prompt.test.tsx diff --git a/packages/opencode/src/cli/cmd/tui/config/keybind.ts b/packages/opencode/src/cli/cmd/tui/config/keybind.ts index bd26cd5d95e..a3755738283 100644 --- a/packages/opencode/src/cli/cmd/tui/config/keybind.ts +++ b/packages/opencode/src/cli/cmd/tui/config/keybind.ts @@ -188,6 +188,7 @@ export const Definitions = { "dialog.select.home": keybind("home", "Move to first dialog item"), "dialog.select.end": keybind("end", "Move to last dialog item"), "dialog.select.submit": keybind("return", "Submit selected dialog item"), + "dialog.prompt.submit": keybind("return", "Submit dialog prompt"), "dialog.mcp.toggle": keybind("space", "Toggle MCP in MCP dialog"), "prompt.autocomplete.prev": keybind("up,ctrl+p", "Move to previous autocomplete item"), "prompt.autocomplete.next": keybind("down,ctrl+n", "Move to next autocomplete item"), diff --git a/packages/opencode/src/cli/cmd/tui/ui/dialog-prompt.tsx b/packages/opencode/src/cli/cmd/tui/ui/dialog-prompt.tsx index 34ab9161f6a..dfd8091852f 100644 --- a/packages/opencode/src/cli/cmd/tui/ui/dialog-prompt.tsx +++ b/packages/opencode/src/cli/cmd/tui/ui/dialog-prompt.tsx @@ -1,8 +1,10 @@ import { TextareaRenderable, TextAttributes } from "@opentui/core" import { useTheme } from "../context/theme" import { useDialog, type DialogContext } from "./dialog" -import { Show, createEffect, onMount, type JSX } from "solid-js" +import { Show, createEffect, createSignal, onMount, type JSX } from "solid-js" import { Spinner } from "../component/spinner" +import { useTuiConfig } from "../context/tui-config" +import { useBindings, useCommandShortcut } from "../keymap" export type DialogPromptProps = { title: string @@ -18,8 +20,32 @@ export type DialogPromptProps = { export function DialogPrompt(props: DialogPromptProps) { const dialog = useDialog() const { theme } = useTheme() + const tuiConfig = useTuiConfig() + const submitShortcut = useCommandShortcut("dialog.prompt.submit") + const [textareaTarget, setTextareaTarget] = createSignal() let textarea: TextareaRenderable + function confirm() { + if (props.busy) return + props.onConfirm?.(textarea.plainText) + } + + useBindings(() => ({ + target: textareaTarget, + enabled: textareaTarget() !== undefined && !props.busy, + // Dialog form semantics must win over the global managed textarea input layer. + priority: 1, + commands: [ + { + name: "dialog.prompt.submit", + title: "Submit dialog prompt", + category: "Dialog", + run: confirm, + }, + ], + bindings: tuiConfig.keybinds.gather("dialog.prompt", ["dialog.prompt.submit"]), + })) + onMount(() => { dialog.setSize("medium") setTimeout(() => { @@ -59,13 +85,10 @@ export function DialogPrompt(props: DialogPromptProps) { {props.description}