From 46da801f3066f253ac9ba78229add9931c7b48e1 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 9 Apr 2026 10:54:26 -0400 Subject: [PATCH 01/29] refactor(effect): drop shell abort signals from runner (#21599) --- packages/opencode/src/effect/runner.ts | 20 +++---- packages/opencode/src/session/prompt.ts | 4 +- packages/opencode/test/effect/runner.test.ts | 56 +++++--------------- 3 files changed, 22 insertions(+), 58 deletions(-) diff --git a/packages/opencode/src/effect/runner.ts b/packages/opencode/src/effect/runner.ts index cb12b4c52ba9..38c45a6342eb 100644 --- a/packages/opencode/src/effect/runner.ts +++ b/packages/opencode/src/effect/runner.ts @@ -1,10 +1,10 @@ -import { Cause, Deferred, Effect, Exit, Fiber, Option, Schema, Scope, SynchronizedRef } from "effect" +import { Cause, Deferred, Effect, Exit, Fiber, Schema, Scope, SynchronizedRef } from "effect" export interface Runner { readonly state: Runner.State readonly busy: boolean readonly ensureRunning: (work: Effect.Effect) => Effect.Effect - readonly startShell: (work: (signal: AbortSignal) => Effect.Effect) => Effect.Effect + readonly startShell: (work: Effect.Effect) => Effect.Effect readonly cancel: Effect.Effect } @@ -20,7 +20,6 @@ export namespace Runner { interface ShellHandle { id: number fiber: Fiber.Fiber - abort: AbortController } interface PendingHandle { @@ -100,13 +99,7 @@ export namespace Runner { }), ).pipe(Effect.flatten) - const stopShell = (shell: ShellHandle) => - Effect.gen(function* () { - shell.abort.abort() - const exit = yield* Fiber.await(shell.fiber).pipe(Effect.timeoutOption("100 millis")) - if (Option.isNone(exit)) yield* Fiber.interrupt(shell.fiber) - yield* Fiber.await(shell.fiber).pipe(Effect.exit, Effect.asVoid) - }) + const stopShell = (shell: ShellHandle) => Fiber.interrupt(shell.fiber) const ensureRunning = (work: Effect.Effect) => SynchronizedRef.modifyEffect( @@ -138,7 +131,7 @@ export namespace Runner { ), ) - const startShell = (work: (signal: AbortSignal) => Effect.Effect) => + const startShell = (work: Effect.Effect) => SynchronizedRef.modifyEffect( ref, Effect.fnUntraced(function* (st) { @@ -153,9 +146,8 @@ export namespace Runner { } yield* busy const id = next() - const abort = new AbortController() - const fiber = yield* work(abort.signal).pipe(Effect.ensuring(finishShell(id)), Effect.forkChild) - const shell = { id, fiber, abort } satisfies ShellHandle + const fiber = yield* work.pipe(Effect.ensuring(finishShell(id)), Effect.forkChild) + const shell = { id, fiber } satisfies ShellHandle return [ Effect.gen(function* () { const exit = yield* Fiber.await(fiber) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index dc75efcdc9df..19f0850ff4c2 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -743,7 +743,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the } satisfies MessageV2.TextPart) }) - const shellImpl = Effect.fn("SessionPrompt.shellImpl")(function* (input: ShellInput, signal: AbortSignal) { + const shellImpl = Effect.fn("SessionPrompt.shellImpl")(function* (input: ShellInput) { const ctx = yield* InstanceState.context const session = yield* sessions.get(input.sessionID) if (session.revert) { @@ -1577,7 +1577,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the function* (input: ShellInput) { const s = yield* InstanceState.get(state) const runner = getRunner(s.runners, input.sessionID) - return yield* runner.startShell((signal) => shellImpl(input, signal)) + return yield* runner.startShell(shellImpl(input)) }, ) diff --git a/packages/opencode/test/effect/runner.test.ts b/packages/opencode/test/effect/runner.test.ts index 9dc395876ee0..a91df76ebf27 100644 --- a/packages/opencode/test/effect/runner.test.ts +++ b/packages/opencode/test/effect/runner.test.ts @@ -250,7 +250,7 @@ describe("Runner", () => { Effect.gen(function* () { const s = yield* Scope.Scope const runner = Runner.make(s) - const result = yield* runner.startShell((_signal) => Effect.succeed("shell-done")) + const result = yield* runner.startShell(Effect.succeed("shell-done")) expect(result).toBe("shell-done") expect(runner.busy).toBe(false) }), @@ -264,7 +264,7 @@ describe("Runner", () => { const fiber = yield* runner.ensureRunning(Effect.never.pipe(Effect.as("x"))).pipe(Effect.forkChild) yield* Effect.sleep("10 millis") - const exit = yield* runner.startShell((_s) => Effect.succeed("nope")).pipe(Effect.exit) + const exit = yield* runner.startShell(Effect.succeed("nope")).pipe(Effect.exit) expect(Exit.isFailure(exit)).toBe(true) yield* runner.cancel @@ -279,12 +279,10 @@ describe("Runner", () => { const runner = Runner.make(s) const gate = yield* Deferred.make() - const sh = yield* runner - .startShell((_signal) => Deferred.await(gate).pipe(Effect.as("first"))) - .pipe(Effect.forkChild) + const sh = yield* runner.startShell(Deferred.await(gate).pipe(Effect.as("first"))).pipe(Effect.forkChild) yield* Effect.sleep("10 millis") - const exit = yield* runner.startShell((_s) => Effect.succeed("second")).pipe(Effect.exit) + const exit = yield* runner.startShell(Effect.succeed("second")).pipe(Effect.exit) expect(Exit.isFailure(exit)).toBe(true) yield* Deferred.succeed(gate, undefined) @@ -302,37 +300,26 @@ describe("Runner", () => { }, }) - const sh = yield* runner - .startShell((signal) => - Effect.promise( - () => - new Promise((resolve) => { - signal.addEventListener("abort", () => resolve("aborted"), { once: true }) - }), - ), - ) - .pipe(Effect.forkChild) + const sh = yield* runner.startShell(Effect.never.pipe(Effect.as("aborted"))).pipe(Effect.forkChild) yield* Effect.sleep("10 millis") - const exit = yield* runner.startShell((_s) => Effect.succeed("second")).pipe(Effect.exit) + const exit = yield* runner.startShell(Effect.succeed("second")).pipe(Effect.exit) expect(Exit.isFailure(exit)).toBe(true) yield* runner.cancel const done = yield* Fiber.await(sh) - expect(Exit.isSuccess(done)).toBe(true) + expect(Exit.isFailure(done)).toBe(true) }), ) it.live( - "cancel interrupts shell that ignores abort signal", + "cancel interrupts shell", Effect.gen(function* () { const s = yield* Scope.Scope const runner = Runner.make(s) const gate = yield* Deferred.make() - const sh = yield* runner - .startShell((_signal) => Deferred.await(gate).pipe(Effect.as("ignored"))) - .pipe(Effect.forkChild) + const sh = yield* runner.startShell(Deferred.await(gate).pipe(Effect.as("ignored"))).pipe(Effect.forkChild) yield* Effect.sleep("10 millis") const stop = yield* runner.cancel.pipe(Effect.forkChild) @@ -356,9 +343,7 @@ describe("Runner", () => { const runner = Runner.make(s) const gate = yield* Deferred.make() - const sh = yield* runner - .startShell((_signal) => Deferred.await(gate).pipe(Effect.as("shell-result"))) - .pipe(Effect.forkChild) + const sh = yield* runner.startShell(Deferred.await(gate).pipe(Effect.as("shell-result"))).pipe(Effect.forkChild) yield* Effect.sleep("10 millis") expect(runner.state._tag).toBe("Shell") @@ -384,9 +369,7 @@ describe("Runner", () => { const calls = yield* Ref.make(0) const gate = yield* Deferred.make() - const sh = yield* runner - .startShell((_signal) => Deferred.await(gate).pipe(Effect.as("shell"))) - .pipe(Effect.forkChild) + const sh = yield* runner.startShell(Deferred.await(gate).pipe(Effect.as("shell"))).pipe(Effect.forkChild) yield* Effect.sleep("10 millis") const work = Effect.gen(function* () { @@ -414,16 +397,7 @@ describe("Runner", () => { const runner = Runner.make(s) const gate = yield* Deferred.make() - const sh = yield* runner - .startShell((signal) => - Effect.promise( - () => - new Promise((resolve) => { - signal.addEventListener("abort", () => resolve("aborted"), { once: true }) - }), - ), - ) - .pipe(Effect.forkChild) + const sh = yield* runner.startShell(Effect.never.pipe(Effect.as("aborted"))).pipe(Effect.forkChild) yield* Effect.sleep("10 millis") const run = yield* runner.ensureRunning(Effect.succeed("y")).pipe(Effect.forkChild) @@ -478,7 +452,7 @@ describe("Runner", () => { const runner = Runner.make(s, { onBusy: Ref.update(count, (n) => n + 1), }) - yield* runner.startShell((_signal) => Effect.succeed("done")) + yield* runner.startShell(Effect.succeed("done")) expect(yield* Ref.get(count)).toBe(1) }), ) @@ -509,9 +483,7 @@ describe("Runner", () => { const runner = Runner.make(s) const gate = yield* Deferred.make() - const fiber = yield* runner - .startShell((_signal) => Deferred.await(gate).pipe(Effect.as("ok"))) - .pipe(Effect.forkChild) + const fiber = yield* runner.startShell(Deferred.await(gate).pipe(Effect.as("ok"))).pipe(Effect.forkChild) yield* Effect.sleep("10 millis") expect(runner.busy).toBe(true) From bd53b651a305dd62ffbb5b5a333394df36bb20ab Mon Sep 17 00:00:00 2001 From: Dax Date: Thu, 9 Apr 2026 11:56:19 -0400 Subject: [PATCH 02/29] refactor: fix tool call state handling and clean up imports (#21709) --- .../opencode/src/server/routes/session.ts | 1 - packages/opencode/src/session/index.ts | 31 ++++++++++++++++++- packages/opencode/src/session/processor.ts | 17 ++++++++-- packages/opencode/src/tool/task.ts | 1 + packages/sdk/js/src/v2/index.ts | 1 - 5 files changed, 45 insertions(+), 6 deletions(-) diff --git a/packages/opencode/src/server/routes/session.ts b/packages/opencode/src/server/routes/session.ts index 68be1a6a5a2d..b57ed9d47c0b 100644 --- a/packages/opencode/src/server/routes/session.ts +++ b/packages/opencode/src/server/routes/session.ts @@ -121,7 +121,6 @@ export const SessionRoutes = lazy(() => ), async (c) => { const sessionID = c.req.valid("param").sessionID - log.info("SEARCH", { url: c.req.url }) const session = await Session.get(sessionID) return c.json(session) }, diff --git a/packages/opencode/src/session/index.ts b/packages/opencode/src/session/index.ts index 65032de96252..8e1ed9dcc9b4 100644 --- a/packages/opencode/src/session/index.ts +++ b/packages/opencode/src/session/index.ts @@ -12,7 +12,7 @@ import { Installation } from "../installation" import { Database, NotFoundError, eq, and, gte, isNull, desc, like, inArray, lt } from "../storage/db" import { SyncEvent } from "../sync" import type { SQL } from "../storage/db" -import { SessionTable } from "./session.sql" +import { PartTable, SessionTable } from "./session.sql" import { ProjectTable } from "../project/project.sql" import { Storage } from "@/storage/storage" import { Log } from "../util/log" @@ -345,6 +345,11 @@ export namespace Session { messageID: MessageID partID: PartID }) => Effect.Effect + readonly getPart: (input: { + sessionID: SessionID + messageID: MessageID + partID: PartID + }) => Effect.Effect readonly updatePart: (part: T) => Effect.Effect readonly updatePartDelta: (input: { sessionID: SessionID @@ -492,6 +497,29 @@ export namespace Session { return part }).pipe(Effect.withSpan("Session.updatePart")) + const getPart: Interface["getPart"] = Effect.fn("Session.getPart")(function* (input) { + const row = Database.use((db) => + db + .select() + .from(PartTable) + .where( + and( + eq(PartTable.session_id, input.sessionID), + eq(PartTable.message_id, input.messageID), + eq(PartTable.id, input.partID), + ), + ) + .get(), + ) + if (!row) return + return { + ...row.data, + id: row.id, + sessionID: row.session_id, + messageID: row.message_id, + } as MessageV2.Part + }) + const create = Effect.fn("Session.create")(function* (input?: { parentID?: SessionID title?: string @@ -675,6 +703,7 @@ export namespace Session { removeMessage, removePart, updatePart, + getPart, updatePartDelta, initialize, }) diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index 497747a5df6c..225961aef05d 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -176,12 +176,22 @@ export namespace SessionProcessor { if (ctx.assistantMessage.summary) { throw new Error(`Tool call not allowed while generating summary: ${value.toolName}`) } - const match = ctx.toolcalls[value.toolCallId] - if (!match) return + const pointer = ctx.toolcalls[value.toolCallId] + const match = yield* session.getPart({ + partID: pointer.id, + messageID: pointer.messageID, + sessionID: pointer.sessionID, + }) + if (!match || match.type !== "tool") return ctx.toolcalls[value.toolCallId] = yield* session.updatePart({ ...match, tool: value.toolName, - state: { status: "running", input: value.input, time: { start: Date.now() } }, + state: { + ...match.state, + status: "running", + input: value.input, + time: { start: Date.now() }, + }, metadata: match.metadata?.providerExecuted ? { ...value.providerMetadata, providerExecuted: true } : value.providerMetadata, @@ -237,6 +247,7 @@ export namespace SessionProcessor { case "tool-error": { const match = ctx.toolcalls[value.toolCallId] if (!match || match.state.status !== "running") return + yield* session.updatePart({ ...match, state: { diff --git a/packages/opencode/src/tool/task.ts b/packages/opencode/src/tool/task.ts index 73b55a2fbad3..b97b53bb9f68 100644 --- a/packages/opencode/src/tool/task.ts +++ b/packages/opencode/src/tool/task.ts @@ -9,6 +9,7 @@ import { SessionPrompt } from "../session/prompt" import { Config } from "../config/config" import { Permission } from "@/permission" import { Effect } from "effect" +import { Log } from "@/util/log" const id = "task" diff --git a/packages/sdk/js/src/v2/index.ts b/packages/sdk/js/src/v2/index.ts index d514784bc292..9615eacc7abe 100644 --- a/packages/sdk/js/src/v2/index.ts +++ b/packages/sdk/js/src/v2/index.ts @@ -6,7 +6,6 @@ import { createOpencodeServer } from "./server.js" import type { ServerOptions } from "./server.js" export * as data from "./data.js" -import * as data from "./data.js" export async function createOpencode(options?: ServerOptions) { const server = await createOpencodeServer({ From 5d3dba666ca5346510163881a3943a4608fe886d Mon Sep 17 00:00:00 2001 From: opencode Date: Thu, 9 Apr 2026 16:24:43 +0000 Subject: [PATCH 03/29] release: v1.4.2 --- bun.lock | 32 +++++++++++++------------- 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/desktop-electron/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/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/util/package.json | 2 +- packages/web/package.json | 2 +- sdks/vscode/package.json | 2 +- 19 files changed, 39 insertions(+), 39 deletions(-) diff --git a/bun.lock b/bun.lock index 483f551d31dd..c700ba66ecda 100644 --- a/bun.lock +++ b/bun.lock @@ -27,7 +27,7 @@ }, "packages/app": { "name": "@opencode-ai/app", - "version": "1.4.1", + "version": "1.4.2", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/sdk": "workspace:*", @@ -81,7 +81,7 @@ }, "packages/console/app": { "name": "@opencode-ai/console-app", - "version": "1.4.1", + "version": "1.4.2", "dependencies": { "@cloudflare/vite-plugin": "1.15.2", "@ibm/plex": "6.4.1", @@ -115,7 +115,7 @@ }, "packages/console/core": { "name": "@opencode-ai/console-core", - "version": "1.4.1", + "version": "1.4.2", "dependencies": { "@aws-sdk/client-sts": "3.782.0", "@jsx-email/render": "1.1.1", @@ -142,7 +142,7 @@ }, "packages/console/function": { "name": "@opencode-ai/console-function", - "version": "1.4.1", + "version": "1.4.2", "dependencies": { "@ai-sdk/anthropic": "3.0.64", "@ai-sdk/openai": "3.0.48", @@ -166,7 +166,7 @@ }, "packages/console/mail": { "name": "@opencode-ai/console-mail", - "version": "1.4.1", + "version": "1.4.2", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", @@ -190,7 +190,7 @@ }, "packages/desktop": { "name": "@opencode-ai/desktop", - "version": "1.4.1", + "version": "1.4.2", "dependencies": { "@opencode-ai/app": "workspace:*", "@opencode-ai/ui": "workspace:*", @@ -223,7 +223,7 @@ }, "packages/desktop-electron": { "name": "@opencode-ai/desktop-electron", - "version": "1.4.1", + "version": "1.4.2", "dependencies": { "effect": "catalog:", "electron-context-menu": "4.1.2", @@ -266,7 +266,7 @@ }, "packages/enterprise": { "name": "@opencode-ai/enterprise", - "version": "1.4.1", + "version": "1.4.2", "dependencies": { "@opencode-ai/ui": "workspace:*", "@opencode-ai/util": "workspace:*", @@ -295,7 +295,7 @@ }, "packages/function": { "name": "@opencode-ai/function", - "version": "1.4.1", + "version": "1.4.2", "dependencies": { "@octokit/auth-app": "8.0.1", "@octokit/rest": "catalog:", @@ -311,7 +311,7 @@ }, "packages/opencode": { "name": "opencode", - "version": "1.4.1", + "version": "1.4.2", "bin": { "opencode": "./bin/opencode", }, @@ -447,7 +447,7 @@ }, "packages/plugin": { "name": "@opencode-ai/plugin", - "version": "1.4.1", + "version": "1.4.2", "dependencies": { "@opencode-ai/sdk": "workspace:*", "zod": "catalog:", @@ -481,7 +481,7 @@ }, "packages/sdk/js": { "name": "@opencode-ai/sdk", - "version": "1.4.1", + "version": "1.4.2", "dependencies": { "cross-spawn": "catalog:", }, @@ -496,7 +496,7 @@ }, "packages/slack": { "name": "@opencode-ai/slack", - "version": "1.4.1", + "version": "1.4.2", "dependencies": { "@opencode-ai/sdk": "workspace:*", "@slack/bolt": "^3.17.1", @@ -531,7 +531,7 @@ }, "packages/ui": { "name": "@opencode-ai/ui", - "version": "1.4.1", + "version": "1.4.2", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/sdk": "workspace:*", @@ -580,7 +580,7 @@ }, "packages/util": { "name": "@opencode-ai/util", - "version": "1.4.1", + "version": "1.4.2", "dependencies": { "zod": "catalog:", }, @@ -591,7 +591,7 @@ }, "packages/web": { "name": "@opencode-ai/web", - "version": "1.4.1", + "version": "1.4.2", "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 a052793d8238..3e12c492b6ce 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/app", - "version": "1.4.1", + "version": "1.4.2", "description": "", "type": "module", "exports": { diff --git a/packages/console/app/package.json b/packages/console/app/package.json index 786c2baeda95..dc3362e979a7 100644 --- a/packages/console/app/package.json +++ b/packages/console/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-app", - "version": "1.4.1", + "version": "1.4.2", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/console/core/package.json b/packages/console/core/package.json index 5184c2fc0a80..9059734ccd0a 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.4.1", + "version": "1.4.2", "private": true, "type": "module", "license": "MIT", diff --git a/packages/console/function/package.json b/packages/console/function/package.json index b34fa7377759..d8f0e0c0e229 100644 --- a/packages/console/function/package.json +++ b/packages/console/function/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-function", - "version": "1.4.1", + "version": "1.4.2", "$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 04a0a06bae9b..0976bb3e21c3 100644 --- a/packages/console/mail/package.json +++ b/packages/console/mail/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-mail", - "version": "1.4.1", + "version": "1.4.2", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", diff --git a/packages/desktop-electron/package.json b/packages/desktop-electron/package.json index f8274a4759f8..48c17d0df2f2 100644 --- a/packages/desktop-electron/package.json +++ b/packages/desktop-electron/package.json @@ -1,7 +1,7 @@ { "name": "@opencode-ai/desktop-electron", "private": true, - "version": "1.4.1", + "version": "1.4.2", "type": "module", "license": "MIT", "homepage": "https://opencode.ai", diff --git a/packages/desktop/package.json b/packages/desktop/package.json index 016a205bdbb6..943cbeb203bb 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@opencode-ai/desktop", "private": true, - "version": "1.4.1", + "version": "1.4.2", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/enterprise/package.json b/packages/enterprise/package.json index c9d98dc03908..10a909c8c653 100644 --- a/packages/enterprise/package.json +++ b/packages/enterprise/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/enterprise", - "version": "1.4.1", + "version": "1.4.2", "private": true, "type": "module", "license": "MIT", diff --git a/packages/extensions/zed/extension.toml b/packages/extensions/zed/extension.toml index 7c49722a587e..95fc2b1c538c 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.4.1" +version = "1.4.2" 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.4.1/opencode-darwin-arm64.zip" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.2/opencode-darwin-arm64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.darwin-x86_64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.1/opencode-darwin-x64.zip" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.2/opencode-darwin-x64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.linux-aarch64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.1/opencode-linux-arm64.tar.gz" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.2/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.4.1/opencode-linux-x64.tar.gz" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.2/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.4.1/opencode-windows-x64.zip" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.2/opencode-windows-x64.zip" cmd = "./opencode.exe" args = ["acp"] diff --git a/packages/function/package.json b/packages/function/package.json index baeee69438f7..dd54e9c0922f 100644 --- a/packages/function/package.json +++ b/packages/function/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/function", - "version": "1.4.1", + "version": "1.4.2", "$schema": "https://json.schemastore.org/package.json", "private": true, "type": "module", diff --git a/packages/opencode/package.json b/packages/opencode/package.json index f842a97bf592..934ef0869c9b 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.4.1", + "version": "1.4.2", "name": "opencode", "type": "module", "license": "MIT", diff --git a/packages/plugin/package.json b/packages/plugin/package.json index 0b52bbd47cf7..adfee9c65c4c 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.4.1", + "version": "1.4.2", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/sdk/js/package.json b/packages/sdk/js/package.json index a3aa709a712e..3dbab2c54ace 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.4.1", + "version": "1.4.2", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/slack/package.json b/packages/slack/package.json index 4e3e54800be5..d23b740cdaa9 100644 --- a/packages/slack/package.json +++ b/packages/slack/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/slack", - "version": "1.4.1", + "version": "1.4.2", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/ui/package.json b/packages/ui/package.json index 8de6ea0d43bf..01c6fae86982 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/ui", - "version": "1.4.1", + "version": "1.4.2", "type": "module", "license": "MIT", "exports": { diff --git a/packages/util/package.json b/packages/util/package.json index 105098595eb1..53c170f1448b 100644 --- a/packages/util/package.json +++ b/packages/util/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/util", - "version": "1.4.1", + "version": "1.4.2", "private": true, "type": "module", "license": "MIT", diff --git a/packages/web/package.json b/packages/web/package.json index de36ca6574a3..d7f627c57ab2 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.4.1", + "version": "1.4.2", "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 afe506d0ea11..1d0472d787ea 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.4.1", + "version": "1.4.2", "publisher": "sst-dev", "repository": { "type": "git", From a7743e646736f3fe7ae92c6a54c5ac125ba5ce70 Mon Sep 17 00:00:00 2001 From: Aleksandr Lossenko Date: Thu, 9 Apr 2026 18:45:52 +0200 Subject: [PATCH 04/29] feat(mcp): add OAuth redirect URI configuration for MCP servers (#21385) Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> --- packages/opencode/src/cli/cmd/mcp.ts | 1 + packages/opencode/src/config/config.ts | 4 +++ packages/opencode/src/mcp/index.ts | 9 +++-- packages/opencode/src/mcp/oauth-callback.ts | 36 +++++++++++++------ packages/opencode/src/mcp/oauth-provider.ts | 23 ++++++++++++ .../opencode/test/mcp/oauth-callback.test.ts | 34 ++++++++++++++++++ 6 files changed, 95 insertions(+), 12 deletions(-) create mode 100644 packages/opencode/test/mcp/oauth-callback.test.ts diff --git a/packages/opencode/src/cli/cmd/mcp.ts b/packages/opencode/src/cli/cmd/mcp.ts index c45b9e55d0f8..41e498102e7e 100644 --- a/packages/opencode/src/cli/cmd/mcp.ts +++ b/packages/opencode/src/cli/cmd/mcp.ts @@ -688,6 +688,7 @@ export const McpDebugCommand = cmd({ clientId: oauthConfig?.clientId, clientSecret: oauthConfig?.clientSecret, scope: oauthConfig?.scope, + redirectUri: oauthConfig?.redirectUri, }, { onRedirect: async () => {}, diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 1952e3b57249..ff79b739fe10 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -399,6 +399,10 @@ export namespace Config { .describe("OAuth client ID. If not provided, dynamic client registration (RFC 7591) will be attempted."), clientSecret: z.string().optional().describe("OAuth client secret (if required by the authorization server)"), scope: z.string().optional().describe("OAuth scopes to request during authorization"), + redirectUri: z + .string() + .optional() + .describe("OAuth redirect URI (default: http://127.0.0.1:19876/mcp/oauth/callback)."), }) .strict() .meta({ diff --git a/packages/opencode/src/mcp/index.ts b/packages/opencode/src/mcp/index.ts index 45e3e2567a28..3196c877680d 100644 --- a/packages/opencode/src/mcp/index.ts +++ b/packages/opencode/src/mcp/index.ts @@ -286,6 +286,7 @@ export namespace MCP { clientId: oauthConfig?.clientId, clientSecret: oauthConfig?.clientSecret, scope: oauthConfig?.scope, + redirectUri: oauthConfig?.redirectUri, }, { onRedirect: async (url) => { @@ -716,13 +717,16 @@ export namespace MCP { if (mcpConfig.type !== "remote") throw new Error(`MCP server ${mcpName} is not a remote server`) if (mcpConfig.oauth === false) throw new Error(`MCP server ${mcpName} has OAuth explicitly disabled`) - yield* Effect.promise(() => McpOAuthCallback.ensureRunning()) + // OAuth config is optional - if not provided, we'll use auto-discovery + const oauthConfig = typeof mcpConfig.oauth === "object" ? mcpConfig.oauth : undefined + + // Start the callback server with custom redirectUri if configured + yield* Effect.promise(() => McpOAuthCallback.ensureRunning(oauthConfig?.redirectUri)) const oauthState = Array.from(crypto.getRandomValues(new Uint8Array(32))) .map((b) => b.toString(16).padStart(2, "0")) .join("") yield* auth.updateOAuthState(mcpName, oauthState) - const oauthConfig = typeof mcpConfig.oauth === "object" ? mcpConfig.oauth : undefined let capturedUrl: URL | undefined const authProvider = new McpOAuthProvider( mcpName, @@ -731,6 +735,7 @@ export namespace MCP { clientId: oauthConfig?.clientId, clientSecret: oauthConfig?.clientSecret, scope: oauthConfig?.scope, + redirectUri: oauthConfig?.redirectUri, }, { onRedirect: async (url) => { diff --git a/packages/opencode/src/mcp/oauth-callback.ts b/packages/opencode/src/mcp/oauth-callback.ts index dd1d886fc1b4..b5b6a7a6ebbc 100644 --- a/packages/opencode/src/mcp/oauth-callback.ts +++ b/packages/opencode/src/mcp/oauth-callback.ts @@ -1,10 +1,14 @@ import { createConnection } from "net" import { createServer } from "http" import { Log } from "../util/log" -import { OAUTH_CALLBACK_PORT, OAUTH_CALLBACK_PATH } from "./oauth-provider" +import { OAUTH_CALLBACK_PORT, OAUTH_CALLBACK_PATH, parseRedirectUri } from "./oauth-provider" const log = Log.create({ service: "mcp.oauth-callback" }) +// Current callback server configuration (may differ from defaults if custom redirectUri is used) +let currentPort = OAUTH_CALLBACK_PORT +let currentPath = OAUTH_CALLBACK_PATH + const HTML_SUCCESS = ` @@ -71,9 +75,9 @@ export namespace McpOAuthCallback { } function handleRequest(req: import("http").IncomingMessage, res: import("http").ServerResponse) { - const url = new URL(req.url || "/", `http://localhost:${OAUTH_CALLBACK_PORT}`) + const url = new URL(req.url || "/", `http://localhost:${currentPort}`) - if (url.pathname !== OAUTH_CALLBACK_PATH) { + if (url.pathname !== currentPath) { res.writeHead(404) res.end("Not found") return @@ -135,19 +139,31 @@ export namespace McpOAuthCallback { res.end(HTML_SUCCESS) } - export async function ensureRunning(): Promise { + export async function ensureRunning(redirectUri?: string): Promise { + // Parse the redirect URI to get port and path (uses defaults if not provided) + const { port, path } = parseRedirectUri(redirectUri) + + // If server is running on a different port/path, stop it first + if (server && (currentPort !== port || currentPath !== path)) { + log.info("stopping oauth callback server to reconfigure", { oldPort: currentPort, newPort: port }) + await stop() + } + if (server) return - const running = await isPortInUse() + const running = await isPortInUse(port) if (running) { - log.info("oauth callback server already running on another instance", { port: OAUTH_CALLBACK_PORT }) + log.info("oauth callback server already running on another instance", { port }) return } + currentPort = port + currentPath = path + server = createServer(handleRequest) await new Promise((resolve, reject) => { - server!.listen(OAUTH_CALLBACK_PORT, () => { - log.info("oauth callback server started", { port: OAUTH_CALLBACK_PORT }) + server!.listen(currentPort, () => { + log.info("oauth callback server started", { port: currentPort, path: currentPath }) resolve() }) server!.on("error", reject) @@ -182,9 +198,9 @@ export namespace McpOAuthCallback { } } - export async function isPortInUse(): Promise { + export async function isPortInUse(port: number = OAUTH_CALLBACK_PORT): Promise { return new Promise((resolve) => { - const socket = createConnection(OAUTH_CALLBACK_PORT, "127.0.0.1") + const socket = createConnection(port, "127.0.0.1") socket.on("connect", () => { socket.destroy() resolve(true) diff --git a/packages/opencode/src/mcp/oauth-provider.ts b/packages/opencode/src/mcp/oauth-provider.ts index b4da73169e1f..d675fc71e469 100644 --- a/packages/opencode/src/mcp/oauth-provider.ts +++ b/packages/opencode/src/mcp/oauth-provider.ts @@ -17,6 +17,7 @@ export interface McpOAuthConfig { clientId?: string clientSecret?: string scope?: string + redirectUri?: string } export interface McpOAuthCallbacks { @@ -32,6 +33,9 @@ export class McpOAuthProvider implements OAuthClientProvider { ) {} get redirectUrl(): string { + if (this.config.redirectUri) { + return this.config.redirectUri + } return `http://127.0.0.1:${OAUTH_CALLBACK_PORT}${OAUTH_CALLBACK_PATH}` } @@ -183,3 +187,22 @@ export class McpOAuthProvider implements OAuthClientProvider { } export { OAUTH_CALLBACK_PORT, OAUTH_CALLBACK_PATH } + +/** + * Parse a redirect URI to extract port and path for the callback server. + * Returns defaults if the URI can't be parsed. + */ +export function parseRedirectUri(redirectUri?: string): { port: number; path: string } { + if (!redirectUri) { + return { port: OAUTH_CALLBACK_PORT, path: OAUTH_CALLBACK_PATH } + } + + try { + const url = new URL(redirectUri) + const port = url.port ? parseInt(url.port, 10) : url.protocol === "https:" ? 443 : 80 + const path = url.pathname || OAUTH_CALLBACK_PATH + return { port, path } + } catch { + return { port: OAUTH_CALLBACK_PORT, path: OAUTH_CALLBACK_PATH } + } +} diff --git a/packages/opencode/test/mcp/oauth-callback.test.ts b/packages/opencode/test/mcp/oauth-callback.test.ts new file mode 100644 index 000000000000..58a4fa8c86cc --- /dev/null +++ b/packages/opencode/test/mcp/oauth-callback.test.ts @@ -0,0 +1,34 @@ +import { test, expect, describe, afterEach } from "bun:test" +import { McpOAuthCallback } from "../../src/mcp/oauth-callback" +import { parseRedirectUri } from "../../src/mcp/oauth-provider" + +describe("parseRedirectUri", () => { + test("returns defaults when no URI provided", () => { + const result = parseRedirectUri() + expect(result.port).toBe(19876) + expect(result.path).toBe("/mcp/oauth/callback") + }) + + test("parses port and path from URI", () => { + const result = parseRedirectUri("http://127.0.0.1:8080/oauth/callback") + expect(result.port).toBe(8080) + expect(result.path).toBe("/oauth/callback") + }) + + test("returns defaults for invalid URI", () => { + const result = parseRedirectUri("not-a-valid-url") + expect(result.port).toBe(19876) + expect(result.path).toBe("/mcp/oauth/callback") + }) +}) + +describe("McpOAuthCallback.ensureRunning", () => { + afterEach(async () => { + await McpOAuthCallback.stop() + }) + + test("starts server with custom redirectUri port and path", async () => { + await McpOAuthCallback.ensureRunning("http://127.0.0.1:18000/custom/callback") + expect(McpOAuthCallback.isRunning()).toBe(true) + }) +}) From f73e4d5d31343a2e975d4213c45d1b5fa1c58e75 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Thu, 9 Apr 2026 16:47:02 +0000 Subject: [PATCH 05/29] chore: generate --- packages/sdk/js/src/v2/gen/types.gen.ts | 4 ++++ packages/sdk/openapi.json | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 4f226e60cf87..38f4db09a3d6 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -1375,6 +1375,10 @@ export type McpOAuthConfig = { * OAuth scopes to request during authorization */ scope?: string + /** + * OAuth redirect URI (default: http://127.0.0.1:19876/mcp/oauth/callback). + */ + redirectUri?: string } export type McpRemoteConfig = { diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index a0672df2d764..366bc1fc792f 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -10882,6 +10882,10 @@ "scope": { "description": "OAuth scopes to request during authorization", "type": "string" + }, + "redirectUri": { + "description": "OAuth redirect URI (default: http://127.0.0.1:19876/mcp/oauth/callback).", + "type": "string" } }, "additionalProperties": false From 581a7692ffd1a8d675f6f34905db1f6935a2b8c3 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 9 Apr 2026 13:01:08 -0400 Subject: [PATCH 06/29] fix(tui): restore hidden session scrollbar default (#20947) --- packages/opencode/src/cli/cmd/tui/routes/session/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 d4ae8db61c28..32a9d13367d1 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -155,7 +155,7 @@ export function Session() { 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) - const [showScrollbar, setShowScrollbar] = kv.signal("scrollbar_visible", true) + const [showScrollbar, setShowScrollbar] = kv.signal("scrollbar_visible", false) const [diffWrapMode] = kv.signal<"word" | "none">("diff_wrap_mode", "word") const [animationsEnabled, setAnimationsEnabled] = kv.signal("animations_enabled", true) const [showGenericToolOutput, setShowGenericToolOutput] = kv.signal("generic_tool_output_visibility", false) From b0600664abacabc3b6bd41de88859248bc2a2594 Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Thu, 9 Apr 2026 12:06:26 -0500 Subject: [PATCH 07/29] feat: add support for fast modes for claude and gpt models (that support it) (#21706) --- packages/opencode/src/plugin/codex.ts | 4 +- packages/opencode/src/provider/models.ts | 57 +++++++++++----- packages/opencode/src/provider/provider.ts | 61 +++++++++++------ .../opencode/test/provider/provider.test.ts | 68 +++++++++++++++++++ 4 files changed, 152 insertions(+), 38 deletions(-) diff --git a/packages/opencode/src/plugin/codex.ts b/packages/opencode/src/plugin/codex.ts index bdeef9823ff2..1e127fae5489 100644 --- a/packages/opencode/src/plugin/codex.ts +++ b/packages/opencode/src/plugin/codex.ts @@ -376,9 +376,9 @@ export async function CodexAuthPlugin(input: PluginInput): Promise { "gpt-5.4", "gpt-5.4-mini", ]) - for (const modelId of Object.keys(provider.models)) { + for (const [modelId, model] of Object.entries(provider.models)) { if (modelId.includes("codex")) continue - if (allowedModels.has(modelId)) continue + if (allowedModels.has(model.api.id)) continue delete provider.models[modelId] } diff --git a/packages/opencode/src/provider/models.ts b/packages/opencode/src/provider/models.ts index 23f61d804e9c..2d787588b0b5 100644 --- a/packages/opencode/src/provider/models.ts +++ b/packages/opencode/src/provider/models.ts @@ -22,6 +22,27 @@ export namespace ModelsDev { ) const ttl = 5 * 60 * 1000 + type JsonValue = string | number | boolean | null | { [key: string]: JsonValue } | JsonValue[] + + const JsonValue: z.ZodType = z.lazy(() => + z.union([z.string(), z.number(), z.boolean(), z.null(), z.array(JsonValue), z.record(z.string(), JsonValue)]), + ) + + const Cost = z.object({ + input: z.number(), + output: z.number(), + cache_read: z.number().optional(), + cache_write: z.number().optional(), + context_over_200k: z + .object({ + input: z.number(), + output: z.number(), + cache_read: z.number().optional(), + cache_write: z.number().optional(), + }) + .optional(), + }) + export const Model = z.object({ id: z.string(), name: z.string(), @@ -41,22 +62,7 @@ export namespace ModelsDev { .strict(), ]) .optional(), - cost: z - .object({ - input: z.number(), - output: z.number(), - cache_read: z.number().optional(), - cache_write: z.number().optional(), - context_over_200k: z - .object({ - input: z.number(), - output: z.number(), - cache_read: z.number().optional(), - cache_write: z.number().optional(), - }) - .optional(), - }) - .optional(), + cost: Cost.optional(), limit: z.object({ context: z.number(), input: z.number().optional(), @@ -68,7 +74,24 @@ export namespace ModelsDev { output: z.array(z.enum(["text", "audio", "image", "video", "pdf"])), }) .optional(), - experimental: z.boolean().optional(), + experimental: z + .object({ + modes: z + .record( + z.string(), + z.object({ + cost: Cost.optional(), + provider: z + .object({ + body: z.record(z.string(), JsonValue).optional(), + headers: z.record(z.string(), z.string()).optional(), + }) + .optional(), + }), + ) + .optional(), + }) + .optional(), status: z.enum(["alpha", "beta", "deprecated"]).optional(), provider: z.object({ npm: z.string().optional(), api: z.string().optional() }).optional(), }) diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 8d5c9f2ced1c..004fb77f91a0 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -926,6 +926,28 @@ export namespace Provider { export class Service extends ServiceMap.Service()("@opencode/Provider") {} + function cost(c: ModelsDev.Model["cost"]): Model["cost"] { + const result: Model["cost"] = { + input: c?.input ?? 0, + output: c?.output ?? 0, + cache: { + read: c?.cache_read ?? 0, + write: c?.cache_write ?? 0, + }, + } + if (c?.context_over_200k) { + result.experimentalOver200K = { + cache: { + read: c.context_over_200k.cache_read ?? 0, + write: c.context_over_200k.cache_write ?? 0, + }, + input: c.context_over_200k.input, + output: c.context_over_200k.output, + } + } + return result + } + function fromModelsDevModel(provider: ModelsDev.Provider, model: ModelsDev.Model): Model { const m: Model = { id: ModelID.make(model.id), @@ -940,24 +962,7 @@ export namespace Provider { status: model.status ?? "active", headers: {}, options: {}, - cost: { - input: model.cost?.input ?? 0, - output: model.cost?.output ?? 0, - cache: { - read: model.cost?.cache_read ?? 0, - write: model.cost?.cache_write ?? 0, - }, - experimentalOver200K: model.cost?.context_over_200k - ? { - cache: { - read: model.cost.context_over_200k.cache_read ?? 0, - write: model.cost.context_over_200k.cache_write ?? 0, - }, - input: model.cost.context_over_200k.input, - output: model.cost.context_over_200k.output, - } - : undefined, - }, + cost: cost(model.cost), limit: { context: model.limit.context, input: model.limit.input, @@ -994,13 +999,31 @@ export namespace Provider { } export function fromModelsDevProvider(provider: ModelsDev.Provider): Info { + const models: Record = {} + for (const [key, model] of Object.entries(provider.models)) { + models[key] = fromModelsDevModel(provider, model) + for (const [mode, opts] of Object.entries(model.experimental?.modes ?? {})) { + const id = `${model.id}-${mode}` + const m = fromModelsDevModel(provider, model) + m.id = ModelID.make(id) + m.name = `${model.name} ${mode[0].toUpperCase()}${mode.slice(1)}` + if (opts.cost) m.cost = mergeDeep(m.cost, cost(opts.cost)) + // convert body params to camelCase for ai sdk compatibility + if (opts.provider?.body) + m.options = Object.fromEntries( + Object.entries(opts.provider.body).map(([k, v]) => [k.replace(/_([a-z])/g, (_, c) => c.toUpperCase()), v]), + ) + if (opts.provider?.headers) m.headers = opts.provider.headers + models[id] = m + } + } return { id: ProviderID.make(provider.id), source: "custom", name: provider.name, env: provider.env ?? [], options: {}, - models: mapValues(provider.models, (model) => fromModelsDevModel(provider, model)), + models, } } diff --git a/packages/opencode/test/provider/provider.test.ts b/packages/opencode/test/provider/provider.test.ts index 88e9ea64c9de..9cadc391a1f1 100644 --- a/packages/opencode/test/provider/provider.test.ts +++ b/packages/opencode/test/provider/provider.test.ts @@ -6,6 +6,7 @@ import { tmpdir } from "../fixture/fixture" import { Global } from "../../src/global" import { Instance } from "../../src/project/instance" import { Plugin } from "../../src/plugin/index" +import { ModelsDev } from "../../src/provider/models" import { Provider } from "../../src/provider/provider" import { ProviderID, ModelID } from "../../src/provider/schema" import { Filesystem } from "../../src/util/filesystem" @@ -1823,6 +1824,73 @@ test("custom model inherits api.url from models.dev provider", async () => { }) }) +test("mode cost preserves over-200k pricing from base model", () => { + const provider = { + id: "openai", + name: "OpenAI", + env: [], + api: "https://api.openai.com/v1", + models: { + "gpt-5.4": { + id: "gpt-5.4", + name: "GPT-5.4", + family: "gpt", + release_date: "2026-03-05", + attachment: true, + reasoning: true, + temperature: false, + tool_call: true, + cost: { + input: 2.5, + output: 15, + cache_read: 0.25, + context_over_200k: { + input: 5, + output: 22.5, + cache_read: 0.5, + }, + }, + limit: { + context: 1_050_000, + input: 922_000, + output: 128_000, + }, + experimental: { + modes: { + fast: { + cost: { + input: 5, + output: 30, + cache_read: 0.5, + }, + provider: { + body: { + service_tier: "priority", + }, + }, + }, + }, + }, + }, + }, + } as ModelsDev.Provider + + const model = Provider.fromModelsDevProvider(provider).models["gpt-5.4-fast"] + expect(model.cost.input).toEqual(5) + expect(model.cost.output).toEqual(30) + expect(model.cost.cache.read).toEqual(0.5) + expect(model.cost.cache.write).toEqual(0) + expect(model.options["serviceTier"]).toEqual("priority") + expect(model.cost.experimentalOver200K).toEqual({ + input: 5, + output: 22.5, + cache: { + read: 0.5, + write: 0, + }, + }) +}) + test("model variants are generated for reasoning models", async () => { await using tmp = await tmpdir({ init: async (dir) => { From 537160dbc0cfa9e2eb1bc3ea5cf86743a15f8d90 Mon Sep 17 00:00:00 2001 From: Simon Klee Date: Sun, 5 Apr 2026 15:02:29 +0200 Subject: [PATCH 08/29] opencode: lazy-load top-level CLI commands The CLI imports every top-level command before argument parsing has decided which handler will run. This makes simple invocations pay for the full command graph up front and slows down the default startup path. Parse the root argv first and load only the command module that matches the selected top-level command. Keep falling back to the default TUI path for non-command positionals, and preserve root help, version and completion handling --- packages/opencode/src/index.ts | 290 +++++++++++++++++++++++++++------ 1 file changed, 244 insertions(+), 46 deletions(-) diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index 753becc26775..59608f757047 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -1,40 +1,17 @@ import yargs from "yargs" import { hideBin } from "yargs/helpers" -import { RunCommand } from "./cli/cmd/run" -import { GenerateCommand } from "./cli/cmd/generate" import { Log } from "./util/log" -import { ConsoleCommand } from "./cli/cmd/account" -import { ProvidersCommand } from "./cli/cmd/providers" -import { AgentCommand } from "./cli/cmd/agent" -import { UpgradeCommand } from "./cli/cmd/upgrade" -import { UninstallCommand } from "./cli/cmd/uninstall" -import { ModelsCommand } from "./cli/cmd/models" import { UI } from "./cli/ui" import { Installation } from "./installation" import { NamedError } from "@opencode-ai/util/error" import { FormatError } from "./cli/error" -import { ServeCommand } from "./cli/cmd/serve" import { Filesystem } from "./util/filesystem" -import { DebugCommand } from "./cli/cmd/debug" -import { StatsCommand } from "./cli/cmd/stats" -import { McpCommand } from "./cli/cmd/mcp" -import { GithubCommand } from "./cli/cmd/github" -import { ExportCommand } from "./cli/cmd/export" -import { ImportCommand } from "./cli/cmd/import" -import { AttachCommand } from "./cli/cmd/tui/attach" -import { TuiThreadCommand } from "./cli/cmd/tui/thread" -import { AcpCommand } from "./cli/cmd/acp" import { EOL } from "os" -import { WebCommand } from "./cli/cmd/web" -import { PrCommand } from "./cli/cmd/pr" -import { SessionCommand } from "./cli/cmd/session" -import { DbCommand } from "./cli/cmd/db" import path from "path" import { Global } from "./global" import { JsonMigration } from "./storage/json-migration" import { Database } from "./storage/db" import { errorMessage } from "./util/error" -import { PluginCommand } from "./cli/cmd/plug" import { Heap } from "./cli/heap" import { drizzle } from "drizzle-orm/bun-sqlite" @@ -52,6 +29,156 @@ process.on("uncaughtException", (e) => { const args = hideBin(process.argv) +type Mode = + | "all" + | "none" + | "tui" + | "attach" + | "run" + | "acp" + | "mcp" + | "generate" + | "debug" + | "console" + | "providers" + | "agent" + | "upgrade" + | "uninstall" + | "serve" + | "web" + | "models" + | "stats" + | "export" + | "import" + | "github" + | "pr" + | "session" + | "plugin" + | "db" + +const map = new Map([ + ["attach", "attach"], + ["run", "run"], + ["acp", "acp"], + ["mcp", "mcp"], + ["generate", "generate"], + ["debug", "debug"], + ["console", "console"], + ["providers", "providers"], + ["auth", "providers"], + ["agent", "agent"], + ["upgrade", "upgrade"], + ["uninstall", "uninstall"], + ["serve", "serve"], + ["web", "web"], + ["models", "models"], + ["stats", "stats"], + ["export", "export"], + ["import", "import"], + ["github", "github"], + ["pr", "pr"], + ["session", "session"], + ["plugin", "plugin"], + ["plug", "plugin"], + ["db", "db"], +]) + +function flag(arg: string, name: string) { + return arg === `--${name}` || arg === `--no-${name}` || arg.startsWith(`--${name}=`) +} + +function value(arg: string, name: string) { + return arg === `--${name}` || arg.startsWith(`--${name}=`) +} + +// Match the root parser closely enough to decide which top-level module to load. +function pick(argv: string[]): Mode { + for (let i = 0; i < argv.length; i++) { + const arg = argv[i] + if (!arg) continue + if (arg === "--") return "tui" + if (arg === "completion") return "all" + if (arg === "--help" || arg === "-h") return "all" + if (arg === "--version" || arg === "-v") return "none" + if (flag(arg, "print-logs") || flag(arg, "pure")) continue + if (value(arg, "log-level")) { + if (arg === "--log-level") i += 1 + continue + } + if (arg.startsWith("-") && !arg.startsWith("--")) { + if (arg.includes("h")) return "all" + if (arg.includes("v")) return "none" + return "tui" + } + if (arg.startsWith("-")) return "tui" + return map.get(arg) ?? "tui" + } + + return "tui" +} + +const mode = pick(args) +const all = mode === "all" +const none = mode === "none" + +function load(on: boolean, get: () => Promise): Promise { + if (!on) { + return Promise.resolve(undefined) + } + + return get() +} + +const [ + TuiThreadCommand, + AttachCommand, + RunCommand, + AcpCommand, + McpCommand, + GenerateCommand, + DebugCommand, + ConsoleCommand, + ProvidersCommand, + AgentCommand, + UpgradeCommand, + UninstallCommand, + ServeCommand, + WebCommand, + ModelsCommand, + StatsCommand, + ExportCommand, + ImportCommand, + GithubCommand, + PrCommand, + SessionCommand, + PluginCommand, + DbCommand, +] = await Promise.all([ + load(!none && (all || mode === "tui"), () => import("./cli/cmd/tui/thread").then((x) => x.TuiThreadCommand)), + load(!none && (all || mode === "attach"), () => import("./cli/cmd/tui/attach").then((x) => x.AttachCommand)), + load(!none && (all || mode === "run"), () => import("./cli/cmd/run").then((x) => x.RunCommand)), + load(!none && (all || mode === "acp"), () => import("./cli/cmd/acp").then((x) => x.AcpCommand)), + load(!none && (all || mode === "mcp"), () => import("./cli/cmd/mcp").then((x) => x.McpCommand)), + load(!none && (all || mode === "generate"), () => import("./cli/cmd/generate").then((x) => x.GenerateCommand)), + load(!none && (all || mode === "debug"), () => import("./cli/cmd/debug").then((x) => x.DebugCommand)), + load(!none && (all || mode === "console"), () => import("./cli/cmd/account").then((x) => x.ConsoleCommand)), + load(!none && (all || mode === "providers"), () => import("./cli/cmd/providers").then((x) => x.ProvidersCommand)), + load(!none && (all || mode === "agent"), () => import("./cli/cmd/agent").then((x) => x.AgentCommand)), + load(!none && (all || mode === "upgrade"), () => import("./cli/cmd/upgrade").then((x) => x.UpgradeCommand)), + load(!none && (all || mode === "uninstall"), () => import("./cli/cmd/uninstall").then((x) => x.UninstallCommand)), + load(!none && (all || mode === "serve"), () => import("./cli/cmd/serve").then((x) => x.ServeCommand)), + load(!none && (all || mode === "web"), () => import("./cli/cmd/web").then((x) => x.WebCommand)), + load(!none && (all || mode === "models"), () => import("./cli/cmd/models").then((x) => x.ModelsCommand)), + load(!none && (all || mode === "stats"), () => import("./cli/cmd/stats").then((x) => x.StatsCommand)), + load(!none && (all || mode === "export"), () => import("./cli/cmd/export").then((x) => x.ExportCommand)), + load(!none && (all || mode === "import"), () => import("./cli/cmd/import").then((x) => x.ImportCommand)), + load(!none && (all || mode === "github"), () => import("./cli/cmd/github").then((x) => x.GithubCommand)), + load(!none && (all || mode === "pr"), () => import("./cli/cmd/pr").then((x) => x.PrCommand)), + load(!none && (all || mode === "session"), () => import("./cli/cmd/session").then((x) => x.SessionCommand)), + load(!none && (all || mode === "plugin"), () => import("./cli/cmd/plug").then((x) => x.PluginCommand)), + load(!none && (all || mode === "db"), () => import("./cli/cmd/db").then((x) => x.DbCommand)), +]) + function show(out: string) { const text = out.trimStart() if (!text.startsWith("opencode ")) { @@ -148,29 +275,100 @@ const cli = yargs(args) }) .usage("") .completion("completion", "generate shell completion script") - .command(AcpCommand) - .command(McpCommand) - .command(TuiThreadCommand) - .command(AttachCommand) - .command(RunCommand) - .command(GenerateCommand) - .command(DebugCommand) - .command(ConsoleCommand) - .command(ProvidersCommand) - .command(AgentCommand) - .command(UpgradeCommand) - .command(UninstallCommand) - .command(ServeCommand) - .command(WebCommand) - .command(ModelsCommand) - .command(StatsCommand) - .command(ExportCommand) - .command(ImportCommand) - .command(GithubCommand) - .command(PrCommand) - .command(SessionCommand) - .command(PluginCommand) - .command(DbCommand) + +if (TuiThreadCommand) { + cli.command(TuiThreadCommand) +} + +if (AttachCommand) { + cli.command(AttachCommand) +} + +if (AcpCommand) { + cli.command(AcpCommand) +} + +if (McpCommand) { + cli.command(McpCommand) +} + +if (RunCommand) { + cli.command(RunCommand) +} + +if (GenerateCommand) { + cli.command(GenerateCommand) +} + +if (DebugCommand) { + cli.command(DebugCommand) +} + +if (ConsoleCommand) { + cli.command(ConsoleCommand) +} + +if (ProvidersCommand) { + cli.command(ProvidersCommand) +} + +if (AgentCommand) { + cli.command(AgentCommand) +} + +if (UpgradeCommand) { + cli.command(UpgradeCommand) +} + +if (UninstallCommand) { + cli.command(UninstallCommand) +} + +if (ServeCommand) { + cli.command(ServeCommand) +} + +if (WebCommand) { + cli.command(WebCommand) +} + +if (ModelsCommand) { + cli.command(ModelsCommand) +} + +if (StatsCommand) { + cli.command(StatsCommand) +} + +if (ExportCommand) { + cli.command(ExportCommand) +} + +if (ImportCommand) { + cli.command(ImportCommand) +} + +if (GithubCommand) { + cli.command(GithubCommand) +} + +if (PrCommand) { + cli.command(PrCommand) +} + +if (SessionCommand) { + cli.command(SessionCommand) +} + +if (PluginCommand) { + cli.command(PluginCommand) +} + +if (DbCommand) { + cli.command(DbCommand) +} + +cli .fail((msg, err) => { if ( msg?.startsWith("Unknown argument") || From 34b979265427444d7e311773d6957221bd092a32 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 9 Apr 2026 13:52:58 -0400 Subject: [PATCH 09/29] delete unused withALS method (#21723) --- packages/opencode/src/effect/instance-state.ts | 6 ------ 1 file changed, 6 deletions(-) diff --git a/packages/opencode/src/effect/instance-state.ts b/packages/opencode/src/effect/instance-state.ts index cc5901fb5eac..a379d3afc030 100644 --- a/packages/opencode/src/effect/instance-state.ts +++ b/packages/opencode/src/effect/instance-state.ts @@ -73,10 +73,4 @@ export namespace InstanceState { Effect.gen(function* () { return yield* ScopedCache.invalidate(self.cache, yield* directory) }) - - /** - * Effect finalizers run on the fiber scheduler after the original async - * boundary, so ALS reads like Instance.directory can be gone by then. - */ - export const withALS = (fn: () => T) => Effect.map(context, (ctx) => Instance.restore(ctx, fn)) } From 02b32e1ba752fae421def60cd6d6e43fbf42af3a Mon Sep 17 00:00:00 2001 From: Simon Klee Date: Thu, 9 Apr 2026 20:03:48 +0200 Subject: [PATCH 10/29] Revert "opencode: lazy-load top-level CLI commands" (#21726) --- packages/opencode/src/index.ts | 290 ++++++--------------------------- 1 file changed, 46 insertions(+), 244 deletions(-) diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index 59608f757047..753becc26775 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -1,17 +1,40 @@ import yargs from "yargs" import { hideBin } from "yargs/helpers" +import { RunCommand } from "./cli/cmd/run" +import { GenerateCommand } from "./cli/cmd/generate" import { Log } from "./util/log" +import { ConsoleCommand } from "./cli/cmd/account" +import { ProvidersCommand } from "./cli/cmd/providers" +import { AgentCommand } from "./cli/cmd/agent" +import { UpgradeCommand } from "./cli/cmd/upgrade" +import { UninstallCommand } from "./cli/cmd/uninstall" +import { ModelsCommand } from "./cli/cmd/models" import { UI } from "./cli/ui" import { Installation } from "./installation" import { NamedError } from "@opencode-ai/util/error" import { FormatError } from "./cli/error" +import { ServeCommand } from "./cli/cmd/serve" import { Filesystem } from "./util/filesystem" +import { DebugCommand } from "./cli/cmd/debug" +import { StatsCommand } from "./cli/cmd/stats" +import { McpCommand } from "./cli/cmd/mcp" +import { GithubCommand } from "./cli/cmd/github" +import { ExportCommand } from "./cli/cmd/export" +import { ImportCommand } from "./cli/cmd/import" +import { AttachCommand } from "./cli/cmd/tui/attach" +import { TuiThreadCommand } from "./cli/cmd/tui/thread" +import { AcpCommand } from "./cli/cmd/acp" import { EOL } from "os" +import { WebCommand } from "./cli/cmd/web" +import { PrCommand } from "./cli/cmd/pr" +import { SessionCommand } from "./cli/cmd/session" +import { DbCommand } from "./cli/cmd/db" import path from "path" import { Global } from "./global" import { JsonMigration } from "./storage/json-migration" import { Database } from "./storage/db" import { errorMessage } from "./util/error" +import { PluginCommand } from "./cli/cmd/plug" import { Heap } from "./cli/heap" import { drizzle } from "drizzle-orm/bun-sqlite" @@ -29,156 +52,6 @@ process.on("uncaughtException", (e) => { const args = hideBin(process.argv) -type Mode = - | "all" - | "none" - | "tui" - | "attach" - | "run" - | "acp" - | "mcp" - | "generate" - | "debug" - | "console" - | "providers" - | "agent" - | "upgrade" - | "uninstall" - | "serve" - | "web" - | "models" - | "stats" - | "export" - | "import" - | "github" - | "pr" - | "session" - | "plugin" - | "db" - -const map = new Map([ - ["attach", "attach"], - ["run", "run"], - ["acp", "acp"], - ["mcp", "mcp"], - ["generate", "generate"], - ["debug", "debug"], - ["console", "console"], - ["providers", "providers"], - ["auth", "providers"], - ["agent", "agent"], - ["upgrade", "upgrade"], - ["uninstall", "uninstall"], - ["serve", "serve"], - ["web", "web"], - ["models", "models"], - ["stats", "stats"], - ["export", "export"], - ["import", "import"], - ["github", "github"], - ["pr", "pr"], - ["session", "session"], - ["plugin", "plugin"], - ["plug", "plugin"], - ["db", "db"], -]) - -function flag(arg: string, name: string) { - return arg === `--${name}` || arg === `--no-${name}` || arg.startsWith(`--${name}=`) -} - -function value(arg: string, name: string) { - return arg === `--${name}` || arg.startsWith(`--${name}=`) -} - -// Match the root parser closely enough to decide which top-level module to load. -function pick(argv: string[]): Mode { - for (let i = 0; i < argv.length; i++) { - const arg = argv[i] - if (!arg) continue - if (arg === "--") return "tui" - if (arg === "completion") return "all" - if (arg === "--help" || arg === "-h") return "all" - if (arg === "--version" || arg === "-v") return "none" - if (flag(arg, "print-logs") || flag(arg, "pure")) continue - if (value(arg, "log-level")) { - if (arg === "--log-level") i += 1 - continue - } - if (arg.startsWith("-") && !arg.startsWith("--")) { - if (arg.includes("h")) return "all" - if (arg.includes("v")) return "none" - return "tui" - } - if (arg.startsWith("-")) return "tui" - return map.get(arg) ?? "tui" - } - - return "tui" -} - -const mode = pick(args) -const all = mode === "all" -const none = mode === "none" - -function load(on: boolean, get: () => Promise): Promise { - if (!on) { - return Promise.resolve(undefined) - } - - return get() -} - -const [ - TuiThreadCommand, - AttachCommand, - RunCommand, - AcpCommand, - McpCommand, - GenerateCommand, - DebugCommand, - ConsoleCommand, - ProvidersCommand, - AgentCommand, - UpgradeCommand, - UninstallCommand, - ServeCommand, - WebCommand, - ModelsCommand, - StatsCommand, - ExportCommand, - ImportCommand, - GithubCommand, - PrCommand, - SessionCommand, - PluginCommand, - DbCommand, -] = await Promise.all([ - load(!none && (all || mode === "tui"), () => import("./cli/cmd/tui/thread").then((x) => x.TuiThreadCommand)), - load(!none && (all || mode === "attach"), () => import("./cli/cmd/tui/attach").then((x) => x.AttachCommand)), - load(!none && (all || mode === "run"), () => import("./cli/cmd/run").then((x) => x.RunCommand)), - load(!none && (all || mode === "acp"), () => import("./cli/cmd/acp").then((x) => x.AcpCommand)), - load(!none && (all || mode === "mcp"), () => import("./cli/cmd/mcp").then((x) => x.McpCommand)), - load(!none && (all || mode === "generate"), () => import("./cli/cmd/generate").then((x) => x.GenerateCommand)), - load(!none && (all || mode === "debug"), () => import("./cli/cmd/debug").then((x) => x.DebugCommand)), - load(!none && (all || mode === "console"), () => import("./cli/cmd/account").then((x) => x.ConsoleCommand)), - load(!none && (all || mode === "providers"), () => import("./cli/cmd/providers").then((x) => x.ProvidersCommand)), - load(!none && (all || mode === "agent"), () => import("./cli/cmd/agent").then((x) => x.AgentCommand)), - load(!none && (all || mode === "upgrade"), () => import("./cli/cmd/upgrade").then((x) => x.UpgradeCommand)), - load(!none && (all || mode === "uninstall"), () => import("./cli/cmd/uninstall").then((x) => x.UninstallCommand)), - load(!none && (all || mode === "serve"), () => import("./cli/cmd/serve").then((x) => x.ServeCommand)), - load(!none && (all || mode === "web"), () => import("./cli/cmd/web").then((x) => x.WebCommand)), - load(!none && (all || mode === "models"), () => import("./cli/cmd/models").then((x) => x.ModelsCommand)), - load(!none && (all || mode === "stats"), () => import("./cli/cmd/stats").then((x) => x.StatsCommand)), - load(!none && (all || mode === "export"), () => import("./cli/cmd/export").then((x) => x.ExportCommand)), - load(!none && (all || mode === "import"), () => import("./cli/cmd/import").then((x) => x.ImportCommand)), - load(!none && (all || mode === "github"), () => import("./cli/cmd/github").then((x) => x.GithubCommand)), - load(!none && (all || mode === "pr"), () => import("./cli/cmd/pr").then((x) => x.PrCommand)), - load(!none && (all || mode === "session"), () => import("./cli/cmd/session").then((x) => x.SessionCommand)), - load(!none && (all || mode === "plugin"), () => import("./cli/cmd/plug").then((x) => x.PluginCommand)), - load(!none && (all || mode === "db"), () => import("./cli/cmd/db").then((x) => x.DbCommand)), -]) - function show(out: string) { const text = out.trimStart() if (!text.startsWith("opencode ")) { @@ -275,100 +148,29 @@ const cli = yargs(args) }) .usage("") .completion("completion", "generate shell completion script") - -if (TuiThreadCommand) { - cli.command(TuiThreadCommand) -} - -if (AttachCommand) { - cli.command(AttachCommand) -} - -if (AcpCommand) { - cli.command(AcpCommand) -} - -if (McpCommand) { - cli.command(McpCommand) -} - -if (RunCommand) { - cli.command(RunCommand) -} - -if (GenerateCommand) { - cli.command(GenerateCommand) -} - -if (DebugCommand) { - cli.command(DebugCommand) -} - -if (ConsoleCommand) { - cli.command(ConsoleCommand) -} - -if (ProvidersCommand) { - cli.command(ProvidersCommand) -} - -if (AgentCommand) { - cli.command(AgentCommand) -} - -if (UpgradeCommand) { - cli.command(UpgradeCommand) -} - -if (UninstallCommand) { - cli.command(UninstallCommand) -} - -if (ServeCommand) { - cli.command(ServeCommand) -} - -if (WebCommand) { - cli.command(WebCommand) -} - -if (ModelsCommand) { - cli.command(ModelsCommand) -} - -if (StatsCommand) { - cli.command(StatsCommand) -} - -if (ExportCommand) { - cli.command(ExportCommand) -} - -if (ImportCommand) { - cli.command(ImportCommand) -} - -if (GithubCommand) { - cli.command(GithubCommand) -} - -if (PrCommand) { - cli.command(PrCommand) -} - -if (SessionCommand) { - cli.command(SessionCommand) -} - -if (PluginCommand) { - cli.command(PluginCommand) -} - -if (DbCommand) { - cli.command(DbCommand) -} - -cli + .command(AcpCommand) + .command(McpCommand) + .command(TuiThreadCommand) + .command(AttachCommand) + .command(RunCommand) + .command(GenerateCommand) + .command(DebugCommand) + .command(ConsoleCommand) + .command(ProvidersCommand) + .command(AgentCommand) + .command(UpgradeCommand) + .command(UninstallCommand) + .command(ServeCommand) + .command(WebCommand) + .command(ModelsCommand) + .command(StatsCommand) + .command(ExportCommand) + .command(ImportCommand) + .command(GithubCommand) + .command(PrCommand) + .command(SessionCommand) + .command(PluginCommand) + .command(DbCommand) .fail((msg, err) => { if ( msg?.startsWith("Unknown argument") || From 2ecc6ae65ffc7c767d0c288a53ccf8344a53011e Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 9 Apr 2026 14:32:41 -0400 Subject: [PATCH 11/29] fix(effect): suspend agent default layer construction (#21732) --- packages/opencode/src/agent/agent.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index 0c6fe6ec91c8..843d65433c90 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -393,11 +393,13 @@ export namespace Agent { }), ) - export const defaultLayer = layer.pipe( - Layer.provide(Provider.defaultLayer), - Layer.provide(Auth.defaultLayer), - Layer.provide(Config.defaultLayer), - Layer.provide(Skill.defaultLayer), + export const defaultLayer = Layer.suspend(() => + layer.pipe( + Layer.provide(Provider.defaultLayer), + Layer.provide(Auth.defaultLayer), + Layer.provide(Config.defaultLayer), + Layer.provide(Skill.defaultLayer), + ), ) const { runPromise } = makeRuntime(Service, defaultLayer) From 9f54115c5dbe98b7a0020875cb2f6035626af19d Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 9 Apr 2026 14:52:06 -0400 Subject: [PATCH 12/29] refactor: remove unused runtime facade exports (#21731) --- packages/opencode/src/account/index.ts | 17 ---------- .../src/effect/cross-spawn-spawner.ts | 1 - packages/opencode/src/git/index.ts | 32 ------------------- packages/opencode/src/mcp/index.ts | 3 -- packages/opencode/src/pty/index.ts | 4 --- packages/opencode/src/session/compaction.ts | 11 ------- packages/opencode/src/session/index.ts | 13 -------- packages/opencode/src/session/todo.ts | 4 --- 8 files changed, 85 deletions(-) diff --git a/packages/opencode/src/account/index.ts b/packages/opencode/src/account/index.ts index 37baf34e9323..0aca857822e8 100644 --- a/packages/opencode/src/account/index.ts +++ b/packages/opencode/src/account/index.ts @@ -461,28 +461,11 @@ export namespace Account { return Option.getOrUndefined(await runPromise((service) => service.active())) } - export async function list(): Promise { - return runPromise((service) => service.list()) - } - - export async function activeOrg(): Promise { - return Option.getOrUndefined(await runPromise((service) => service.activeOrg())) - } - export async function orgsByAccount(): Promise { return runPromise((service) => service.orgsByAccount()) } - export async function orgs(accountID: AccountID): Promise { - return runPromise((service) => service.orgs(accountID)) - } - export async function switchOrg(accountID: AccountID, orgID: OrgID) { return runPromise((service) => service.use(accountID, Option.some(orgID))) } - - export async function token(accountID: AccountID): Promise { - const t = await runPromise((service) => service.token(accountID)) - return Option.getOrUndefined(t) - } } diff --git a/packages/opencode/src/effect/cross-spawn-spawner.ts b/packages/opencode/src/effect/cross-spawn-spawner.ts index 92e5b3ba2d07..76982a613367 100644 --- a/packages/opencode/src/effect/cross-spawn-spawner.ts +++ b/packages/opencode/src/effect/cross-spawn-spawner.ts @@ -499,4 +499,3 @@ const rt = lazy(async () => { type RT = Awaited> export const runPromiseExit: RT["runPromiseExit"] = async (...args) => (await rt()).runPromiseExit(...(args as [any])) -export const runPromise: RT["runPromise"] = async (...args) => (await rt()).runPromise(...(args as [any])) diff --git a/packages/opencode/src/git/index.ts b/packages/opencode/src/git/index.ts index 2b3a8a9b050c..10c96d560bdf 100644 --- a/packages/opencode/src/git/index.ts +++ b/packages/opencode/src/git/index.ts @@ -265,39 +265,7 @@ export namespace Git { return runPromise((git) => git.run(args, opts)) } - export async function branch(cwd: string) { - return runPromise((git) => git.branch(cwd)) - } - - export async function prefix(cwd: string) { - return runPromise((git) => git.prefix(cwd)) - } - export async function defaultBranch(cwd: string) { return runPromise((git) => git.defaultBranch(cwd)) } - - export async function hasHead(cwd: string) { - return runPromise((git) => git.hasHead(cwd)) - } - - export async function mergeBase(cwd: string, base: string, head?: string) { - return runPromise((git) => git.mergeBase(cwd, base, head)) - } - - export async function show(cwd: string, ref: string, file: string, prefix?: string) { - return runPromise((git) => git.show(cwd, ref, file, prefix)) - } - - export async function status(cwd: string) { - return runPromise((git) => git.status(cwd)) - } - - export async function diff(cwd: string, ref: string) { - return runPromise((git) => git.diff(cwd, ref)) - } - - export async function stats(cwd: string, ref: string) { - return runPromise((git) => git.stats(cwd, ref)) - } } diff --git a/packages/opencode/src/mcp/index.ts b/packages/opencode/src/mcp/index.ts index 3196c877680d..2599a8dec904 100644 --- a/packages/opencode/src/mcp/index.ts +++ b/packages/opencode/src/mcp/index.ts @@ -906,9 +906,6 @@ export namespace MCP { export const disconnect = async (name: string) => runPromise((svc) => svc.disconnect(name)) - export const getPrompt = async (clientName: string, name: string, args?: Record) => - runPromise((svc) => svc.getPrompt(clientName, name, args)) - export const startAuth = async (mcpName: string) => runPromise((svc) => svc.startAuth(mcpName)) export const authenticate = async (mcpName: string) => runPromise((svc) => svc.authenticate(mcpName)) diff --git a/packages/opencode/src/pty/index.ts b/packages/opencode/src/pty/index.ts index 0321b9800ba5..7695b9ce6a85 100644 --- a/packages/opencode/src/pty/index.ts +++ b/packages/opencode/src/pty/index.ts @@ -371,10 +371,6 @@ export namespace Pty { return runPromise((svc) => svc.get(id)) } - export async function resize(id: PtyID, cols: number, rows: number) { - return runPromise((svc) => svc.resize(id, cols, rows)) - } - export async function write(id: PtyID, data: string) { return runPromise((svc) => svc.write(id, data)) } diff --git a/packages/opencode/src/session/compaction.ts b/packages/opencode/src/session/compaction.ts index 0961c20a6e1a..975327198906 100644 --- a/packages/opencode/src/session/compaction.ts +++ b/packages/opencode/src/session/compaction.ts @@ -401,17 +401,6 @@ When constructing the summary, try to stick to this template: return runPromise((svc) => svc.prune(input)) } - export const process = fn( - z.object({ - parentID: MessageID.zod, - messages: z.custom(), - sessionID: SessionID.zod, - auto: z.boolean(), - overflow: z.boolean().optional(), - }), - (input) => runPromise((svc) => svc.process(input)), - ) - export const create = fn( z.object({ sessionID: SessionID.zod, diff --git a/packages/opencode/src/session/index.ts b/packages/opencode/src/session/index.ts index 8e1ed9dcc9b4..cc81293ecb5d 100644 --- a/packages/opencode/src/session/index.ts +++ b/packages/opencode/src/session/index.ts @@ -730,7 +730,6 @@ export namespace Session { runPromise((svc) => svc.fork(input)), ) - export const touch = fn(SessionID.zod, (id) => runPromise((svc) => svc.touch(id))) export const get = fn(SessionID.zod, (id) => runPromise((svc) => svc.get(id))) export const share = fn(SessionID.zod, (id) => runPromise((svc) => svc.share(id))) export const unshare = fn(SessionID.zod, (id) => runPromise((svc) => svc.unshare(id))) @@ -743,24 +742,12 @@ export namespace Session { runPromise((svc) => svc.setArchived(input)), ) - export const setPermission = fn(z.object({ sessionID: SessionID.zod, permission: Permission.Ruleset }), (input) => - runPromise((svc) => svc.setPermission(input)), - ) - export const setRevert = fn( z.object({ sessionID: SessionID.zod, revert: Info.shape.revert, summary: Info.shape.summary }), (input) => runPromise((svc) => svc.setRevert({ sessionID: input.sessionID, revert: input.revert, summary: input.summary })), ) - export const clearRevert = fn(SessionID.zod, (id) => runPromise((svc) => svc.clearRevert(id))) - - export const setSummary = fn(z.object({ sessionID: SessionID.zod, summary: Info.shape.summary }), (input) => - runPromise((svc) => svc.setSummary({ sessionID: input.sessionID, summary: input.summary })), - ) - - export const diff = fn(SessionID.zod, (id) => runPromise((svc) => svc.diff(id))) - export const messages = fn(z.object({ sessionID: SessionID.zod, limit: z.number().optional() }), (input) => runPromise((svc) => svc.messages(input)), ) diff --git a/packages/opencode/src/session/todo.ts b/packages/opencode/src/session/todo.ts index 2d85ad224f1b..4adfc7bc5fb3 100644 --- a/packages/opencode/src/session/todo.ts +++ b/packages/opencode/src/session/todo.ts @@ -85,10 +85,6 @@ export namespace Todo { export const defaultLayer = layer.pipe(Layer.provide(Bus.layer)) const { runPromise } = makeRuntime(Service, defaultLayer) - export async function update(input: { sessionID: SessionID; todos: Info[] }) { - return runPromise((svc) => svc.update(input)) - } - export async function get(sessionID: SessionID) { return runPromise((svc) => svc.get(sessionID)) } From 3199383eef4cc2ac4ca086f9485b071061dcff70 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 9 Apr 2026 15:20:28 -0400 Subject: [PATCH 13/29] fix: finalize interrupted bash via tool result path (#21724) --- packages/opencode/src/session/processor.ts | 178 +++++++++++++----- packages/opencode/src/session/prompt.ts | 20 +- .../opencode/test/session/compaction.test.ts | 13 +- .../test/session/prompt-effect.test.ts | 138 ++++++++++++++ 4 files changed, 282 insertions(+), 67 deletions(-) diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index 225961aef05d..2e4d34bfcaf2 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -1,4 +1,4 @@ -import { Cause, Effect, Layer, ServiceMap } from "effect" +import { Cause, Deferred, Effect, Layer, ServiceMap } from "effect" import * as Stream from "effect/Stream" import { Agent } from "@/agent/agent" import { Bus } from "@/bus" @@ -18,6 +18,7 @@ import { SessionStatus } from "./status" import { SessionSummary } from "./summary" import type { Provider } from "@/provider/provider" import { Question } from "@/question" +import { errorMessage } from "@/util/error" import { isRecord } from "@/util/record" export namespace SessionProcessor { @@ -30,7 +31,19 @@ export namespace SessionProcessor { export interface Handle { readonly message: MessageV2.Assistant - readonly partFromToolCall: (toolCallID: string) => MessageV2.ToolPart | undefined + readonly updateToolCall: ( + toolCallID: string, + update: (part: MessageV2.ToolPart) => MessageV2.ToolPart, + ) => Effect.Effect + readonly completeToolCall: ( + toolCallID: string, + output: { + title: string + metadata: Record + output: string + attachments?: MessageV2.FilePart[] + }, + ) => Effect.Effect readonly process: (streamInput: LLM.StreamInput) => Effect.Effect } @@ -44,8 +57,15 @@ export namespace SessionProcessor { readonly create: (input: Input) => Effect.Effect } + type ToolCall = { + partID: MessageV2.ToolPart["id"] + messageID: MessageV2.ToolPart["messageID"] + sessionID: MessageV2.ToolPart["sessionID"] + done: Deferred.Deferred + } + interface ProcessorContext extends Input { - toolcalls: Record + toolcalls: Record shouldBreak: boolean snapshot: string | undefined blocked: boolean @@ -108,6 +128,88 @@ export namespace SessionProcessor { aborted, }) + const settleToolCall = Effect.fn("SessionProcessor.settleToolCall")(function* (toolCallID: string) { + const done = ctx.toolcalls[toolCallID]?.done + delete ctx.toolcalls[toolCallID] + if (done) yield* Deferred.succeed(done, undefined).pipe(Effect.ignore) + }) + + const readToolCall = Effect.fn("SessionProcessor.readToolCall")(function* (toolCallID: string) { + const call = ctx.toolcalls[toolCallID] + if (!call) return + const part = yield* session.getPart({ + partID: call.partID, + messageID: call.messageID, + sessionID: call.sessionID, + }) + if (!part || part.type !== "tool") { + delete ctx.toolcalls[toolCallID] + return + } + return { call, part } + }) + + const updateToolCall = Effect.fn("SessionProcessor.updateToolCall")(function* ( + toolCallID: string, + update: (part: MessageV2.ToolPart) => MessageV2.ToolPart, + ) { + const match = yield* readToolCall(toolCallID) + if (!match) return + const part = yield* session.updatePart(update(match.part)) + ctx.toolcalls[toolCallID] = { + ...match.call, + partID: part.id, + messageID: part.messageID, + sessionID: part.sessionID, + } + return part + }) + + const completeToolCall = Effect.fn("SessionProcessor.completeToolCall")(function* ( + toolCallID: string, + output: { + title: string + metadata: Record + output: string + attachments?: MessageV2.FilePart[] + }, + ) { + const match = yield* readToolCall(toolCallID) + if (!match || match.part.state.status !== "running") return + yield* session.updatePart({ + ...match.part, + state: { + status: "completed", + input: match.part.state.input, + output: output.output, + metadata: output.metadata, + title: output.title, + time: { start: match.part.state.time.start, end: Date.now() }, + attachments: output.attachments, + }, + }) + yield* settleToolCall(toolCallID) + }) + + const failToolCall = Effect.fn("SessionProcessor.failToolCall")(function* (toolCallID: string, error: unknown) { + const match = yield* readToolCall(toolCallID) + if (!match || match.part.state.status !== "running") return false + yield* session.updatePart({ + ...match.part, + state: { + status: "error", + input: match.part.state.input, + error: errorMessage(error), + time: { start: match.part.state.time.start, end: Date.now() }, + }, + }) + if (error instanceof Permission.RejectedError || error instanceof Question.RejectedError) { + ctx.blocked = ctx.shouldBreak + } + yield* settleToolCall(toolCallID) + return true + }) + const handleEvent = Effect.fn("SessionProcessor.handleEvent")(function* (value: StreamEvent) { switch (value.type) { case "start": @@ -154,8 +256,8 @@ export namespace SessionProcessor { if (ctx.assistantMessage.summary) { throw new Error(`Tool call not allowed while generating summary: ${value.toolName}`) } - ctx.toolcalls[value.id] = yield* session.updatePart({ - id: ctx.toolcalls[value.id]?.id ?? PartID.ascending(), + const part = yield* session.updatePart({ + id: ctx.toolcalls[value.id]?.partID ?? PartID.ascending(), messageID: ctx.assistantMessage.id, sessionID: ctx.assistantMessage.sessionID, type: "tool", @@ -164,6 +266,12 @@ export namespace SessionProcessor { state: { status: "pending", input: {}, raw: "" }, metadata: value.providerExecuted ? { providerExecuted: true } : undefined, } satisfies MessageV2.ToolPart) + ctx.toolcalls[value.id] = { + done: yield* Deferred.make(), + partID: part.id, + messageID: part.messageID, + sessionID: part.sessionID, + } return case "tool-input-delta": @@ -176,14 +284,7 @@ export namespace SessionProcessor { if (ctx.assistantMessage.summary) { throw new Error(`Tool call not allowed while generating summary: ${value.toolName}`) } - const pointer = ctx.toolcalls[value.toolCallId] - const match = yield* session.getPart({ - partID: pointer.id, - messageID: pointer.messageID, - sessionID: pointer.sessionID, - }) - if (!match || match.type !== "tool") return - ctx.toolcalls[value.toolCallId] = yield* session.updatePart({ + yield* updateToolCall(value.toolCallId, (match) => ({ ...match, tool: value.toolName, state: { @@ -195,7 +296,7 @@ export namespace SessionProcessor { metadata: match.metadata?.providerExecuted ? { ...value.providerMetadata, providerExecuted: true } : value.providerMetadata, - } satisfies MessageV2.ToolPart) + })) const parts = MessageV2.parts(ctx.assistantMessage.id) const recentParts = parts.slice(-DOOM_LOOP_THRESHOLD) @@ -226,41 +327,12 @@ export namespace SessionProcessor { } case "tool-result": { - const match = ctx.toolcalls[value.toolCallId] - if (!match || match.state.status !== "running") return - yield* session.updatePart({ - ...match, - state: { - status: "completed", - input: value.input ?? match.state.input, - output: value.output.output, - metadata: value.output.metadata, - title: value.output.title, - time: { start: match.state.time.start, end: Date.now() }, - attachments: value.output.attachments, - }, - }) - delete ctx.toolcalls[value.toolCallId] + yield* completeToolCall(value.toolCallId, value.output) return } case "tool-error": { - const match = ctx.toolcalls[value.toolCallId] - if (!match || match.state.status !== "running") return - - yield* session.updatePart({ - ...match, - state: { - status: "error", - input: value.input ?? match.state.input, - error: value.error instanceof Error ? value.error.message : String(value.error), - time: { start: match.state.time.start, end: Date.now() }, - }, - }) - if (value.error instanceof Permission.RejectedError || value.error instanceof Question.RejectedError) { - ctx.blocked = ctx.shouldBreak - } - delete ctx.toolcalls[value.toolCallId] + yield* failToolCall(value.toolCallId, value.error) return } @@ -413,7 +485,16 @@ export namespace SessionProcessor { } ctx.reasoningMap = {} - for (const part of Object.values(ctx.toolcalls)) { + yield* Effect.forEach( + Object.values(ctx.toolcalls), + (call) => Deferred.await(call.done).pipe(Effect.timeout("250 millis"), Effect.ignore), + { concurrency: "unbounded" }, + ) + + for (const toolCallID of Object.keys(ctx.toolcalls)) { + const match = yield* readToolCall(toolCallID) + if (!match) continue + const part = match.part const end = Date.now() const metadata = "metadata" in part.state && isRecord(part.state.metadata) ? part.state.metadata : {} yield* session.updatePart({ @@ -503,9 +584,8 @@ export namespace SessionProcessor { get message() { return ctx.assistantMessage }, - partFromToolCall(toolCallID: string) { - return ctx.toolcalls[toolCallID] - }, + updateToolCall, + completeToolCall, process, } satisfies Handle }) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 19f0850ff4c2..088a367cad5d 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -388,7 +388,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the model: Provider.Model session: Session.Info tools?: Record - processor: Pick + processor: Pick bypassAgentCheck: boolean messages: MessageV2.WithParts[] }) { @@ -405,10 +405,9 @@ NOTE: At any point in time through this workflow you should feel free to ask the messages: input.messages, metadata: (val) => Effect.runPromise( - Effect.gen(function* () { - const match = input.processor.partFromToolCall(options.toolCallId) - if (!match || !["running", "pending"].includes(match.state.status)) return - yield* sessions.updatePart({ + input.processor.updateToolCall(options.toolCallId, (match) => { + if (!["running", "pending"].includes(match.state.status)) return match + return { ...match, state: { title: val.title, @@ -417,7 +416,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the input: args, time: { start: Date.now() }, }, - }) + } }), ), ask: (req) => @@ -465,6 +464,9 @@ NOTE: At any point in time through this workflow you should feel free to ask the { tool: item.id, sessionID: ctx.sessionID, callID: ctx.callID, args }, output, ) + if (options.abortSignal?.aborted) { + yield* input.processor.completeToolCall(options.toolCallId, output) + } return output }), ) @@ -529,7 +531,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the ...(truncated.truncated && { outputPath: truncated.outputPath }), } - return { + const output = { title: "", metadata, output: truncated.content, @@ -541,6 +543,10 @@ NOTE: At any point in time through this workflow you should feel free to ask the })), content: result.content, } + if (opts.abortSignal?.aborted) { + yield* input.processor.completeToolCall(opts.toolCallId, output) + } + return output }), ) tools[key] = item diff --git a/packages/opencode/test/session/compaction.test.ts b/packages/opencode/test/session/compaction.test.ts index c37371d9f871..76a83c34da00 100644 --- a/packages/opencode/test/session/compaction.test.ts +++ b/packages/opencode/test/session/compaction.test.ts @@ -139,17 +139,8 @@ function fake( get message() { return msg }, - partFromToolCall() { - return { - id: PartID.ascending(), - messageID: msg.id, - sessionID: msg.sessionID, - type: "tool", - callID: "fake", - tool: "fake", - state: { status: "pending", input: {}, raw: "" }, - } - }, + updateToolCall: Effect.fn("TestSessionProcessor.updateToolCall")(() => Effect.succeed(undefined)), + completeToolCall: Effect.fn("TestSessionProcessor.completeToolCall")(() => Effect.void), process: Effect.fn("TestSessionProcessor.process")(() => Effect.succeed(result)), } satisfies SessionProcessorModule.SessionProcessor.Handle } diff --git a/packages/opencode/test/session/prompt-effect.test.ts b/packages/opencode/test/session/prompt-effect.test.ts index 38d7ed9f5aca..e4c46337c411 100644 --- a/packages/opencode/test/session/prompt-effect.test.ts +++ b/packages/opencode/test/session/prompt-effect.test.ts @@ -538,6 +538,93 @@ it.live("failed subtask preserves metadata on error tool state", () => ), ) +it.live( + "running subtask preserves metadata after tool-call transition", + () => + provideTmpdirServer( + Effect.fnUntraced(function* ({ llm }) { + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const chat = yield* sessions.create({ title: "Pinned" }) + yield* llm.hang + const msg = yield* user(chat.id, "hello") + yield* addSubtask(chat.id, msg.id) + + const fiber = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild) + + const tool = yield* Effect.promise(async () => { + const end = Date.now() + 5_000 + while (Date.now() < end) { + const msgs = await Effect.runPromise(MessageV2.filterCompactedEffect(chat.id)) + const taskMsg = msgs.find((item) => item.info.role === "assistant" && item.info.agent === "general") + const tool = taskMsg?.parts.find((part): part is MessageV2.ToolPart => part.type === "tool") + if (tool?.state.status === "running" && tool.state.metadata?.sessionId) return tool + await new Promise((done) => setTimeout(done, 20)) + } + throw new Error("timed out waiting for running subtask metadata") + }) + + if (tool.state.status !== "running") return + expect(typeof tool.state.metadata?.sessionId).toBe("string") + expect(tool.state.title).toBeDefined() + expect(tool.state.metadata?.model).toBeDefined() + + yield* prompt.cancel(chat.id) + yield* Fiber.await(fiber) + }), + { git: true, config: providerCfg }, + ), + 5_000, +) + +it.live( + "running task tool preserves metadata after tool-call transition", + () => + provideTmpdirServer( + Effect.fnUntraced(function* ({ llm }) { + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const chat = yield* sessions.create({ + title: "Pinned", + permission: [{ permission: "*", pattern: "*", action: "allow" }], + }) + yield* llm.tool("task", { + description: "inspect bug", + prompt: "look into the cache key path", + subagent_type: "general", + }) + yield* llm.hang + yield* user(chat.id, "hello") + + const fiber = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild) + + const tool = yield* Effect.promise(async () => { + const end = Date.now() + 5_000 + while (Date.now() < end) { + const msgs = await Effect.runPromise(MessageV2.filterCompactedEffect(chat.id)) + const assistant = msgs.findLast((item) => item.info.role === "assistant" && item.info.agent === "build") + const tool = assistant?.parts.find( + (part): part is MessageV2.ToolPart => part.type === "tool" && part.tool === "task", + ) + if (tool?.state.status === "running" && tool.state.metadata?.sessionId) return tool + await new Promise((done) => setTimeout(done, 20)) + } + throw new Error("timed out waiting for running task metadata") + }) + + if (tool.state.status !== "running") return + expect(typeof tool.state.metadata?.sessionId).toBe("string") + expect(tool.state.title).toBe("inspect bug") + expect(tool.state.metadata?.model).toBeDefined() + + yield* prompt.cancel(chat.id) + yield* Fiber.await(fiber) + }), + { git: true, config: providerCfg }, + ), + 10_000, +) + it.live( "loop sets status to busy then idle", () => @@ -1173,6 +1260,57 @@ unix( 30_000, ) +unix( + "cancel finalizes interrupted bash tool output through normal truncation", + () => + provideTmpdirServer( + ({ dir, llm }) => + Effect.gen(function* () { + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const chat = yield* sessions.create({ + title: "Interrupted bash truncation", + permission: [{ permission: "*", pattern: "*", action: "allow" }], + }) + + yield* prompt.prompt({ + sessionID: chat.id, + agent: "build", + noReply: true, + parts: [{ type: "text", text: "run bash" }], + }) + + yield* llm.tool("bash", { + command: + 'i=0; while [ "$i" -lt 4000 ]; do printf "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx %05d\\n" "$i"; i=$((i + 1)); done; sleep 30', + description: "Print many lines", + timeout: 30_000, + workdir: path.resolve(dir), + }) + + const run = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild) + yield* llm.wait(1) + yield* Effect.sleep(150) + yield* prompt.cancel(chat.id) + + const exit = yield* Fiber.await(run) + expect(Exit.isSuccess(exit)).toBe(true) + if (Exit.isFailure(exit)) return + + const tool = completedTool(exit.value.parts) + if (!tool) return + + expect(tool.state.metadata.truncated).toBe(true) + expect(typeof tool.state.metadata.outputPath).toBe("string") + expect(tool.state.output).toContain("The tool call succeeded but the output was truncated.") + expect(tool.state.output).toContain("Full output saved to:") + expect(tool.state.output).not.toContain("Tool execution aborted") + }), + { git: true, config: providerCfg }, + ), + 30_000, +) + unix( "cancel interrupts loop queued behind shell", () => From 10441efad1895cbaa77be3ba5277026b489894a2 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 9 Apr 2026 16:03:40 -0400 Subject: [PATCH 14/29] refactor(effect): extract session run state service (#21744) --- packages/opencode/src/project/vcs.ts | 62 +++++----- .../opencode/src/server/routes/session.ts | 3 +- packages/opencode/src/session/prompt.ts | 65 +--------- packages/opencode/src/session/revert.ts | 10 +- packages/opencode/src/session/run-state.ts | 114 ++++++++++++++++++ packages/opencode/src/session/status.ts | 2 +- .../test/server/session-actions.test.ts | 3 +- .../test/session/prompt-effect.test.ts | 37 +++--- .../test/session/snapshot-tool-race.test.ts | 3 + 9 files changed, 186 insertions(+), 113 deletions(-) create mode 100644 packages/opencode/src/session/run-state.ts diff --git a/packages/opencode/src/project/vcs.ts b/packages/opencode/src/project/vcs.ts index ec6e415c8207..d31dff6a9797 100644 --- a/packages/opencode/src/project/vcs.ts +++ b/packages/opencode/src/project/vcs.ts @@ -161,39 +161,37 @@ export namespace Vcs { const bus = yield* Bus.Service const state = yield* InstanceState.make( - Effect.fn("Vcs.state")((ctx) => - Effect.gen(function* () { - if (ctx.project.vcs !== "git") { - return { current: undefined, root: undefined } - } - - const get = Effect.fnUntraced(function* () { - return yield* git.branch(ctx.directory) - }) - const [current, root] = yield* Effect.all([git.branch(ctx.directory), git.defaultBranch(ctx.directory)], { - concurrency: 2, - }) - const value = { current, root } - log.info("initialized", { branch: value.current, default_branch: value.root?.name }) - - yield* bus.subscribe(FileWatcher.Event.Updated).pipe( - Stream.filter((evt) => evt.properties.file.endsWith("HEAD")), - Stream.runForEach((_evt) => - Effect.gen(function* () { - const next = yield* get() - if (next !== value.current) { - log.info("branch changed", { from: value.current, to: next }) - value.current = next - yield* bus.publish(Event.BranchUpdated, { branch: next }) - } - }), - ), - Effect.forkScoped, - ) + Effect.fn("Vcs.state")(function* (ctx) { + if (ctx.project.vcs !== "git") { + return { current: undefined, root: undefined } + } - return value - }), - ), + const get = Effect.fnUntraced(function* () { + return yield* git.branch(ctx.directory) + }) + const [current, root] = yield* Effect.all([git.branch(ctx.directory), git.defaultBranch(ctx.directory)], { + concurrency: 2, + }) + const value = { current, root } + log.info("initialized", { branch: value.current, default_branch: value.root?.name }) + + yield* bus.subscribe(FileWatcher.Event.Updated).pipe( + Stream.filter((evt) => evt.properties.file.endsWith("HEAD")), + Stream.runForEach((_evt) => + Effect.gen(function* () { + const next = yield* get() + if (next !== value.current) { + log.info("branch changed", { from: value.current, to: next }) + value.current = next + yield* bus.publish(Event.BranchUpdated, { branch: next }) + } + }), + ), + Effect.forkScoped, + ) + + return value + }), ) return Service.of({ diff --git a/packages/opencode/src/server/routes/session.ts b/packages/opencode/src/server/routes/session.ts index b57ed9d47c0b..fb0b6b69c109 100644 --- a/packages/opencode/src/server/routes/session.ts +++ b/packages/opencode/src/server/routes/session.ts @@ -6,6 +6,7 @@ import z from "zod" import { Session } from "../../session" import { MessageV2 } from "../../session/message-v2" import { SessionPrompt } from "../../session/prompt" +import { SessionRunState } from "@/session/run-state" import { SessionCompaction } from "../../session/compaction" import { SessionRevert } from "../../session/revert" import { SessionStatus } from "@/session/status" @@ -698,7 +699,7 @@ export const SessionRoutes = lazy(() => ), async (c) => { const params = c.req.valid("param") - await SessionPrompt.assertNotBusy(params.sessionID) + await SessionRunState.assertNotBusy(params.sessionID) await Session.removeMessage({ sessionID: params.sessionID, messageID: params.messageID, diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 088a367cad5d..7f0a014ab249 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -20,7 +20,6 @@ import PROMPT_PLAN from "../session/prompt/plan.txt" import BUILD_SWITCH from "../session/prompt/build-switch.txt" import MAX_STEPS from "../session/prompt/max-steps.txt" import { ToolRegistry } from "../tool/registry" -import { Runner } from "@/effect/runner" import { MCP } from "../mcp" import { LSP } from "../lsp" import { FileTime } from "../file/time" @@ -48,6 +47,7 @@ import { Cause, Effect, Exit, Layer, Option, Scope, ServiceMap } from "effect" import { InstanceState } from "@/effect/instance-state" import { makeRuntime } from "@/effect/run-service" import { TaskTool } from "@/tool/task" +import { SessionRunState } from "./run-state" // @ts-ignore globalThis.AI_SDK_LOG_WARNINGS = false @@ -66,7 +66,6 @@ export namespace SessionPrompt { const log = Log.create({ service: "session.prompt" }) export interface Interface { - readonly assertNotBusy: (sessionID: SessionID) => Effect.Effect readonly cancel: (sessionID: SessionID) => Effect.Effect readonly prompt: (input: PromptInput) => Effect.Effect readonly loop: (input: z.infer) => Effect.Effect @@ -99,55 +98,11 @@ export namespace SessionPrompt { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner const scope = yield* Scope.Scope const instruction = yield* Instruction.Service - - const state = yield* InstanceState.make( - Effect.fn("SessionPrompt.state")(function* () { - const runners = new Map>() - yield* Effect.addFinalizer( - Effect.fnUntraced(function* () { - yield* Effect.forEach(runners.values(), (r) => r.cancel, { concurrency: "unbounded", discard: true }) - runners.clear() - }), - ) - return { runners } - }), - ) - - const getRunner = (runners: Map>, sessionID: SessionID) => { - const existing = runners.get(sessionID) - if (existing) return existing - const runner = Runner.make(scope, { - onIdle: Effect.gen(function* () { - runners.delete(sessionID) - yield* status.set(sessionID, { type: "idle" }) - }), - onBusy: status.set(sessionID, { type: "busy" }), - onInterrupt: lastAssistant(sessionID), - busy: () => { - throw new Session.BusyError(sessionID) - }, - }) - runners.set(sessionID, runner) - return runner - } - - const assertNotBusy: (sessionID: SessionID) => Effect.Effect = Effect.fn( - "SessionPrompt.assertNotBusy", - )(function* (sessionID: SessionID) { - const s = yield* InstanceState.get(state) - const runner = s.runners.get(sessionID) - if (runner?.busy) throw new Session.BusyError(sessionID) - }) + const state = yield* SessionRunState.Service const cancel = Effect.fn("SessionPrompt.cancel")(function* (sessionID: SessionID) { log.info("cancel", { sessionID }) - const s = yield* InstanceState.get(state) - const runner = s.runners.get(sessionID) - if (!runner || !runner.busy) { - yield* status.set(sessionID, { type: "idle" }) - return - } - yield* runner.cancel + yield* state.cancel(sessionID) }) const resolvePromptParts = Effect.fn("SessionPrompt.resolvePromptParts")(function* (template: string) { @@ -1574,16 +1529,12 @@ NOTE: At any point in time through this workflow you should feel free to ask the const loop: (input: z.infer) => Effect.Effect = Effect.fn( "SessionPrompt.loop", )(function* (input: z.infer) { - const s = yield* InstanceState.get(state) - const runner = getRunner(s.runners, input.sessionID) - return yield* runner.ensureRunning(runLoop(input.sessionID)) + return yield* state.ensureRunning(input.sessionID, lastAssistant(input.sessionID), runLoop(input.sessionID)) }) const shell: (input: ShellInput) => Effect.Effect = Effect.fn("SessionPrompt.shell")( function* (input: ShellInput) { - const s = yield* InstanceState.get(state) - const runner = getRunner(s.runners, input.sessionID) - return yield* runner.startShell(shellImpl(input)) + return yield* state.startShell(input.sessionID, lastAssistant(input.sessionID), shellImpl(input)) }, ) @@ -1704,7 +1655,6 @@ NOTE: At any point in time through this workflow you should feel free to ask the }) return Service.of({ - assertNotBusy, cancel, prompt, loop, @@ -1718,6 +1668,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the const defaultLayer = Layer.unwrap( Effect.sync(() => layer.pipe( + Layer.provide(SessionRunState.layer), Layer.provide(SessionStatus.layer), Layer.provide(SessionCompaction.defaultLayer), Layer.provide(SessionProcessor.defaultLayer), @@ -1741,10 +1692,6 @@ NOTE: At any point in time through this workflow you should feel free to ask the ) const { runPromise } = makeRuntime(Service, defaultLayer) - export async function assertNotBusy(sessionID: SessionID) { - return runPromise((svc) => svc.assertNotBusy(SessionID.zod.parse(sessionID))) - } - export const PromptInput = z.object({ sessionID: SessionID.zod, messageID: MessageID.zod.optional(), diff --git a/packages/opencode/src/session/revert.ts b/packages/opencode/src/session/revert.ts index 9df3f36eb8c7..a2d517f77c0a 100644 --- a/packages/opencode/src/session/revert.ts +++ b/packages/opencode/src/session/revert.ts @@ -9,8 +9,9 @@ import { Log } from "../util/log" import { Session } from "." import { MessageV2 } from "./message-v2" import { SessionID, MessageID, PartID } from "./schema" -import { SessionPrompt } from "./prompt" +import { SessionRunState } from "./run-state" import { SessionSummary } from "./summary" +import { SessionStatus } from "./status" export namespace SessionRevert { const log = Log.create({ service: "session.revert" }) @@ -38,9 +39,10 @@ export namespace SessionRevert { const storage = yield* Storage.Service const bus = yield* Bus.Service const summary = yield* SessionSummary.Service + const state = yield* SessionRunState.Service const revert = Effect.fn("SessionRevert.revert")(function* (input: RevertInput) { - yield* Effect.promise(() => SessionPrompt.assertNotBusy(input.sessionID)) + yield* state.assertNotBusy(input.sessionID) const all = yield* sessions.messages({ sessionID: input.sessionID }) let lastUser: MessageV2.User | undefined const session = yield* sessions.get(input.sessionID) @@ -93,7 +95,7 @@ export namespace SessionRevert { const unrevert = Effect.fn("SessionRevert.unrevert")(function* (input: { sessionID: SessionID }) { log.info("unreverting", input) - yield* Effect.promise(() => SessionPrompt.assertNotBusy(input.sessionID)) + yield* state.assertNotBusy(input.sessionID) const session = yield* sessions.get(input.sessionID) if (!session.revert) return session if (session.revert.snapshot) yield* snap.restore(session.revert!.snapshot!) @@ -151,6 +153,8 @@ export namespace SessionRevert { export const defaultLayer = Layer.unwrap( Effect.sync(() => layer.pipe( + Layer.provide(SessionRunState.layer), + Layer.provide(SessionStatus.layer), Layer.provide(Session.defaultLayer), Layer.provide(Snapshot.defaultLayer), Layer.provide(Storage.defaultLayer), diff --git a/packages/opencode/src/session/run-state.ts b/packages/opencode/src/session/run-state.ts new file mode 100644 index 000000000000..3c2022bd00bf --- /dev/null +++ b/packages/opencode/src/session/run-state.ts @@ -0,0 +1,114 @@ +import { InstanceState } from "@/effect/instance-state" +import { Runner } from "@/effect/runner" +import { makeRuntime } from "@/effect/run-service" +import { Effect, Layer, Scope, ServiceMap } from "effect" +import { Session } from "." +import { MessageV2 } from "./message-v2" +import { SessionID } from "./schema" +import { SessionStatus } from "./status" + +export namespace SessionRunState { + export interface Interface { + readonly assertNotBusy: (sessionID: SessionID) => Effect.Effect + readonly cancel: (sessionID: SessionID) => Effect.Effect + readonly ensureRunning: ( + sessionID: SessionID, + onInterrupt: Effect.Effect, + work: Effect.Effect, + ) => Effect.Effect + readonly startShell: ( + sessionID: SessionID, + onInterrupt: Effect.Effect, + work: Effect.Effect, + ) => Effect.Effect + } + + export class Service extends ServiceMap.Service()("@opencode/SessionRunState") {} + + export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const status = yield* SessionStatus.Service + + const state = yield* InstanceState.make( + Effect.fn("SessionRunState.state")(function* () { + const scope = yield* Scope.Scope + const runners = new Map>() + yield* Effect.addFinalizer( + Effect.fnUntraced(function* () { + yield* Effect.forEach(runners.values(), (runner) => runner.cancel, { + concurrency: "unbounded", + discard: true, + }) + runners.clear() + }), + ) + return { runners, scope } + }), + ) + + const runner = Effect.fn("SessionRunState.runner")(function* ( + sessionID: SessionID, + onInterrupt: Effect.Effect, + ) { + const data = yield* InstanceState.get(state) + const existing = data.runners.get(sessionID) + if (existing) return existing + const next = Runner.make(data.scope, { + onIdle: Effect.gen(function* () { + data.runners.delete(sessionID) + yield* status.set(sessionID, { type: "idle" }) + }), + onBusy: status.set(sessionID, { type: "busy" }), + onInterrupt, + busy: () => { + throw new Session.BusyError(sessionID) + }, + }) + data.runners.set(sessionID, next) + return next + }) + + const assertNotBusy = Effect.fn("SessionRunState.assertNotBusy")(function* (sessionID: SessionID) { + const data = yield* InstanceState.get(state) + const existing = data.runners.get(sessionID) + if (existing?.busy) throw new Session.BusyError(sessionID) + }) + + const cancel = Effect.fn("SessionRunState.cancel")(function* (sessionID: SessionID) { + const data = yield* InstanceState.get(state) + const existing = data.runners.get(sessionID) + if (!existing || !existing.busy) { + yield* status.set(sessionID, { type: "idle" }) + return + } + yield* existing.cancel + }) + + const ensureRunning = Effect.fn("SessionRunState.ensureRunning")(function* ( + sessionID: SessionID, + onInterrupt: Effect.Effect, + work: Effect.Effect, + ) { + return yield* (yield* runner(sessionID, onInterrupt)).ensureRunning(work) + }) + + const startShell = Effect.fn("SessionRunState.startShell")(function* ( + sessionID: SessionID, + onInterrupt: Effect.Effect, + work: Effect.Effect, + ) { + return yield* (yield* runner(sessionID, onInterrupt)).startShell(work) + }) + + return Service.of({ assertNotBusy, cancel, ensureRunning, startShell }) + }), + ) + + export const defaultLayer = layer.pipe(Layer.provide(SessionStatus.defaultLayer)) + const { runPromise } = makeRuntime(Service, defaultLayer) + + export async function assertNotBusy(sessionID: SessionID) { + return runPromise((svc) => svc.assertNotBusy(sessionID)) + } +} diff --git a/packages/opencode/src/session/status.ts b/packages/opencode/src/session/status.ts index 34a79eed112c..16fccaf3e831 100644 --- a/packages/opencode/src/session/status.ts +++ b/packages/opencode/src/session/status.ts @@ -85,7 +85,7 @@ export namespace SessionStatus { }), ) - const defaultLayer = layer.pipe(Layer.provide(Bus.layer)) + export const defaultLayer = layer.pipe(Layer.provide(Bus.layer)) const { runPromise } = makeRuntime(Service, defaultLayer) export async function get(sessionID: SessionID) { diff --git a/packages/opencode/test/server/session-actions.test.ts b/packages/opencode/test/server/session-actions.test.ts index 004c2900a208..4ab485965ea2 100644 --- a/packages/opencode/test/server/session-actions.test.ts +++ b/packages/opencode/test/server/session-actions.test.ts @@ -5,6 +5,7 @@ import { Session } from "../../src/session" import { ModelID, ProviderID } from "../../src/provider/schema" import { MessageID, PartID, type SessionID } from "../../src/session/schema" import { SessionPrompt } from "../../src/session/prompt" +import { SessionRunState } from "../../src/session/run-state" import { Log } from "../../src/util/log" import { tmpdir } from "../fixture/fixture" @@ -64,7 +65,7 @@ describe("session action routes", () => { fn: async () => { const session = await Session.create({}) const msg = await user(session.id, "hello") - const busy = spyOn(SessionPrompt, "assertNotBusy").mockRejectedValue(new Session.BusyError(session.id)) + const busy = spyOn(SessionRunState, "assertNotBusy").mockRejectedValue(new Session.BusyError(session.id)) const remove = spyOn(Session, "removeMessage").mockResolvedValue(msg.id) const app = Server.Default().app diff --git a/packages/opencode/test/session/prompt-effect.test.ts b/packages/opencode/test/session/prompt-effect.test.ts index e4c46337c411..81288f0ca14b 100644 --- a/packages/opencode/test/session/prompt-effect.test.ts +++ b/packages/opencode/test/session/prompt-effect.test.ts @@ -25,6 +25,7 @@ import { SessionCompaction } from "../../src/session/compaction" import { Instruction } from "../../src/session/instruction" import { SessionProcessor } from "../../src/session/processor" import { SessionPrompt } from "../../src/session/prompt" +import { SessionRunState } from "../../src/session/run-state" import { MessageID, PartID, SessionID } from "../../src/session/schema" import { SessionStatus } from "../../src/session/status" import { Shell } from "../../src/shell/shell" @@ -143,6 +144,7 @@ const filetime = Layer.succeed( ) const status = SessionStatus.layer.pipe(Layer.provideMerge(Bus.layer)) +const run = SessionRunState.layer.pipe(Layer.provide(status)) const infra = Layer.mergeAll(NodeFileSystem.layer, CrossSpawnSpawner.defaultLayer) function makeHttp() { const deps = Layer.mergeAll( @@ -174,6 +176,7 @@ function makeHttp() { return Layer.mergeAll( TestLLMServer.layer, SessionPrompt.layer.pipe( + Layer.provideMerge(run), Layer.provideMerge(compact), Layer.provideMerge(proc), Layer.provideMerge(registry), @@ -300,9 +303,10 @@ const addSubtask = (sessionID: SessionID, messageID: MessageID, model = ref) => const boot = Effect.fn("test.boot")(function* (input?: { title?: string }) { const prompt = yield* SessionPrompt.Service + const run = yield* SessionRunState.Service const sessions = yield* Session.Service const chat = yield* sessions.create(input ?? { title: "Pinned" }) - return { prompt, sessions, chat } + return { prompt, run, sessions, chat } }) // Loop semantics @@ -800,7 +804,7 @@ it.live("concurrent loop callers get same result", () => provideTmpdirInstance( (dir) => Effect.gen(function* () { - const { prompt, chat } = yield* boot() + const { prompt, run, chat } = yield* boot() yield* seed(chat.id, { finish: "stop" }) const [a, b] = yield* Effect.all([prompt.loop({ sessionID: chat.id }), prompt.loop({ sessionID: chat.id })], { @@ -809,7 +813,7 @@ it.live("concurrent loop callers get same result", () => expect(a.info.id).toBe(b.info.id) expect(a.info.role).toBe("assistant") - yield* prompt.assertNotBusy(chat.id) + yield* run.assertNotBusy(chat.id) }), { git: true }, ), @@ -913,6 +917,7 @@ it.live( provideTmpdirServer( Effect.fnUntraced(function* ({ llm }) { const prompt = yield* SessionPrompt.Service + const run = yield* SessionRunState.Service const sessions = yield* Session.Service yield* llm.hang @@ -922,7 +927,7 @@ it.live( const fiber = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild) yield* llm.wait(1) - const exit = yield* prompt.assertNotBusy(chat.id).pipe(Effect.exit) + const exit = yield* run.assertNotBusy(chat.id).pipe(Effect.exit) expect(Exit.isFailure(exit)).toBe(true) if (Exit.isFailure(exit)) { expect(Cause.squash(exit.cause)).toBeInstanceOf(Session.BusyError) @@ -940,11 +945,11 @@ it.live("assertNotBusy succeeds when idle", () => provideTmpdirInstance( (dir) => Effect.gen(function* () { - const prompt = yield* SessionPrompt.Service + const run = yield* SessionRunState.Service const sessions = yield* Session.Service const chat = yield* sessions.create({}) - const exit = yield* prompt.assertNotBusy(chat.id).pipe(Effect.exit) + const exit = yield* run.assertNotBusy(chat.id).pipe(Effect.exit) expect(Exit.isSuccess(exit)).toBe(true) }), { git: true }, @@ -985,7 +990,7 @@ unix("shell captures stdout and stderr in completed tool output", () => provideTmpdirInstance( (dir) => Effect.gen(function* () { - const { prompt, chat } = yield* boot() + const { prompt, run, chat } = yield* boot() const result = yield* prompt.shell({ sessionID: chat.id, agent: "build", @@ -1000,7 +1005,7 @@ unix("shell captures stdout and stderr in completed tool output", () => expect(tool.state.output).toContain("err") expect(tool.state.metadata.output).toContain("out") expect(tool.state.metadata.output).toContain("err") - yield* prompt.assertNotBusy(chat.id) + yield* run.assertNotBusy(chat.id) }), { git: true, config: cfg }, ), @@ -1010,7 +1015,7 @@ unix("shell completes a fast command on the preferred shell", () => provideTmpdirInstance( (dir) => Effect.gen(function* () { - const { prompt, chat } = yield* boot() + const { prompt, run, chat } = yield* boot() const result = yield* prompt.shell({ sessionID: chat.id, agent: "build", @@ -1024,7 +1029,7 @@ unix("shell completes a fast command on the preferred shell", () => expect(tool.state.input.command).toBe("pwd") expect(tool.state.output).toContain(dir) expect(tool.state.metadata.output).toContain(dir) - yield* prompt.assertNotBusy(chat.id) + yield* run.assertNotBusy(chat.id) }), { git: true, config: cfg }, ), @@ -1034,7 +1039,7 @@ unix("shell lists files from the project directory", () => provideTmpdirInstance( (dir) => Effect.gen(function* () { - const { prompt, chat } = yield* boot() + const { prompt, run, chat } = yield* boot() yield* Effect.promise(() => Bun.write(path.join(dir, "README.md"), "# e2e\n")) const result = yield* prompt.shell({ @@ -1050,7 +1055,7 @@ unix("shell lists files from the project directory", () => expect(tool.state.input.command).toBe("command ls") expect(tool.state.output).toContain("README.md") expect(tool.state.metadata.output).toContain("README.md") - yield* prompt.assertNotBusy(chat.id) + yield* run.assertNotBusy(chat.id) }), { git: true, config: cfg }, ), @@ -1060,7 +1065,7 @@ unix("shell captures stderr from a failing command", () => provideTmpdirInstance( (dir) => Effect.gen(function* () { - const { prompt, chat } = yield* boot() + const { prompt, run, chat } = yield* boot() const result = yield* prompt.shell({ sessionID: chat.id, agent: "build", @@ -1073,7 +1078,7 @@ unix("shell captures stderr from a failing command", () => expect(tool.state.output).toContain("not found") expect(tool.state.metadata.output).toContain("not found") - yield* prompt.assertNotBusy(chat.id) + yield* run.assertNotBusy(chat.id) }), { git: true, config: cfg }, ), @@ -1198,7 +1203,7 @@ unix( provideTmpdirInstance( (dir) => Effect.gen(function* () { - const { prompt, chat } = yield* boot() + const { prompt, run, chat } = yield* boot() const sh = yield* prompt .shell({ sessionID: chat.id, agent: "build", command: "sleep 30" }) @@ -1209,7 +1214,7 @@ unix( const status = yield* SessionStatus.Service expect((yield* status.get(chat.id)).type).toBe("idle") - const busy = yield* prompt.assertNotBusy(chat.id).pipe(Effect.exit) + const busy = yield* run.assertNotBusy(chat.id).pipe(Effect.exit) expect(Exit.isSuccess(busy)).toBe(true) const exit = yield* Fiber.await(sh) diff --git a/packages/opencode/test/session/snapshot-tool-race.test.ts b/packages/opencode/test/session/snapshot-tool-race.test.ts index c192a446bd49..9cc4d750c2c1 100644 --- a/packages/opencode/test/session/snapshot-tool-race.test.ts +++ b/packages/opencode/test/session/snapshot-tool-race.test.ts @@ -43,6 +43,7 @@ import { Todo } from "../../src/session/todo" import { SessionCompaction } from "../../src/session/compaction" import { Instruction } from "../../src/session/instruction" import { SessionProcessor } from "../../src/session/processor" +import { SessionRunState } from "../../src/session/run-state" import { SessionStatus } from "../../src/session/status" import { Shell } from "../../src/shell/shell" import { Snapshot } from "../../src/snapshot" @@ -107,6 +108,7 @@ const filetime = Layer.succeed( ) const status = SessionStatus.layer.pipe(Layer.provideMerge(Bus.layer)) +const run = SessionRunState.layer.pipe(Layer.provide(status)) const infra = Layer.mergeAll(NodeFileSystem.layer, CrossSpawnSpawner.defaultLayer) function makeHttp() { @@ -139,6 +141,7 @@ function makeHttp() { return Layer.mergeAll( TestLLMServer.layer, SessionPrompt.layer.pipe( + Layer.provideMerge(run), Layer.provideMerge(compact), Layer.provideMerge(proc), Layer.provideMerge(registry), From 35b44df94ab50bc4e2c4a18f029e0f52c3bf5be7 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Thu, 9 Apr 2026 20:05:05 +0000 Subject: [PATCH 15/29] chore: generate --- packages/sdk/js/src/v2/gen/types.gen.ts | 62 ++++---- packages/sdk/openapi.json | 180 ++++++++++++------------ 2 files changed, 121 insertions(+), 121 deletions(-) diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 38f4db09a3d6..3d1495995d4d 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -94,35 +94,6 @@ export type EventMessagePartDelta = { } } -export type PermissionRequest = { - id: string - sessionID: string - permission: string - patterns: Array - metadata: { - [key: string]: unknown - } - always: Array - tool?: { - messageID: string - callID: string - } -} - -export type EventPermissionAsked = { - type: "permission.asked" - properties: PermissionRequest -} - -export type EventPermissionReplied = { - type: "permission.replied" - properties: { - sessionID: string - requestID: string - reply: "once" | "always" | "reject" - } -} - export type SessionStatus = | { type: "idle" @@ -152,6 +123,35 @@ export type EventSessionIdle = { } } +export type PermissionRequest = { + id: string + sessionID: string + permission: string + patterns: Array + metadata: { + [key: string]: unknown + } + always: Array + tool?: { + messageID: string + callID: string + } +} + +export type EventPermissionAsked = { + type: "permission.asked" + properties: PermissionRequest +} + +export type EventPermissionReplied = { + type: "permission.replied" + properties: { + sessionID: string + requestID: string + reply: "once" | "always" | "reject" + } +} + export type QuestionOption = { /** * Display text (1-5 words, concise) @@ -972,10 +972,10 @@ export type Event = | EventLspClientDiagnostics | EventLspUpdated | EventMessagePartDelta - | EventPermissionAsked - | EventPermissionReplied | EventSessionStatus | EventSessionIdle + | EventPermissionAsked + | EventPermissionReplied | EventQuestionAsked | EventQuestionReplied | EventQuestionRejected diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 366bc1fc792f..0de4dbdd924b 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -7324,6 +7324,92 @@ }, "required": ["type", "properties"] }, + "SessionStatus": { + "anyOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "idle" + } + }, + "required": ["type"] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "retry" + }, + "attempt": { + "type": "number" + }, + "message": { + "type": "string" + }, + "next": { + "type": "number" + } + }, + "required": ["type", "attempt", "message", "next"] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "busy" + } + }, + "required": ["type"] + } + ] + }, + "Event.session.status": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "session.status" + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses.*" + }, + "status": { + "$ref": "#/components/schemas/SessionStatus" + } + }, + "required": ["sessionID", "status"] + } + }, + "required": ["type", "properties"] + }, + "Event.session.idle": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "session.idle" + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses.*" + } + }, + "required": ["sessionID"] + } + }, + "required": ["type", "properties"] + }, "PermissionRequest": { "type": "object", "properties": { @@ -7414,92 +7500,6 @@ }, "required": ["type", "properties"] }, - "SessionStatus": { - "anyOf": [ - { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "idle" - } - }, - "required": ["type"] - }, - { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "retry" - }, - "attempt": { - "type": "number" - }, - "message": { - "type": "string" - }, - "next": { - "type": "number" - } - }, - "required": ["type", "attempt", "message", "next"] - }, - { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "busy" - } - }, - "required": ["type"] - } - ] - }, - "Event.session.status": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "session.status" - }, - "properties": { - "type": "object", - "properties": { - "sessionID": { - "type": "string", - "pattern": "^ses.*" - }, - "status": { - "$ref": "#/components/schemas/SessionStatus" - } - }, - "required": ["sessionID", "status"] - } - }, - "required": ["type", "properties"] - }, - "Event.session.idle": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "session.idle" - }, - "properties": { - "type": "object", - "properties": { - "sessionID": { - "type": "string", - "pattern": "^ses.*" - } - }, - "required": ["sessionID"] - } - }, - "required": ["type", "properties"] - }, "QuestionOption": { "type": "object", "properties": { @@ -9811,16 +9811,16 @@ "$ref": "#/components/schemas/Event.message.part.delta" }, { - "$ref": "#/components/schemas/Event.permission.asked" + "$ref": "#/components/schemas/Event.session.status" }, { - "$ref": "#/components/schemas/Event.permission.replied" + "$ref": "#/components/schemas/Event.session.idle" }, { - "$ref": "#/components/schemas/Event.session.status" + "$ref": "#/components/schemas/Event.permission.asked" }, { - "$ref": "#/components/schemas/Event.session.idle" + "$ref": "#/components/schemas/Event.permission.replied" }, { "$ref": "#/components/schemas/Event.question.asked" From 7202b3a32541c4eb6b39adcc41d2333deb9cd538 Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Thu, 9 Apr 2026 15:25:59 -0500 Subject: [PATCH 16/29] fix: ensure that openai oauth works for agent create cmd, use temporary hack (#21749) Co-authored-by: OpeOginni --- packages/opencode/src/agent/agent.ts | 23 ++++++++++++++--------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index 843d65433c90..2a8bed092799 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -341,6 +341,10 @@ export namespace Agent { ) const existing = yield* InstanceState.useEffect(state, (s) => s.list()) + // TODO: clean this up so provider specific logic doesnt bleed over + const authInfo = yield* auth.get(model.providerID).pipe(Effect.orDie) + const isOpenaiOauth = model.providerID === "openai" && authInfo?.type === "oauth" + const params = { experimental_telemetry: { isEnabled: cfg.experimental?.openTelemetry, @@ -350,12 +354,14 @@ export namespace Agent { }, temperature: 0.3, messages: [ - ...system.map( - (item): ModelMessage => ({ - role: "system", - content: item, - }), - ), + ...(isOpenaiOauth + ? [] + : system.map( + (item): ModelMessage => ({ + role: "system", + content: item, + }), + )), { role: "user", content: `Create an agent configuration based on this request: \"${input.description}\".\n\nIMPORTANT: The following identifiers already exist and must NOT be used: ${existing.map((i) => i.name).join(", ")}\n Return ONLY the JSON object, no other text, do not wrap in backticks`, @@ -369,13 +375,12 @@ export namespace Agent { }), } satisfies Parameters[0] - // TODO: clean this up so provider specific logic doesnt bleed over - const authInfo = yield* auth.get(model.providerID).pipe(Effect.orDie) - if (model.providerID === "openai" && authInfo?.type === "oauth") { + if (isOpenaiOauth) { return yield* Effect.promise(async () => { const result = streamObject({ ...params, providerOptions: ProviderTransform.providerOptions(resolved, { + instructions: system.join("\n"), store: false, }), onError: () => {}, From b2f621b897ca636dc720b035072555e03c0da30a Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 9 Apr 2026 16:28:42 -0400 Subject: [PATCH 17/29] refactor(session): inline init route orchestration (#21754) --- .../opencode/src/server/routes/session.ts | 19 +++++++++-- packages/opencode/src/session/index.ts | 34 +------------------ 2 files changed, 18 insertions(+), 35 deletions(-) diff --git a/packages/opencode/src/server/routes/session.ts b/packages/opencode/src/server/routes/session.ts index fb0b6b69c109..b1a6af582728 100644 --- a/packages/opencode/src/server/routes/session.ts +++ b/packages/opencode/src/server/routes/session.ts @@ -14,6 +14,7 @@ import { SessionSummary } from "@/session/summary" import { Todo } from "../../session/todo" import { Agent } from "../../agent/agent" import { Snapshot } from "@/snapshot" +import { Command } from "../../command" import { Log } from "../../util/log" import { Permission } from "@/permission" import { PermissionID } from "@/permission/schema" @@ -292,6 +293,7 @@ export const SessionRoutes = lazy(() => return c.json(session) }, ) + // TODO(v2): remove this dedicated route and rely on the normal `/init` command flow. .post( "/:sessionID/init", describeRoute({ @@ -317,11 +319,24 @@ export const SessionRoutes = lazy(() => sessionID: SessionID.zod, }), ), - validator("json", Session.initialize.schema.omit({ sessionID: true })), + validator( + "json", + z.object({ + modelID: ModelID.zod, + providerID: ProviderID.zod, + messageID: MessageID.zod, + }), + ), async (c) => { const sessionID = c.req.valid("param").sessionID const body = c.req.valid("json") - await Session.initialize({ ...body, sessionID }) + await SessionPrompt.command({ + sessionID, + messageID: body.messageID, + model: body.providerID + "/" + body.modelID, + command: Command.Default.INIT, + arguments: "", + }) return c.json(true) }, ) diff --git a/packages/opencode/src/session/index.ts b/packages/opencode/src/session/index.ts index cc81293ecb5d..2e68f22ede93 100644 --- a/packages/opencode/src/session/index.ts +++ b/packages/opencode/src/session/index.ts @@ -20,16 +20,13 @@ import { updateSchema } from "../util/update-schema" import { MessageV2 } from "./message-v2" import { Instance } from "../project/instance" import { InstanceState } from "@/effect/instance-state" -import { SessionPrompt } from "./prompt" import { fn } from "@/util/fn" -import { Command } from "../command" import { Snapshot } from "@/snapshot" import { ProjectID } from "../project/schema" import { WorkspaceID } from "../control-plane/schema" import { SessionID, MessageID, PartID } from "./schema" import type { Provider } from "@/provider/provider" -import { ModelID, ProviderID } from "@/provider/schema" import { Permission } from "@/permission" import { Global } from "@/global" import type { LanguageModelV2Usage } from "@ai-sdk/provider" @@ -358,12 +355,6 @@ export namespace Session { field: string delta: string }) => Effect.Effect - readonly initialize: (input: { - sessionID: SessionID - modelID: ModelID - providerID: ProviderID - messageID: MessageID - }) => Effect.Effect } export class Service extends ServiceMap.Service()("@opencode/Session") {} @@ -616,7 +607,7 @@ export namespace Session { const diff = Effect.fn("Session.diff")(function* (sessionID: SessionID) { return yield* Effect.tryPromise(() => Storage.read(["session_diff", sessionID])).pipe( - Effect.orElseSucceed(() => [] as Snapshot.FileDiff[]), + Effect.orElseSucceed((): Snapshot.FileDiff[] => []), ) }) @@ -665,23 +656,6 @@ export namespace Session { yield* bus.publish(MessageV2.Event.PartDelta, input) }) - const initialize = Effect.fn("Session.initialize")(function* (input: { - sessionID: SessionID - modelID: ModelID - providerID: ProviderID - messageID: MessageID - }) { - yield* Effect.promise(() => - SessionPrompt.command({ - sessionID: input.sessionID, - messageID: input.messageID, - model: input.providerID + "/" + input.modelID, - command: Command.Default.INIT, - arguments: "", - }), - ) - }) - return Service.of({ create, fork, @@ -705,7 +679,6 @@ export namespace Session { updatePart, getPart, updatePartDelta, - initialize, }) }), ) @@ -895,9 +868,4 @@ export namespace Session { }), (input) => runPromise((svc) => svc.updatePartDelta(input)), ) - - export const initialize = fn( - z.object({ sessionID: SessionID.zod, modelID: ModelID.zod, providerID: ProviderID.zod, messageID: MessageID.zod }), - (input) => runPromise((svc) => svc.initialize(input)), - ) } From bbe4a04f9fcba9e46eb1baf5e7152b22465808d5 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Thu, 9 Apr 2026 20:29:48 +0000 Subject: [PATCH 18/29] chore: generate --- packages/sdk/js/src/v2/gen/types.gen.ts | 378 +++++------ packages/sdk/openapi.json | 794 ++++++++++++------------ 2 files changed, 586 insertions(+), 586 deletions(-) diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 3d1495995d4d..62c62e138f6f 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -94,35 +94,6 @@ export type EventMessagePartDelta = { } } -export type SessionStatus = - | { - type: "idle" - } - | { - type: "retry" - attempt: number - message: string - next: number - } - | { - type: "busy" - } - -export type EventSessionStatus = { - type: "session.status" - properties: { - sessionID: string - status: SessionStatus - } -} - -export type EventSessionIdle = { - type: "session.idle" - properties: { - sessionID: string - } -} - export type PermissionRequest = { id: string sessionID: string @@ -152,81 +123,95 @@ export type EventPermissionReplied = { } } -export type QuestionOption = { - /** - * Display text (1-5 words, concise) - */ - label: string - /** - * Explanation of choice - */ - description: string +export type SnapshotFileDiff = { + file: string + patch: string + additions: number + deletions: number + status?: "added" | "deleted" | "modified" } -export type QuestionInfo = { - /** - * Complete question - */ - question: string - /** - * Very short label (max 30 chars) - */ - header: string - /** - * Available choices - */ - options: Array - /** - * Allow selecting multiple choices - */ - multiple?: boolean - /** - * Allow typing a custom answer (default: true) - */ - custom?: boolean +export type EventSessionDiff = { + type: "session.diff" + properties: { + sessionID: string + diff: Array + } } -export type QuestionRequest = { - id: string - sessionID: string - /** - * Questions to ask - */ - questions: Array - tool?: { - messageID: string - callID: string +export type ProviderAuthError = { + name: "ProviderAuthError" + data: { + providerID: string + message: string } } -export type EventQuestionAsked = { - type: "question.asked" - properties: QuestionRequest +export type UnknownError = { + name: "UnknownError" + data: { + message: string + } } -export type QuestionAnswer = Array +export type MessageOutputLengthError = { + name: "MessageOutputLengthError" + data: { + [key: string]: unknown + } +} -export type EventQuestionReplied = { - type: "question.replied" - properties: { - sessionID: string - requestID: string - answers: Array +export type MessageAbortedError = { + name: "MessageAbortedError" + data: { + message: string } } -export type EventQuestionRejected = { - type: "question.rejected" - properties: { - sessionID: string - requestID: string +export type StructuredOutputError = { + name: "StructuredOutputError" + data: { + message: string + retries: number } } -export type EventSessionCompacted = { - type: "session.compacted" +export type ContextOverflowError = { + name: "ContextOverflowError" + data: { + message: string + responseBody?: string + } +} + +export type ApiError = { + name: "APIError" + data: { + message: string + statusCode?: number + isRetryable: boolean + responseHeaders?: { + [key: string]: string + } + responseBody?: string + metadata?: { + [key: string]: string + } + } +} + +export type EventSessionError = { + type: "session.error" properties: { - sessionID: string + sessionID?: string + error?: + | ProviderAuthError + | UnknownError + | MessageOutputLengthError + | MessageAbortedError + | StructuredOutputError + | ContextOverflowError + | ApiError } } @@ -245,26 +230,10 @@ export type EventFileWatcherUpdated = { } } -export type Todo = { - /** - * Brief description of the task - */ - content: string - /** - * Current status of the task: pending, in_progress, completed, cancelled - */ - status: string - /** - * Priority level of the task: high, medium, low - */ - priority: string -} - -export type EventTodoUpdated = { - type: "todo.updated" +export type EventVcsBranchUpdated = { + type: "vcs.branch.updated" properties: { - sessionID: string - todos: Array + branch?: string } } @@ -347,116 +316,147 @@ export type EventCommandExecuted = { } } -export type SnapshotFileDiff = { - file: string - patch: string - additions: number - deletions: number - status?: "added" | "deleted" | "modified" -} - -export type EventSessionDiff = { - type: "session.diff" +export type EventWorkspaceReady = { + type: "workspace.ready" properties: { - sessionID: string - diff: Array + name: string } } -export type ProviderAuthError = { - name: "ProviderAuthError" - data: { - providerID: string +export type EventWorkspaceFailed = { + type: "workspace.failed" + properties: { message: string } } -export type UnknownError = { - name: "UnknownError" - data: { - message: string - } +export type QuestionOption = { + /** + * Display text (1-5 words, concise) + */ + label: string + /** + * Explanation of choice + */ + description: string } -export type MessageOutputLengthError = { - name: "MessageOutputLengthError" - data: { - [key: string]: unknown - } +export type QuestionInfo = { + /** + * Complete question + */ + question: string + /** + * Very short label (max 30 chars) + */ + header: string + /** + * Available choices + */ + options: Array + /** + * Allow selecting multiple choices + */ + multiple?: boolean + /** + * Allow typing a custom answer (default: true) + */ + custom?: boolean } -export type MessageAbortedError = { - name: "MessageAbortedError" - data: { - message: string +export type QuestionRequest = { + id: string + sessionID: string + /** + * Questions to ask + */ + questions: Array + tool?: { + messageID: string + callID: string } } -export type StructuredOutputError = { - name: "StructuredOutputError" - data: { - message: string - retries: number +export type EventQuestionAsked = { + type: "question.asked" + properties: QuestionRequest +} + +export type QuestionAnswer = Array + +export type EventQuestionReplied = { + type: "question.replied" + properties: { + sessionID: string + requestID: string + answers: Array } } -export type ContextOverflowError = { - name: "ContextOverflowError" - data: { - message: string - responseBody?: string +export type EventQuestionRejected = { + type: "question.rejected" + properties: { + sessionID: string + requestID: string } } -export type ApiError = { - name: "APIError" - data: { - message: string - statusCode?: number - isRetryable: boolean - responseHeaders?: { - [key: string]: string +export type SessionStatus = + | { + type: "idle" } - responseBody?: string - metadata?: { - [key: string]: string + | { + type: "retry" + attempt: number + message: string + next: number + } + | { + type: "busy" } - } -} -export type EventSessionError = { - type: "session.error" +export type EventSessionStatus = { + type: "session.status" properties: { - sessionID?: string - error?: - | ProviderAuthError - | UnknownError - | MessageOutputLengthError - | MessageAbortedError - | StructuredOutputError - | ContextOverflowError - | ApiError + sessionID: string + status: SessionStatus } } -export type EventVcsBranchUpdated = { - type: "vcs.branch.updated" +export type EventSessionIdle = { + type: "session.idle" properties: { - branch?: string + sessionID: string } } -export type EventWorkspaceReady = { - type: "workspace.ready" +export type EventSessionCompacted = { + type: "session.compacted" properties: { - name: string + sessionID: string } } -export type EventWorkspaceFailed = { - type: "workspace.failed" +export type Todo = { + /** + * Brief description of the task + */ + content: string + /** + * Current status of the task: pending, in_progress, completed, cancelled + */ + status: string + /** + * Priority level of the task: high, medium, low + */ + priority: string +} + +export type EventTodoUpdated = { + type: "todo.updated" properties: { - message: string + sessionID: string + todos: Array } } @@ -972,17 +972,13 @@ export type Event = | EventLspClientDiagnostics | EventLspUpdated | EventMessagePartDelta - | EventSessionStatus - | EventSessionIdle | EventPermissionAsked | EventPermissionReplied - | EventQuestionAsked - | EventQuestionReplied - | EventQuestionRejected - | EventSessionCompacted + | EventSessionDiff + | EventSessionError | EventFileEdited | EventFileWatcherUpdated - | EventTodoUpdated + | EventVcsBranchUpdated | EventTuiPromptAppend | EventTuiCommandExecute | EventTuiToastShow @@ -990,11 +986,15 @@ export type Event = | EventMcpToolsChanged | EventMcpBrowserOpenFailed | EventCommandExecuted - | EventSessionDiff - | EventSessionError - | EventVcsBranchUpdated | EventWorkspaceReady | EventWorkspaceFailed + | EventQuestionAsked + | EventQuestionReplied + | EventQuestionRejected + | EventSessionStatus + | EventSessionIdle + | EventSessionCompacted + | EventTodoUpdated | EventPtyCreated | EventPtyUpdated | EventPtyExited diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 0de4dbdd924b..450de5131931 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -7324,92 +7324,6 @@ }, "required": ["type", "properties"] }, - "SessionStatus": { - "anyOf": [ - { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "idle" - } - }, - "required": ["type"] - }, - { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "retry" - }, - "attempt": { - "type": "number" - }, - "message": { - "type": "string" - }, - "next": { - "type": "number" - } - }, - "required": ["type", "attempt", "message", "next"] - }, - { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "busy" - } - }, - "required": ["type"] - } - ] - }, - "Event.session.status": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "session.status" - }, - "properties": { - "type": "object", - "properties": { - "sessionID": { - "type": "string", - "pattern": "^ses.*" - }, - "status": { - "$ref": "#/components/schemas/SessionStatus" - } - }, - "required": ["sessionID", "status"] - } - }, - "required": ["type", "properties"] - }, - "Event.session.idle": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "session.idle" - }, - "properties": { - "type": "object", - "properties": { - "sessionID": { - "type": "string", - "pattern": "^ses.*" - } - }, - "required": ["sessionID"] - } - }, - "required": ["type", "properties"] - }, "PermissionRequest": { "type": "object", "properties": { @@ -7500,162 +7414,224 @@ }, "required": ["type", "properties"] }, - "QuestionOption": { + "SnapshotFileDiff": { "type": "object", "properties": { - "label": { - "description": "Display text (1-5 words, concise)", + "file": { "type": "string" }, - "description": { - "description": "Explanation of choice", + "patch": { "type": "string" + }, + "additions": { + "type": "number" + }, + "deletions": { + "type": "number" + }, + "status": { + "type": "string", + "enum": ["added", "deleted", "modified"] } }, - "required": ["label", "description"] + "required": ["file", "patch", "additions", "deletions"] }, - "QuestionInfo": { + "Event.session.diff": { "type": "object", "properties": { - "question": { - "description": "Complete question", - "type": "string" - }, - "header": { - "description": "Very short label (max 30 chars)", - "type": "string" - }, - "options": { - "description": "Available choices", - "type": "array", - "items": { - "$ref": "#/components/schemas/QuestionOption" - } - }, - "multiple": { - "description": "Allow selecting multiple choices", - "type": "boolean" + "type": { + "type": "string", + "const": "session.diff" }, - "custom": { - "description": "Allow typing a custom answer (default: true)", - "type": "boolean" + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses.*" + }, + "diff": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SnapshotFileDiff" + } + } + }, + "required": ["sessionID", "diff"] } }, - "required": ["question", "header", "options"] + "required": ["type", "properties"] }, - "QuestionRequest": { + "ProviderAuthError": { "type": "object", "properties": { - "id": { + "name": { "type": "string", - "pattern": "^que.*" + "const": "ProviderAuthError" }, - "sessionID": { + "data": { + "type": "object", + "properties": { + "providerID": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": ["providerID", "message"] + } + }, + "required": ["name", "data"] + }, + "UnknownError": { + "type": "object", + "properties": { + "name": { "type": "string", - "pattern": "^ses.*" - }, - "questions": { - "description": "Questions to ask", - "type": "array", - "items": { - "$ref": "#/components/schemas/QuestionInfo" - } + "const": "UnknownError" }, - "tool": { + "data": { "type": "object", "properties": { - "messageID": { - "type": "string", - "pattern": "^msg.*" - }, - "callID": { + "message": { "type": "string" } }, - "required": ["messageID", "callID"] + "required": ["message"] } }, - "required": ["id", "sessionID", "questions"] + "required": ["name", "data"] }, - "Event.question.asked": { + "MessageOutputLengthError": { "type": "object", "properties": { - "type": { + "name": { "type": "string", - "const": "question.asked" + "const": "MessageOutputLengthError" }, - "properties": { - "$ref": "#/components/schemas/QuestionRequest" + "data": { + "type": "object", + "properties": {} } }, - "required": ["type", "properties"] + "required": ["name", "data"] }, - "QuestionAnswer": { - "type": "array", - "items": { - "type": "string" - } + "MessageAbortedError": { + "type": "object", + "properties": { + "name": { + "type": "string", + "const": "MessageAbortedError" + }, + "data": { + "type": "object", + "properties": { + "message": { + "type": "string" + } + }, + "required": ["message"] + } + }, + "required": ["name", "data"] }, - "Event.question.replied": { + "StructuredOutputError": { "type": "object", "properties": { - "type": { + "name": { "type": "string", - "const": "question.replied" + "const": "StructuredOutputError" }, - "properties": { + "data": { "type": "object", "properties": { - "sessionID": { - "type": "string", - "pattern": "^ses.*" + "message": { + "type": "string" }, - "requestID": { - "type": "string", - "pattern": "^que.*" + "retries": { + "type": "number" + } + }, + "required": ["message", "retries"] + } + }, + "required": ["name", "data"] + }, + "ContextOverflowError": { + "type": "object", + "properties": { + "name": { + "type": "string", + "const": "ContextOverflowError" + }, + "data": { + "type": "object", + "properties": { + "message": { + "type": "string" }, - "answers": { - "type": "array", - "items": { - "$ref": "#/components/schemas/QuestionAnswer" - } + "responseBody": { + "type": "string" } }, - "required": ["sessionID", "requestID", "answers"] + "required": ["message"] } }, - "required": ["type", "properties"] + "required": ["name", "data"] }, - "Event.question.rejected": { + "APIError": { "type": "object", "properties": { - "type": { + "name": { "type": "string", - "const": "question.rejected" + "const": "APIError" }, - "properties": { + "data": { "type": "object", "properties": { - "sessionID": { - "type": "string", - "pattern": "^ses.*" + "message": { + "type": "string" }, - "requestID": { - "type": "string", - "pattern": "^que.*" + "statusCode": { + "type": "number" + }, + "isRetryable": { + "type": "boolean" + }, + "responseHeaders": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string" + } + }, + "responseBody": { + "type": "string" + }, + "metadata": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string" + } } }, - "required": ["sessionID", "requestID"] + "required": ["message", "isRetryable"] } }, - "required": ["type", "properties"] + "required": ["name", "data"] }, - "Event.session.compacted": { + "Event.session.error": { "type": "object", "properties": { "type": { "type": "string", - "const": "session.compacted" + "const": "session.error" }, "properties": { "type": "object", @@ -7663,9 +7639,33 @@ "sessionID": { "type": "string", "pattern": "^ses.*" + }, + "error": { + "anyOf": [ + { + "$ref": "#/components/schemas/ProviderAuthError" + }, + { + "$ref": "#/components/schemas/UnknownError" + }, + { + "$ref": "#/components/schemas/MessageOutputLengthError" + }, + { + "$ref": "#/components/schemas/MessageAbortedError" + }, + { + "$ref": "#/components/schemas/StructuredOutputError" + }, + { + "$ref": "#/components/schemas/ContextOverflowError" + }, + { + "$ref": "#/components/schemas/APIError" + } + ] } - }, - "required": ["sessionID"] + } } }, "required": ["type", "properties"] @@ -7724,46 +7724,20 @@ }, "required": ["type", "properties"] }, - "Todo": { - "type": "object", - "properties": { - "content": { - "description": "Brief description of the task", - "type": "string" - }, - "status": { - "description": "Current status of the task: pending, in_progress, completed, cancelled", - "type": "string" - }, - "priority": { - "description": "Priority level of the task: high, medium, low", - "type": "string" - } - }, - "required": ["content", "status", "priority"] - }, - "Event.todo.updated": { + "Event.vcs.branch.updated": { "type": "object", "properties": { "type": { "type": "string", - "const": "todo.updated" + "const": "vcs.branch.updated" }, "properties": { "type": "object", "properties": { - "sessionID": { - "type": "string", - "pattern": "^ses.*" - }, - "todos": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Todo" - } + "branch": { + "type": "string" } - }, - "required": ["sessionID", "todos"] + } } }, "required": ["type", "properties"] @@ -7954,224 +7928,243 @@ }, "required": ["type", "properties"] }, - "SnapshotFileDiff": { - "type": "object", - "properties": { - "file": { - "type": "string" - }, - "patch": { - "type": "string" - }, - "additions": { - "type": "number" - }, - "deletions": { - "type": "number" - }, - "status": { - "type": "string", - "enum": ["added", "deleted", "modified"] - } - }, - "required": ["file", "patch", "additions", "deletions"] - }, - "Event.session.diff": { + "Event.workspace.ready": { "type": "object", "properties": { "type": { "type": "string", - "const": "session.diff" + "const": "workspace.ready" }, "properties": { "type": "object", "properties": { - "sessionID": { - "type": "string", - "pattern": "^ses.*" - }, - "diff": { - "type": "array", - "items": { - "$ref": "#/components/schemas/SnapshotFileDiff" - } + "name": { + "type": "string" } }, - "required": ["sessionID", "diff"] + "required": ["name"] } }, "required": ["type", "properties"] }, - "ProviderAuthError": { + "Event.workspace.failed": { "type": "object", "properties": { - "name": { + "type": { "type": "string", - "const": "ProviderAuthError" + "const": "workspace.failed" }, - "data": { + "properties": { "type": "object", "properties": { - "providerID": { - "type": "string" - }, "message": { "type": "string" } }, - "required": ["providerID", "message"] + "required": ["message"] } }, - "required": ["name", "data"] + "required": ["type", "properties"] }, - "UnknownError": { + "QuestionOption": { "type": "object", "properties": { - "name": { - "type": "string", - "const": "UnknownError" + "label": { + "description": "Display text (1-5 words, concise)", + "type": "string" }, - "data": { - "type": "object", - "properties": { - "message": { - "type": "string" - } - }, - "required": ["message"] + "description": { + "description": "Explanation of choice", + "type": "string" } }, - "required": ["name", "data"] + "required": ["label", "description"] }, - "MessageOutputLengthError": { + "QuestionInfo": { "type": "object", "properties": { - "name": { - "type": "string", - "const": "MessageOutputLengthError" + "question": { + "description": "Complete question", + "type": "string" }, - "data": { - "type": "object", - "properties": {} + "header": { + "description": "Very short label (max 30 chars)", + "type": "string" + }, + "options": { + "description": "Available choices", + "type": "array", + "items": { + "$ref": "#/components/schemas/QuestionOption" + } + }, + "multiple": { + "description": "Allow selecting multiple choices", + "type": "boolean" + }, + "custom": { + "description": "Allow typing a custom answer (default: true)", + "type": "boolean" } }, - "required": ["name", "data"] + "required": ["question", "header", "options"] }, - "MessageAbortedError": { + "QuestionRequest": { "type": "object", "properties": { - "name": { + "id": { "type": "string", - "const": "MessageAbortedError" + "pattern": "^que.*" }, - "data": { + "sessionID": { + "type": "string", + "pattern": "^ses.*" + }, + "questions": { + "description": "Questions to ask", + "type": "array", + "items": { + "$ref": "#/components/schemas/QuestionInfo" + } + }, + "tool": { "type": "object", "properties": { - "message": { + "messageID": { + "type": "string", + "pattern": "^msg.*" + }, + "callID": { "type": "string" } }, - "required": ["message"] + "required": ["messageID", "callID"] + } + }, + "required": ["id", "sessionID", "questions"] + }, + "Event.question.asked": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "question.asked" + }, + "properties": { + "$ref": "#/components/schemas/QuestionRequest" } }, - "required": ["name", "data"] + "required": ["type", "properties"] }, - "StructuredOutputError": { + "QuestionAnswer": { + "type": "array", + "items": { + "type": "string" + } + }, + "Event.question.replied": { "type": "object", "properties": { - "name": { + "type": { "type": "string", - "const": "StructuredOutputError" + "const": "question.replied" }, - "data": { + "properties": { "type": "object", "properties": { - "message": { - "type": "string" + "sessionID": { + "type": "string", + "pattern": "^ses.*" }, - "retries": { - "type": "number" + "requestID": { + "type": "string", + "pattern": "^que.*" + }, + "answers": { + "type": "array", + "items": { + "$ref": "#/components/schemas/QuestionAnswer" + } } }, - "required": ["message", "retries"] + "required": ["sessionID", "requestID", "answers"] } }, - "required": ["name", "data"] + "required": ["type", "properties"] }, - "ContextOverflowError": { + "Event.question.rejected": { "type": "object", "properties": { - "name": { + "type": { "type": "string", - "const": "ContextOverflowError" + "const": "question.rejected" }, - "data": { + "properties": { "type": "object", "properties": { - "message": { - "type": "string" + "sessionID": { + "type": "string", + "pattern": "^ses.*" }, - "responseBody": { - "type": "string" + "requestID": { + "type": "string", + "pattern": "^que.*" } }, - "required": ["message"] + "required": ["sessionID", "requestID"] } }, - "required": ["name", "data"] + "required": ["type", "properties"] }, - "APIError": { - "type": "object", - "properties": { - "name": { - "type": "string", - "const": "APIError" + "SessionStatus": { + "anyOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "idle" + } + }, + "required": ["type"] }, - "data": { + { "type": "object", "properties": { - "message": { - "type": "string" + "type": { + "type": "string", + "const": "retry" }, - "statusCode": { + "attempt": { "type": "number" }, - "isRetryable": { - "type": "boolean" - }, - "responseHeaders": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "type": "string" - } - }, - "responseBody": { + "message": { "type": "string" }, - "metadata": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "type": "string" - } + "next": { + "type": "number" } }, - "required": ["message", "isRetryable"] + "required": ["type", "attempt", "message", "next"] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "busy" + } + }, + "required": ["type"] } - }, - "required": ["name", "data"] + ] }, - "Event.session.error": { + "Event.session.status": { "type": "object", "properties": { "type": { "type": "string", - "const": "session.error" + "const": "session.status" }, "properties": { "type": "object", @@ -8180,88 +8173,95 @@ "type": "string", "pattern": "^ses.*" }, - "error": { - "anyOf": [ - { - "$ref": "#/components/schemas/ProviderAuthError" - }, - { - "$ref": "#/components/schemas/UnknownError" - }, - { - "$ref": "#/components/schemas/MessageOutputLengthError" - }, - { - "$ref": "#/components/schemas/MessageAbortedError" - }, - { - "$ref": "#/components/schemas/StructuredOutputError" - }, - { - "$ref": "#/components/schemas/ContextOverflowError" - }, - { - "$ref": "#/components/schemas/APIError" - } - ] + "status": { + "$ref": "#/components/schemas/SessionStatus" } - } + }, + "required": ["sessionID", "status"] } }, "required": ["type", "properties"] }, - "Event.vcs.branch.updated": { + "Event.session.idle": { "type": "object", "properties": { "type": { "type": "string", - "const": "vcs.branch.updated" + "const": "session.idle" }, "properties": { "type": "object", "properties": { - "branch": { - "type": "string" + "sessionID": { + "type": "string", + "pattern": "^ses.*" } - } + }, + "required": ["sessionID"] } }, "required": ["type", "properties"] }, - "Event.workspace.ready": { + "Event.session.compacted": { "type": "object", "properties": { "type": { "type": "string", - "const": "workspace.ready" + "const": "session.compacted" }, "properties": { "type": "object", "properties": { - "name": { - "type": "string" + "sessionID": { + "type": "string", + "pattern": "^ses.*" } }, - "required": ["name"] + "required": ["sessionID"] } }, "required": ["type", "properties"] }, - "Event.workspace.failed": { + "Todo": { + "type": "object", + "properties": { + "content": { + "description": "Brief description of the task", + "type": "string" + }, + "status": { + "description": "Current status of the task: pending, in_progress, completed, cancelled", + "type": "string" + }, + "priority": { + "description": "Priority level of the task: high, medium, low", + "type": "string" + } + }, + "required": ["content", "status", "priority"] + }, + "Event.todo.updated": { "type": "object", "properties": { "type": { "type": "string", - "const": "workspace.failed" + "const": "todo.updated" }, "properties": { "type": "object", "properties": { - "message": { - "type": "string" + "sessionID": { + "type": "string", + "pattern": "^ses.*" + }, + "todos": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Todo" + } } }, - "required": ["message"] + "required": ["sessionID", "todos"] } }, "required": ["type", "properties"] @@ -9810,12 +9810,6 @@ { "$ref": "#/components/schemas/Event.message.part.delta" }, - { - "$ref": "#/components/schemas/Event.session.status" - }, - { - "$ref": "#/components/schemas/Event.session.idle" - }, { "$ref": "#/components/schemas/Event.permission.asked" }, @@ -9823,16 +9817,10 @@ "$ref": "#/components/schemas/Event.permission.replied" }, { - "$ref": "#/components/schemas/Event.question.asked" - }, - { - "$ref": "#/components/schemas/Event.question.replied" - }, - { - "$ref": "#/components/schemas/Event.question.rejected" + "$ref": "#/components/schemas/Event.session.diff" }, { - "$ref": "#/components/schemas/Event.session.compacted" + "$ref": "#/components/schemas/Event.session.error" }, { "$ref": "#/components/schemas/Event.file.edited" @@ -9841,7 +9829,7 @@ "$ref": "#/components/schemas/Event.file.watcher.updated" }, { - "$ref": "#/components/schemas/Event.todo.updated" + "$ref": "#/components/schemas/Event.vcs.branch.updated" }, { "$ref": "#/components/schemas/Event.tui.prompt.append" @@ -9865,19 +9853,31 @@ "$ref": "#/components/schemas/Event.command.executed" }, { - "$ref": "#/components/schemas/Event.session.diff" + "$ref": "#/components/schemas/Event.workspace.ready" }, { - "$ref": "#/components/schemas/Event.session.error" + "$ref": "#/components/schemas/Event.workspace.failed" }, { - "$ref": "#/components/schemas/Event.vcs.branch.updated" + "$ref": "#/components/schemas/Event.question.asked" }, { - "$ref": "#/components/schemas/Event.workspace.ready" + "$ref": "#/components/schemas/Event.question.replied" }, { - "$ref": "#/components/schemas/Event.workspace.failed" + "$ref": "#/components/schemas/Event.question.rejected" + }, + { + "$ref": "#/components/schemas/Event.session.status" + }, + { + "$ref": "#/components/schemas/Event.session.idle" + }, + { + "$ref": "#/components/schemas/Event.session.compacted" + }, + { + "$ref": "#/components/schemas/Event.todo.updated" }, { "$ref": "#/components/schemas/Event.pty.created" From 1a902b291c5c096b33a5f618721559e03f0a5dab Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Thu, 9 Apr 2026 16:33:38 -0400 Subject: [PATCH 19/29] ci: skip winget publish on beta and ensure finalize always runs Beta releases no longer trigger unnecessary Winget submissions, and release finalization now completes even when some build artifacts are missing. --- .github/workflows/publish.yml | 2 ++ packages/desktop/scripts/finalize-latest-json.ts | 7 +++++-- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 276e07748d7d..2f305be5c595 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -213,6 +213,7 @@ jobs: needs: - build-cli - version + if: github.ref_name != 'beta' continue-on-error: false env: AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} @@ -547,6 +548,7 @@ jobs: - sign-cli-windows - build-tauri - build-electron + if: always() && !failure() && !cancelled() runs-on: blacksmith-4vcpu-ubuntu-2404 steps: - uses: actions/checkout@v3 diff --git a/packages/desktop/scripts/finalize-latest-json.ts b/packages/desktop/scripts/finalize-latest-json.ts index a2b95d2c4771..855c6a3878cf 100644 --- a/packages/desktop/scripts/finalize-latest-json.ts +++ b/packages/desktop/scripts/finalize-latest-json.ts @@ -21,7 +21,7 @@ const releaseId = process.env.OPENCODE_RELEASE if (!releaseId) throw new Error("OPENCODE_RELEASE is required") const version = process.env.OPENCODE_VERSION -if (!releaseId) throw new Error("OPENCODE_VERSION is required") +if (!version) throw new Error("OPENCODE_VERSION is required") const token = process.env.GH_TOKEN ?? process.env.GITHUB_TOKEN if (!token) throw new Error("GH_TOKEN or GITHUB_TOKEN is required") @@ -54,7 +54,10 @@ const assets = release.assets ?? [] const assetByName = new Map(assets.map((asset) => [asset.name, asset])) const latestAsset = assetByName.get("latest.json") -if (!latestAsset) throw new Error("latest.json asset not found") +if (!latestAsset) { + console.log("latest.json not found, skipping tauri finalization") + process.exit(0) +} const latestRes = await fetch(latestAsset.url, { headers: { From eac50f9151be808c06a36612a69a0e4522988cdd Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Thu, 9 Apr 2026 17:06:53 -0400 Subject: [PATCH 20/29] ci: prevent beta branch builds from triggering production release steps Skip Windows and Linux code signing, along with artifact downloads for the beta branch to ensure beta builds don't go through production release processes. --- .github/workflows/publish.yml | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 2f305be5c595..46a6577807d7 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -114,7 +114,7 @@ jobs: - build-cli - version runs-on: blacksmith-4vcpu-windows-2025 - if: github.repository == 'anomalyco/opencode' + if: github.repository == 'anomalyco/opencode' && github.ref_name != 'beta' env: AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} @@ -390,6 +390,7 @@ jobs: needs: - build-cli - version + if: github.repository == 'anomalyco/opencode' && github.ref_name != 'beta' continue-on-error: false env: AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} @@ -422,7 +423,6 @@ jobs: target: aarch64-unknown-linux-gnu platform_flag: --linux runs-on: ${{ matrix.settings.host }} - # if: github.ref_name == 'beta' steps: - uses: actions/checkout@v3 @@ -591,12 +591,13 @@ jobs: path: packages/opencode/dist - uses: actions/download-artifact@v4 + if: github.ref_name != 'beta' with: name: opencode-cli-signed-windows path: packages/opencode/dist - uses: actions/download-artifact@v4 - if: needs.version.outputs.release + if: needs.version.outputs.release && github.ref_name != 'beta' with: pattern: latest-yml-* path: /tmp/latest-yml From 877be7e8e04142cd8fbebcb5e6c4b9617bf28cce Mon Sep 17 00:00:00 2001 From: opencode Date: Fri, 10 Apr 2026 01:00:12 +0000 Subject: [PATCH 21/29] release: v1.4.3 --- bun.lock | 32 +++++++++++++------------- 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/desktop-electron/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/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/util/package.json | 2 +- packages/web/package.json | 2 +- sdks/vscode/package.json | 2 +- 19 files changed, 39 insertions(+), 39 deletions(-) diff --git a/bun.lock b/bun.lock index c700ba66ecda..deeda0646516 100644 --- a/bun.lock +++ b/bun.lock @@ -27,7 +27,7 @@ }, "packages/app": { "name": "@opencode-ai/app", - "version": "1.4.2", + "version": "1.4.3", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/sdk": "workspace:*", @@ -81,7 +81,7 @@ }, "packages/console/app": { "name": "@opencode-ai/console-app", - "version": "1.4.2", + "version": "1.4.3", "dependencies": { "@cloudflare/vite-plugin": "1.15.2", "@ibm/plex": "6.4.1", @@ -115,7 +115,7 @@ }, "packages/console/core": { "name": "@opencode-ai/console-core", - "version": "1.4.2", + "version": "1.4.3", "dependencies": { "@aws-sdk/client-sts": "3.782.0", "@jsx-email/render": "1.1.1", @@ -142,7 +142,7 @@ }, "packages/console/function": { "name": "@opencode-ai/console-function", - "version": "1.4.2", + "version": "1.4.3", "dependencies": { "@ai-sdk/anthropic": "3.0.64", "@ai-sdk/openai": "3.0.48", @@ -166,7 +166,7 @@ }, "packages/console/mail": { "name": "@opencode-ai/console-mail", - "version": "1.4.2", + "version": "1.4.3", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", @@ -190,7 +190,7 @@ }, "packages/desktop": { "name": "@opencode-ai/desktop", - "version": "1.4.2", + "version": "1.4.3", "dependencies": { "@opencode-ai/app": "workspace:*", "@opencode-ai/ui": "workspace:*", @@ -223,7 +223,7 @@ }, "packages/desktop-electron": { "name": "@opencode-ai/desktop-electron", - "version": "1.4.2", + "version": "1.4.3", "dependencies": { "effect": "catalog:", "electron-context-menu": "4.1.2", @@ -266,7 +266,7 @@ }, "packages/enterprise": { "name": "@opencode-ai/enterprise", - "version": "1.4.2", + "version": "1.4.3", "dependencies": { "@opencode-ai/ui": "workspace:*", "@opencode-ai/util": "workspace:*", @@ -295,7 +295,7 @@ }, "packages/function": { "name": "@opencode-ai/function", - "version": "1.4.2", + "version": "1.4.3", "dependencies": { "@octokit/auth-app": "8.0.1", "@octokit/rest": "catalog:", @@ -311,7 +311,7 @@ }, "packages/opencode": { "name": "opencode", - "version": "1.4.2", + "version": "1.4.3", "bin": { "opencode": "./bin/opencode", }, @@ -447,7 +447,7 @@ }, "packages/plugin": { "name": "@opencode-ai/plugin", - "version": "1.4.2", + "version": "1.4.3", "dependencies": { "@opencode-ai/sdk": "workspace:*", "zod": "catalog:", @@ -481,7 +481,7 @@ }, "packages/sdk/js": { "name": "@opencode-ai/sdk", - "version": "1.4.2", + "version": "1.4.3", "dependencies": { "cross-spawn": "catalog:", }, @@ -496,7 +496,7 @@ }, "packages/slack": { "name": "@opencode-ai/slack", - "version": "1.4.2", + "version": "1.4.3", "dependencies": { "@opencode-ai/sdk": "workspace:*", "@slack/bolt": "^3.17.1", @@ -531,7 +531,7 @@ }, "packages/ui": { "name": "@opencode-ai/ui", - "version": "1.4.2", + "version": "1.4.3", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/sdk": "workspace:*", @@ -580,7 +580,7 @@ }, "packages/util": { "name": "@opencode-ai/util", - "version": "1.4.2", + "version": "1.4.3", "dependencies": { "zod": "catalog:", }, @@ -591,7 +591,7 @@ }, "packages/web": { "name": "@opencode-ai/web", - "version": "1.4.2", + "version": "1.4.3", "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 3e12c492b6ce..2ac271df232b 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/app", - "version": "1.4.2", + "version": "1.4.3", "description": "", "type": "module", "exports": { diff --git a/packages/console/app/package.json b/packages/console/app/package.json index dc3362e979a7..bcb02a907919 100644 --- a/packages/console/app/package.json +++ b/packages/console/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-app", - "version": "1.4.2", + "version": "1.4.3", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/console/core/package.json b/packages/console/core/package.json index 9059734ccd0a..e60da9d45346 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.4.2", + "version": "1.4.3", "private": true, "type": "module", "license": "MIT", diff --git a/packages/console/function/package.json b/packages/console/function/package.json index d8f0e0c0e229..d6ad86b8f7af 100644 --- a/packages/console/function/package.json +++ b/packages/console/function/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-function", - "version": "1.4.2", + "version": "1.4.3", "$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 0976bb3e21c3..898387d01b93 100644 --- a/packages/console/mail/package.json +++ b/packages/console/mail/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-mail", - "version": "1.4.2", + "version": "1.4.3", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", diff --git a/packages/desktop-electron/package.json b/packages/desktop-electron/package.json index 48c17d0df2f2..694382045d7d 100644 --- a/packages/desktop-electron/package.json +++ b/packages/desktop-electron/package.json @@ -1,7 +1,7 @@ { "name": "@opencode-ai/desktop-electron", "private": true, - "version": "1.4.2", + "version": "1.4.3", "type": "module", "license": "MIT", "homepage": "https://opencode.ai", diff --git a/packages/desktop/package.json b/packages/desktop/package.json index 943cbeb203bb..8815bf7bcef6 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@opencode-ai/desktop", "private": true, - "version": "1.4.2", + "version": "1.4.3", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/enterprise/package.json b/packages/enterprise/package.json index 10a909c8c653..db3da877b9a6 100644 --- a/packages/enterprise/package.json +++ b/packages/enterprise/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/enterprise", - "version": "1.4.2", + "version": "1.4.3", "private": true, "type": "module", "license": "MIT", diff --git a/packages/extensions/zed/extension.toml b/packages/extensions/zed/extension.toml index 95fc2b1c538c..c13d6c1eb086 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.4.2" +version = "1.4.3" 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.4.2/opencode-darwin-arm64.zip" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.3/opencode-darwin-arm64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.darwin-x86_64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.2/opencode-darwin-x64.zip" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.3/opencode-darwin-x64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.linux-aarch64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.2/opencode-linux-arm64.tar.gz" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.3/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.4.2/opencode-linux-x64.tar.gz" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.3/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.4.2/opencode-windows-x64.zip" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.3/opencode-windows-x64.zip" cmd = "./opencode.exe" args = ["acp"] diff --git a/packages/function/package.json b/packages/function/package.json index dd54e9c0922f..76262c25a577 100644 --- a/packages/function/package.json +++ b/packages/function/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/function", - "version": "1.4.2", + "version": "1.4.3", "$schema": "https://json.schemastore.org/package.json", "private": true, "type": "module", diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 934ef0869c9b..7cce13190860 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.4.2", + "version": "1.4.3", "name": "opencode", "type": "module", "license": "MIT", diff --git a/packages/plugin/package.json b/packages/plugin/package.json index adfee9c65c4c..ced1523dacd4 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.4.2", + "version": "1.4.3", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/sdk/js/package.json b/packages/sdk/js/package.json index 3dbab2c54ace..47e09dfab45d 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.4.2", + "version": "1.4.3", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/slack/package.json b/packages/slack/package.json index d23b740cdaa9..892c7172ed93 100644 --- a/packages/slack/package.json +++ b/packages/slack/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/slack", - "version": "1.4.2", + "version": "1.4.3", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/ui/package.json b/packages/ui/package.json index 01c6fae86982..12325ecc7d74 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/ui", - "version": "1.4.2", + "version": "1.4.3", "type": "module", "license": "MIT", "exports": { diff --git a/packages/util/package.json b/packages/util/package.json index 53c170f1448b..bee3d082ac34 100644 --- a/packages/util/package.json +++ b/packages/util/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/util", - "version": "1.4.2", + "version": "1.4.3", "private": true, "type": "module", "license": "MIT", diff --git a/packages/web/package.json b/packages/web/package.json index d7f627c57ab2..df003f79fdb3 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.4.2", + "version": "1.4.3", "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 1d0472d787ea..290834180d72 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.4.2", + "version": "1.4.3", "publisher": "sst-dev", "repository": { "type": "git", From 98874a09f76402e9bdc5df197faab24f4e477c2f Mon Sep 17 00:00:00 2001 From: Luke Parker <10430890+Hona@users.noreply.github.com> Date: Fri, 10 Apr 2026 10:00:46 +1000 Subject: [PATCH 22/29] fix windows e2e backend not stopping on sigterm waiting 10s for no reason (#21781) --- packages/app/e2e/backend.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/packages/app/e2e/backend.ts b/packages/app/e2e/backend.ts index 9febc4b3ff4d..a03d1d437504 100644 --- a/packages/app/e2e/backend.ts +++ b/packages/app/e2e/backend.ts @@ -44,8 +44,12 @@ async function waitForHealth(url: string, probe = "/global/health") { throw new Error(`Timed out waiting for backend health at ${url}${probe}${last ? ` (${last})` : ""}`) } +function done(proc: ReturnType) { + return proc.exitCode !== null || proc.signalCode !== null +} + async function waitExit(proc: ReturnType, timeout = 10_000) { - if (proc.exitCode !== null) return + if (done(proc)) return await Promise.race([ new Promise((resolve) => proc.once("exit", () => resolve())), new Promise((resolve) => setTimeout(resolve, timeout)), @@ -123,11 +127,11 @@ export async function startBackend(label: string, input?: { llmUrl?: string }): return { url, async stop() { - if (proc.exitCode === null) { + if (!done(proc)) { proc.kill("SIGTERM") await waitExit(proc) } - if (proc.exitCode === null) { + if (!done(proc)) { proc.kill("SIGKILL") await waitExit(proc) } From b16ee08fd5c867fa1402237d53bb43d62a61cff2 Mon Sep 17 00:00:00 2001 From: Luke Parker <10430890+Hona@users.noreply.github.com> Date: Fri, 10 Apr 2026 10:01:10 +1000 Subject: [PATCH 23/29] ci use node 24 in test workflow fixing random ECONNRESET (#21782) --- .github/workflows/test.yml | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 70a8477fb51f..510f682549ef 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -17,6 +17,9 @@ permissions: contents: read checks: write +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + jobs: unit: name: unit (${{ matrix.settings.name }}) @@ -38,6 +41,11 @@ jobs: with: token: ${{ secrets.GITHUB_TOKEN }} + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: "24" + - name: Setup Bun uses: ./.github/actions/setup-bun @@ -102,6 +110,11 @@ jobs: with: token: ${{ secrets.GITHUB_TOKEN }} + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: "24" + - name: Setup Bun uses: ./.github/actions/setup-bun From 04074d3f4a62e039e11ff73e73b1a2be6f651439 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Thu, 9 Apr 2026 21:34:52 -0400 Subject: [PATCH 24/29] core: enable prod channel to use shared production database Ensures users on the prod channel have their data persisted to the same database as latest and beta channels, preventing data fragmentation across different release channels. --- packages/opencode/src/storage/db.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/opencode/src/storage/db.ts b/packages/opencode/src/storage/db.ts index 4cb0dbc3e184..78320ac0af1d 100644 --- a/packages/opencode/src/storage/db.ts +++ b/packages/opencode/src/storage/db.ts @@ -29,7 +29,7 @@ const log = Log.create({ service: "db" }) export namespace Database { export function getChannelPath() { - if (["latest", "beta"].includes(CHANNEL) || Flag.OPENCODE_DISABLE_CHANNEL_DB) + if (["latest", "beta", "prod"].includes(CHANNEL) || Flag.OPENCODE_DISABLE_CHANNEL_DB) return path.join(Global.Path.data, "opencode.db") const safe = CHANNEL.replace(/[^a-zA-Z0-9._-]/g, "-") return path.join(Global.Path.data, `opencode-${safe}.db`) From 16c60c9ee782285530ce88f5f36ea5eb7898d1c2 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 9 Apr 2026 21:47:48 -0400 Subject: [PATCH 25/29] refactor(session): extract sharing orchestration (#21759) --- packages/opencode/src/cli/cmd/github.ts | 3 +- .../opencode/src/server/routes/session.ts | 11 +-- packages/opencode/src/session/index.ts | 40 +---------- packages/opencode/src/share/session.ts | 67 +++++++++++++++++++ packages/opencode/src/share/share-next.ts | 6 +- specs/v2/session.md | 17 +++++ 6 files changed, 100 insertions(+), 44 deletions(-) create mode 100644 packages/opencode/src/share/session.ts create mode 100644 specs/v2/session.md diff --git a/packages/opencode/src/cli/cmd/github.ts b/packages/opencode/src/cli/cmd/github.ts index e8f3e6a11e1c..8b693e79ae20 100644 --- a/packages/opencode/src/cli/cmd/github.ts +++ b/packages/opencode/src/cli/cmd/github.ts @@ -21,6 +21,7 @@ import { cmd } from "./cmd" import { ModelsDev } from "../../provider/models" import { Instance } from "@/project/instance" import { bootstrap } from "../bootstrap" +import { SessionShare } from "@/share/session" import { Session } from "../../session" import type { SessionID } from "../../session/schema" import { MessageID, PartID } from "../../session/schema" @@ -559,7 +560,7 @@ export const GithubRunCommand = cmd({ shareId = await (async () => { if (share === false) return if (!share && repoData.data.private) return - await Session.share(session.id) + await SessionShare.share(session.id) return session.id.slice(-8) })() console.log("opencode session", session.id) diff --git a/packages/opencode/src/server/routes/session.ts b/packages/opencode/src/server/routes/session.ts index b1a6af582728..83658987e3c7 100644 --- a/packages/opencode/src/server/routes/session.ts +++ b/packages/opencode/src/server/routes/session.ts @@ -9,6 +9,7 @@ import { SessionPrompt } from "../../session/prompt" import { SessionRunState } from "@/session/run-state" import { SessionCompaction } from "../../session/compaction" import { SessionRevert } from "../../session/revert" +import { SessionShare } from "@/share/session" import { SessionStatus } from "@/session/status" import { SessionSummary } from "@/session/summary" import { Todo } from "../../session/todo" @@ -206,10 +207,10 @@ export const SessionRoutes = lazy(() => }, }, }), - validator("json", Session.create.schema.optional()), + validator("json", Session.create.schema), async (c) => { const body = c.req.valid("json") ?? {} - const session = await Session.create(body) + const session = await SessionShare.create(body) return c.json(session) }, ) @@ -426,7 +427,7 @@ export const SessionRoutes = lazy(() => ), async (c) => { const sessionID = c.req.valid("param").sessionID - await Session.share(sessionID) + await SessionShare.share(sessionID) const session = await Session.get(sessionID) return c.json(session) }, @@ -491,12 +492,12 @@ export const SessionRoutes = lazy(() => validator( "param", z.object({ - sessionID: Session.unshare.schema, + sessionID: SessionID.zod, }), ), async (c) => { const sessionID = c.req.valid("param").sessionID - await Session.unshare(sessionID) + await SessionShare.unshare(sessionID) const session = await Session.get(sessionID) return c.json(session) }, diff --git a/packages/opencode/src/session/index.ts b/packages/opencode/src/session/index.ts index 2e68f22ede93..bbd6693c53ac 100644 --- a/packages/opencode/src/session/index.ts +++ b/packages/opencode/src/session/index.ts @@ -5,7 +5,6 @@ import { Bus } from "@/bus" import { Decimal } from "decimal.js" import z from "zod" import { type ProviderMetadata } from "ai" -import { Config } from "../config/config" import { Flag } from "../flag/flag" import { Installation } from "../installation" @@ -30,7 +29,7 @@ import type { Provider } from "@/provider/provider" import { Permission } from "@/permission" import { Global } from "@/global" import type { LanguageModelV2Usage } from "@ai-sdk/provider" -import { Effect, Layer, Scope, ServiceMap } from "effect" +import { Effect, Layer, ServiceMap } from "effect" import { makeRuntime } from "@/effect/run-service" export namespace Session { @@ -319,8 +318,6 @@ export namespace Session { readonly fork: (input: { sessionID: SessionID; messageID?: MessageID }) => Effect.Effect readonly touch: (sessionID: SessionID) => Effect.Effect readonly get: (id: SessionID) => Effect.Effect - readonly share: (id: SessionID) => Effect.Effect<{ url: string }> - readonly unshare: (id: SessionID) => Effect.Effect readonly setTitle: (input: { sessionID: SessionID; title: string }) => Effect.Effect readonly setArchived: (input: { sessionID: SessionID; time?: number }) => Effect.Effect readonly setPermission: (input: { sessionID: SessionID; permission: Permission.Ruleset }) => Effect.Effect @@ -364,12 +361,10 @@ export namespace Session { const db = (fn: (d: Parameters[0] extends (trx: infer D) => any ? D : never) => T) => Effect.sync(() => Database.use(fn)) - export const layer: Layer.Layer = Layer.effect( + export const layer: Layer.Layer = Layer.effect( Service, Effect.gen(function* () { const bus = yield* Bus.Service - const config = yield* Config.Service - const scope = yield* Scope.Scope const createNext = Effect.fn("Session.createNext")(function* (input: { id?: SessionID @@ -399,11 +394,6 @@ export namespace Session { yield* Effect.sync(() => SyncEvent.run(Event.Created, { sessionID: result.id, info: result })) - const cfg = yield* config.get() - if (!result.parentID && (Flag.OPENCODE_AUTO_SHARE || cfg.share === "auto")) { - yield* share(result.id).pipe(Effect.ignore, Effect.forkIn(scope)) - } - if (!Flag.OPENCODE_EXPERIMENTAL_WORKSPACES) { // This only exist for backwards compatibility. We should not be // manually publishing this event; it is a sync event now @@ -422,25 +412,6 @@ export namespace Session { return fromRow(row) }) - const share = Effect.fn("Session.share")(function* (id: SessionID) { - const cfg = yield* config.get() - if (cfg.share === "disabled") throw new Error("Sharing is disabled in configuration") - const result = yield* Effect.promise(async () => { - const { ShareNext } = await import("@/share/share-next") - return ShareNext.create(id) - }) - yield* Effect.sync(() => SyncEvent.run(Event.Updated, { sessionID: id, info: { share: { url: result.url } } })) - return result - }) - - const unshare = Effect.fn("Session.unshare")(function* (id: SessionID) { - yield* Effect.promise(async () => { - const { ShareNext } = await import("@/share/share-next") - await ShareNext.remove(id) - }) - yield* Effect.sync(() => SyncEvent.run(Event.Updated, { sessionID: id, info: { share: { url: null } } })) - }) - const children = Effect.fn("Session.children")(function* (parentID: SessionID) { const ctx = yield* InstanceState.context const rows = yield* db((d) => @@ -460,7 +431,6 @@ export namespace Session { for (const child of kids) { yield* remove(child.id) } - yield* unshare(sessionID).pipe(Effect.ignore) yield* Effect.sync(() => { SyncEvent.run(Event.Deleted, { sessionID, info: session }) SyncEvent.remove(sessionID) @@ -661,8 +631,6 @@ export namespace Session { fork, touch, get, - share, - unshare, setTitle, setArchived, setPermission, @@ -683,7 +651,7 @@ export namespace Session { }), ) - export const defaultLayer = layer.pipe(Layer.provide(Bus.layer), Layer.provide(Config.defaultLayer)) + export const defaultLayer = layer.pipe(Layer.provide(Bus.layer)) const { runPromise } = makeRuntime(Service, defaultLayer) @@ -704,8 +672,6 @@ export namespace Session { ) export const get = fn(SessionID.zod, (id) => runPromise((svc) => svc.get(id))) - export const share = fn(SessionID.zod, (id) => runPromise((svc) => svc.share(id))) - export const unshare = fn(SessionID.zod, (id) => runPromise((svc) => svc.unshare(id))) export const setTitle = fn(z.object({ sessionID: SessionID.zod, title: z.string() }), (input) => runPromise((svc) => svc.setTitle(input)), diff --git a/packages/opencode/src/share/session.ts b/packages/opencode/src/share/session.ts new file mode 100644 index 000000000000..1446b5bb4def --- /dev/null +++ b/packages/opencode/src/share/session.ts @@ -0,0 +1,67 @@ +import { makeRuntime } from "@/effect/run-service" +import { Session } from "@/session" +import { SessionID } from "@/session/schema" +import { SyncEvent } from "@/sync" +import { fn } from "@/util/fn" +import { Effect, Layer, Scope, ServiceMap } from "effect" +import { Config } from "../config/config" +import { Flag } from "../flag/flag" +import { ShareNext } from "./share-next" + +export namespace SessionShare { + export interface Interface { + readonly create: (input?: Parameters[0]) => Effect.Effect + readonly share: (sessionID: SessionID) => Effect.Effect<{ url: string }, unknown> + readonly unshare: (sessionID: SessionID) => Effect.Effect + } + + export class Service extends ServiceMap.Service()("@opencode/SessionShare") {} + + export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const cfg = yield* Config.Service + const session = yield* Session.Service + const shareNext = yield* ShareNext.Service + const scope = yield* Scope.Scope + + const share = Effect.fn("SessionShare.share")(function* (sessionID: SessionID) { + const conf = yield* cfg.get() + if (conf.share === "disabled") throw new Error("Sharing is disabled in configuration") + const result = yield* shareNext.create(sessionID) + yield* Effect.sync(() => + SyncEvent.run(Session.Event.Updated, { sessionID, info: { share: { url: result.url } } }), + ) + return result + }) + + const unshare = Effect.fn("SessionShare.unshare")(function* (sessionID: SessionID) { + yield* shareNext.remove(sessionID) + yield* Effect.sync(() => SyncEvent.run(Session.Event.Updated, { sessionID, info: { share: { url: null } } })) + }) + + const create = Effect.fn("SessionShare.create")(function* (input?: Parameters[0]) { + const result = yield* session.create(input) + if (result.parentID) return result + const conf = yield* cfg.get() + if (!(Flag.OPENCODE_AUTO_SHARE || conf.share === "auto")) return result + yield* share(result.id).pipe(Effect.ignore, Effect.forkIn(scope)) + return result + }) + + return Service.of({ create, share, unshare }) + }), + ) + + export const defaultLayer = layer.pipe( + Layer.provide(ShareNext.defaultLayer), + Layer.provide(Session.defaultLayer), + Layer.provide(Config.defaultLayer), + ) + + const { runPromise } = makeRuntime(Service, defaultLayer) + + export const create = fn(Session.create.schema, (input) => runPromise((svc) => svc.create(input))) + export const share = fn(SessionID.zod, (sessionID) => runPromise((svc) => svc.share(sessionID))) + export const unshare = fn(SessionID.zod, (sessionID) => runPromise((svc) => svc.unshare(sessionID))) +} diff --git a/packages/opencode/src/share/share-next.ts b/packages/opencode/src/share/share-next.ts index 0cd0055c85d2..26b2d2570a32 100644 --- a/packages/opencode/src/share/share-next.ts +++ b/packages/opencode/src/share/share-next.ts @@ -159,7 +159,10 @@ export namespace ShareNext { if (disabled) return cache - const watch = (def: D, fn: (evt: { properties: any }) => Effect.Effect) => + const watch = ( + def: D, + fn: (evt: { properties: any }) => Effect.Effect, + ) => bus.subscribe(def as never).pipe( Stream.runForEach((evt) => fn(evt).pipe( @@ -194,6 +197,7 @@ export namespace ShareNext { yield* watch(Session.Event.Diff, (evt) => sync(evt.properties.sessionID, [{ type: "session_diff", data: evt.properties.diff }]), ) + yield* watch(Session.Event.Deleted, (evt) => remove(evt.properties.sessionID)) return cache }), diff --git a/specs/v2/session.md b/specs/v2/session.md new file mode 100644 index 000000000000..cae90ba7c883 --- /dev/null +++ b/specs/v2/session.md @@ -0,0 +1,17 @@ +# Session API + +## Remove Dedicated `session.init` Route + +The dedicated `POST /session/:sessionID/init` endpoint exists only as a compatibility wrapper around the normal `/init` command flow. + +Current behavior: + +- the route calls `SessionPrompt.command(...)` +- it sends `Command.Default.INIT` +- it does not provide distinct session-core behavior beyond running the existing init command in an existing session + +V2 plan: + +- remove the dedicated `session.init` endpoint +- rely on the normal `/init` command flow instead +- avoid reintroducing `Session.initialize`-style special cases in the session service layer From 17bd16667c9706c1f210bb36e01a0d23bcdddb02 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 9 Apr 2026 22:20:27 -0400 Subject: [PATCH 26/29] refactor(effect): move tool descriptions into registry (#21795) --- packages/opencode/src/tool/registry.ts | 49 +++++++++++++++++-- packages/opencode/src/tool/skill.ts | 21 -------- packages/opencode/src/tool/task.ts | 16 ------ packages/opencode/src/worktree/index.ts | 6 ++- .../test/session/prompt-effect.test.ts | 2 + .../test/session/snapshot-tool-race.test.ts | 2 + packages/opencode/test/tool/skill.test.ts | 21 +++++--- packages/opencode/test/tool/task.test.ts | 24 +++++++-- 8 files changed, 87 insertions(+), 54 deletions(-) diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index 800c45ced0c0..dbb8fb28606b 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -5,12 +5,12 @@ import { EditTool } from "./edit" import { GlobTool } from "./glob" import { GrepTool } from "./grep" import { ReadTool } from "./read" -import { TaskDescription, TaskTool } from "./task" +import { TaskTool } from "./task" import { TodoWriteTool } from "./todo" import { WebFetchTool } from "./webfetch" import { WriteTool } from "./write" import { InvalidTool } from "./invalid" -import { SkillDescription, SkillTool } from "./skill" +import { SkillTool } from "./skill" import { Tool } from "./tool" import { Config } from "../config/config" import { type ToolContext as PluginToolContext, type ToolDefinition } from "@opencode-ai/plugin" @@ -38,6 +38,8 @@ import { FileTime } from "../file/time" import { Instruction } from "../session/instruction" import { AppFileSystem } from "../filesystem" import { Agent } from "../agent/agent" +import { Skill } from "../skill" +import { Permission } from "@/permission" export namespace ToolRegistry { const log = Log.create({ service: "tool.registry" }) @@ -73,6 +75,7 @@ export namespace ToolRegistry { | Question.Service | Todo.Service | Agent.Service + | Skill.Service | LSP.Service | FileTime.Service | Instruction.Service @@ -82,6 +85,8 @@ export namespace ToolRegistry { Effect.gen(function* () { const config = yield* Config.Service const plugin = yield* Plugin.Service + const agents = yield* Agent.Service + const skill = yield* Skill.Service const task = yield* TaskTool const read = yield* ReadTool @@ -199,6 +204,40 @@ export namespace ToolRegistry { return (yield* all()).map((tool) => tool.id) }) + const describeSkill = Effect.fn("ToolRegistry.describeSkill")(function* (agent: Agent.Info) { + const list = yield* skill.available(agent) + if (list.length === 0) return "No skills are currently available." + return [ + "Load a specialized skill that provides domain-specific instructions and workflows.", + "", + "When you recognize that a task matches one of the available skills listed below, use this tool to load the full skill instructions.", + "", + "The skill will inject detailed instructions, workflows, and access to bundled resources (scripts, references, templates) into the conversation context.", + "", + 'Tool output includes a `` block with the loaded content.', + "", + "The following skills provide specialized sets of instructions for particular tasks", + "Invoke this tool to load a skill when a task matches one of the available skills listed below:", + "", + Skill.fmt(list, { verbose: false }), + ].join("\n") + }) + + const describeTask = Effect.fn("ToolRegistry.describeTask")(function* (agent: Agent.Info) { + const items = (yield* agents.list()).filter((item) => item.mode !== "primary") + const filtered = items.filter( + (item) => Permission.evaluate("task", item.name, agent.permission).action !== "deny", + ) + const list = filtered.toSorted((a, b) => a.name.localeCompare(b.name)) + const description = list + .map( + (item) => + `- ${item.name}: ${item.description ?? "This subagent should only be called manually by the user."}`, + ) + .join("\n") + return ["Available agent types and the tools they have access to:", description].join("\n") + }) + const tools: Interface["tools"] = Effect.fn("ToolRegistry.tools")(function* (input) { const filtered = (yield* all()).filter((tool) => { if (tool.id === CodeSearchTool.id || tool.id === WebSearchTool.id) { @@ -227,8 +266,8 @@ export namespace ToolRegistry { id: tool.id, description: [ output.description, - tool.id === TaskTool.id ? yield* TaskDescription(input.agent) : undefined, - tool.id === SkillTool.id ? yield* SkillDescription(input.agent) : undefined, + tool.id === TaskTool.id ? yield* describeTask(input.agent) : undefined, + tool.id === SkillTool.id ? yield* describeSkill(input.agent) : undefined, ] .filter(Boolean) .join("\n"), @@ -257,7 +296,9 @@ export namespace ToolRegistry { Layer.provide(Plugin.defaultLayer), Layer.provide(Question.defaultLayer), Layer.provide(Todo.defaultLayer), + Layer.provide(Skill.defaultLayer), Layer.provide(Agent.defaultLayer), + Layer.provide(Skill.defaultLayer), Layer.provide(LSP.defaultLayer), Layer.provide(FileTime.defaultLayer), Layer.provide(Instruction.defaultLayer), diff --git a/packages/opencode/src/tool/skill.ts b/packages/opencode/src/tool/skill.ts index 276f3931d012..e0777d00f74a 100644 --- a/packages/opencode/src/tool/skill.ts +++ b/packages/opencode/src/tool/skill.ts @@ -1,4 +1,3 @@ -import { Effect } from "effect" import path from "path" import { pathToFileURL } from "url" import z from "zod" @@ -98,23 +97,3 @@ export const SkillTool = Tool.define("skill", async () => { }, } }) - -export const SkillDescription: Tool.DynamicDescription = (agent) => - Effect.gen(function* () { - const list = yield* Effect.promise(() => Skill.available(agent)) - if (list.length === 0) return "No skills are currently available." - return [ - "Load a specialized skill that provides domain-specific instructions and workflows.", - "", - "When you recognize that a task matches one of the available skills listed below, use this tool to load the full skill instructions.", - "", - "The skill will inject detailed instructions, workflows, and access to bundled resources (scripts, references, templates) into the conversation context.", - "", - 'Tool output includes a `` block with the loaded content.', - "", - "The following skills provide specialized sets of instructions for particular tasks", - "Invoke this tool to load a skill when a task matches one of the available skills listed below:", - "", - Skill.fmt(list, { verbose: false }), - ].join("\n") - }) diff --git a/packages/opencode/src/tool/task.ts b/packages/opencode/src/tool/task.ts index b97b53bb9f68..900938f0d3a4 100644 --- a/packages/opencode/src/tool/task.ts +++ b/packages/opencode/src/tool/task.ts @@ -7,7 +7,6 @@ import { MessageV2 } from "../session/message-v2" import { Agent } from "../agent/agent" import { SessionPrompt } from "../session/prompt" import { Config } from "../config/config" -import { Permission } from "@/permission" import { Effect } from "effect" import { Log } from "@/util/log" @@ -176,18 +175,3 @@ export const TaskTool = Tool.defineEffect( } }), ) - -export const TaskDescription: Tool.DynamicDescription = (agent) => - Effect.gen(function* () { - const items = yield* Effect.promise(() => - Agent.list().then((items) => items.filter((item) => item.mode !== "primary")), - ) - const filtered = items.filter((item) => Permission.evaluate(id, item.name, agent.permission).action !== "deny") - const list = filtered.toSorted((a, b) => a.name.localeCompare(b.name)) - const description = list - .map( - (item) => `- ${item.name}: ${item.description ?? "This subagent should only be called manually by the user."}`, - ) - .join("\n") - return ["Available agent types and the tools they have access to:", description].join("\n") - }) diff --git a/packages/opencode/src/worktree/index.ts b/packages/opencode/src/worktree/index.ts index b34364ccd871..54986d65cd57 100644 --- a/packages/opencode/src/worktree/index.ts +++ b/packages/opencode/src/worktree/index.ts @@ -171,7 +171,7 @@ export namespace Worktree { export const layer: Layer.Layer< Service, never, - AppFileSystem.Service | Path.Path | ChildProcessSpawner.ChildProcessSpawner | Project.Service + AppFileSystem.Service | Path.Path | ChildProcessSpawner.ChildProcessSpawner | Git.Service | Project.Service > = Layer.effect( Service, Effect.gen(function* () { @@ -179,6 +179,7 @@ export namespace Worktree { const fs = yield* AppFileSystem.Service const pathSvc = yield* Path.Path const spawner = yield* ChildProcessSpawner.ChildProcessSpawner + const gitSvc = yield* Git.Service const project = yield* Project.Service const git = Effect.fnUntraced( @@ -516,7 +517,7 @@ export namespace Worktree { const worktreePath = entry.path - const base = yield* Effect.promise(() => Git.defaultBranch(Instance.worktree)) + const base = yield* gitSvc.defaultBranch(Instance.worktree) if (!base) { throw new ResetFailedError({ message: "Default branch not found" }) } @@ -583,6 +584,7 @@ export namespace Worktree { ) const defaultLayer = layer.pipe( + Layer.provide(Git.defaultLayer), Layer.provide(CrossSpawnSpawner.defaultLayer), Layer.provide(Project.defaultLayer), Layer.provide(AppFileSystem.defaultLayer), diff --git a/packages/opencode/test/session/prompt-effect.test.ts b/packages/opencode/test/session/prompt-effect.test.ts index 81288f0ca14b..e9893760c9ec 100644 --- a/packages/opencode/test/session/prompt-effect.test.ts +++ b/packages/opencode/test/session/prompt-effect.test.ts @@ -28,6 +28,7 @@ import { SessionPrompt } from "../../src/session/prompt" import { SessionRunState } from "../../src/session/run-state" import { MessageID, PartID, SessionID } from "../../src/session/schema" import { SessionStatus } from "../../src/session/status" +import { Skill } from "../../src/skill" import { Shell } from "../../src/shell/shell" import { Snapshot } from "../../src/snapshot" import { ToolRegistry } from "../../src/tool/registry" @@ -166,6 +167,7 @@ function makeHttp() { const question = Question.layer.pipe(Layer.provideMerge(deps)) const todo = Todo.layer.pipe(Layer.provideMerge(deps)) const registry = ToolRegistry.layer.pipe( + Layer.provide(Skill.defaultLayer), Layer.provideMerge(todo), Layer.provideMerge(question), 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 9cc4d750c2c1..75ba8ef16ce2 100644 --- a/packages/opencode/test/session/snapshot-tool-race.test.ts +++ b/packages/opencode/test/session/snapshot-tool-race.test.ts @@ -39,6 +39,7 @@ import { Permission } from "../../src/permission" import { Plugin } from "../../src/plugin" import { Provider as ProviderSvc } from "../../src/provider/provider" import { Question } from "../../src/question" +import { Skill } from "../../src/skill" import { Todo } from "../../src/session/todo" import { SessionCompaction } from "../../src/session/compaction" import { Instruction } from "../../src/session/instruction" @@ -131,6 +132,7 @@ function makeHttp() { const question = Question.layer.pipe(Layer.provideMerge(deps)) const todo = Todo.layer.pipe(Layer.provideMerge(deps)) const registry = ToolRegistry.layer.pipe( + Layer.provide(Skill.defaultLayer), Layer.provideMerge(todo), Layer.provideMerge(question), Layer.provideMerge(deps), diff --git a/packages/opencode/test/tool/skill.test.ts b/packages/opencode/test/tool/skill.test.ts index e6269a4f389f..ea9aeeaf9efe 100644 --- a/packages/opencode/test/tool/skill.test.ts +++ b/packages/opencode/test/tool/skill.test.ts @@ -5,7 +5,8 @@ import { pathToFileURL } from "url" import type { Permission } from "../../src/permission" import type { Tool } from "../../src/tool/tool" import { Instance } from "../../src/project/instance" -import { SkillTool, SkillDescription } from "../../src/tool/skill" +import { SkillTool } from "../../src/tool/skill" +import { ToolRegistry } from "../../src/tool/registry" import { tmpdir } from "../fixture/fixture" import { SessionID, MessageID } from "../../src/session/schema" @@ -49,9 +50,11 @@ description: Skill for tool tests. await Instance.provide({ directory: tmp.path, fn: async () => { - const desc = await Effect.runPromise( - SkillDescription({ name: "build", mode: "primary" as const, permission: [], options: {} }), - ) + const desc = await ToolRegistry.tools({ + providerID: "opencode" as any, + modelID: "gpt-5" as any, + agent: { name: "build", mode: "primary" as const, permission: [], options: {} }, + }).then((tools) => tools.find((tool) => tool.id === SkillTool.id)?.description ?? "") expect(desc).toContain(`**tool-skill**: Skill for tool tests.`) }, }) @@ -92,8 +95,14 @@ description: ${description} directory: tmp.path, fn: async () => { const agent = { name: "build", mode: "primary" as const, permission: [], options: {} } - const first = await Effect.runPromise(SkillDescription(agent)) - const second = await Effect.runPromise(SkillDescription(agent)) + const load = () => + ToolRegistry.tools({ + providerID: "opencode" as any, + modelID: "gpt-5" as any, + agent, + }).then((tools) => tools.find((tool) => tool.id === SkillTool.id)?.description ?? "") + const first = await load() + const second = await load() expect(first).toBe(second) diff --git a/packages/opencode/test/tool/task.test.ts b/packages/opencode/test/tool/task.test.ts index 8ebfa59d2313..e3e6d58d3c1d 100644 --- a/packages/opencode/test/tool/task.test.ts +++ b/packages/opencode/test/tool/task.test.ts @@ -9,7 +9,8 @@ import { MessageV2 } from "../../src/session/message-v2" import { SessionPrompt } from "../../src/session/prompt" import { MessageID, PartID } from "../../src/session/schema" import { ModelID, ProviderID } from "../../src/provider/schema" -import { TaskDescription, TaskTool } from "../../src/tool/task" +import { TaskTool } from "../../src/tool/task" +import { ToolRegistry } from "../../src/tool/registry" import { provideTmpdirInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" @@ -23,7 +24,13 @@ const ref = { } const it = testEffect( - Layer.mergeAll(Agent.defaultLayer, Config.defaultLayer, CrossSpawnSpawner.defaultLayer, Session.defaultLayer), + Layer.mergeAll( + Agent.defaultLayer, + Config.defaultLayer, + CrossSpawnSpawner.defaultLayer, + Session.defaultLayer, + ToolRegistry.defaultLayer, + ), ) const seed = Effect.fn("TaskToolTest.seed")(function* (title = "Pinned") { @@ -92,8 +99,13 @@ describe("tool.task", () => { Effect.gen(function* () { const agent = yield* Agent.Service const build = yield* agent.get("build") - const first = yield* TaskDescription(build) - const second = yield* TaskDescription(build) + const registry = yield* ToolRegistry.Service + const get = Effect.fnUntraced(function* () { + const tools = yield* registry.tools({ ...ref, agent: build }) + return tools.find((tool) => tool.id === TaskTool.id)?.description ?? "" + }) + const first = yield* get() + const second = yield* get() expect(first).toBe(second) @@ -130,7 +142,9 @@ describe("tool.task", () => { Effect.gen(function* () { const agent = yield* Agent.Service const build = yield* agent.get("build") - const description = yield* TaskDescription(build) + const registry = yield* ToolRegistry.Service + const description = + (yield* registry.tools({ ...ref, agent: build })).find((tool) => tool.id === TaskTool.id)?.description ?? "" expect(description).toContain("- alpha: Alpha agent") expect(description).not.toContain("- zebra: Zebra agent") From eca11ca71ab34d5818a18754981c97bc03b62bc1 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 9 Apr 2026 22:28:11 -0400 Subject: [PATCH 27/29] refactor(effect): use SessionRevert service in prompt (#21796) --- packages/opencode/src/session/compaction.ts | 20 ++++---- packages/opencode/src/session/processor.ts | 24 ++++----- packages/opencode/src/session/prompt.ts | 50 +++++++++---------- packages/opencode/src/session/revert.ts | 19 +++---- packages/opencode/src/session/summary.ts | 14 +++--- packages/opencode/src/tool/registry.ts | 27 +++++----- .../test/session/prompt-effect.test.ts | 2 + .../test/session/snapshot-tool-race.test.ts | 2 + 8 files changed, 75 insertions(+), 83 deletions(-) diff --git a/packages/opencode/src/session/compaction.ts b/packages/opencode/src/session/compaction.ts index 975327198906..937aa7132530 100644 --- a/packages/opencode/src/session/compaction.ts +++ b/packages/opencode/src/session/compaction.ts @@ -377,17 +377,15 @@ When constructing the summary, try to stick to this template: }), ) - export const defaultLayer = Layer.unwrap( - Effect.sync(() => - layer.pipe( - Layer.provide(Provider.defaultLayer), - Layer.provide(Session.defaultLayer), - Layer.provide(SessionProcessor.defaultLayer), - Layer.provide(Agent.defaultLayer), - Layer.provide(Plugin.defaultLayer), - Layer.provide(Bus.layer), - Layer.provide(Config.defaultLayer), - ), + export const defaultLayer = Layer.suspend(() => + layer.pipe( + Layer.provide(Provider.defaultLayer), + Layer.provide(Session.defaultLayer), + Layer.provide(SessionProcessor.defaultLayer), + Layer.provide(Agent.defaultLayer), + Layer.provide(Plugin.defaultLayer), + Layer.provide(Bus.layer), + Layer.provide(Config.defaultLayer), ), ) diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index 2e4d34bfcaf2..99389de1e2f8 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -594,19 +594,17 @@ export namespace SessionProcessor { }), ) - export const defaultLayer = Layer.unwrap( - Effect.sync(() => - layer.pipe( - Layer.provide(Session.defaultLayer), - Layer.provide(Snapshot.defaultLayer), - Layer.provide(Agent.defaultLayer), - Layer.provide(LLM.defaultLayer), - Layer.provide(Permission.defaultLayer), - Layer.provide(Plugin.defaultLayer), - Layer.provide(SessionStatus.layer.pipe(Layer.provide(Bus.layer))), - Layer.provide(Bus.layer), - Layer.provide(Config.defaultLayer), - ), + export const defaultLayer = Layer.suspend(() => + layer.pipe( + Layer.provide(Session.defaultLayer), + Layer.provide(Snapshot.defaultLayer), + Layer.provide(Agent.defaultLayer), + Layer.provide(LLM.defaultLayer), + Layer.provide(Permission.defaultLayer), + Layer.provide(Plugin.defaultLayer), + Layer.provide(SessionStatus.defaultLayer), + Layer.provide(Bus.layer), + Layer.provide(Config.defaultLayer), ), ) } diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 7f0a014ab249..33be6b9c58f9 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -99,6 +99,7 @@ export namespace SessionPrompt { const scope = yield* Scope.Scope const instruction = yield* Instruction.Service const state = yield* SessionRunState.Service + const revert = yield* SessionRevert.Service const cancel = Effect.fn("SessionPrompt.cancel")(function* (sessionID: SessionID) { log.info("cancel", { sessionID }) @@ -708,7 +709,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the const ctx = yield* InstanceState.context const session = yield* sessions.get(input.sessionID) if (session.revert) { - yield* Effect.promise(() => SessionRevert.cleanup(session)) + yield* revert.cleanup(session) } const agent = yield* agents.get(input.agent) if (!agent) { @@ -1269,7 +1270,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the const prompt: (input: PromptInput) => Effect.Effect = Effect.fn("SessionPrompt.prompt")( function* (input: PromptInput) { const session = yield* sessions.get(input.sessionID) - yield* Effect.promise(() => SessionRevert.cleanup(session)) + yield* revert.cleanup(session) const message = yield* createUserMessage(input) yield* sessions.touch(input.sessionID) @@ -1665,29 +1666,28 @@ NOTE: At any point in time through this workflow you should feel free to ask the }), ) - const defaultLayer = Layer.unwrap( - Effect.sync(() => - layer.pipe( - Layer.provide(SessionRunState.layer), - Layer.provide(SessionStatus.layer), - Layer.provide(SessionCompaction.defaultLayer), - Layer.provide(SessionProcessor.defaultLayer), - Layer.provide(Command.defaultLayer), - Layer.provide(Permission.defaultLayer), - Layer.provide(MCP.defaultLayer), - Layer.provide(LSP.defaultLayer), - Layer.provide(FileTime.defaultLayer), - Layer.provide(ToolRegistry.defaultLayer), - Layer.provide(Truncate.layer), - Layer.provide(Provider.defaultLayer), - Layer.provide(Instruction.defaultLayer), - Layer.provide(AppFileSystem.defaultLayer), - Layer.provide(Plugin.defaultLayer), - Layer.provide(Session.defaultLayer), - Layer.provide(Agent.defaultLayer), - Layer.provide(Bus.layer), - Layer.provide(CrossSpawnSpawner.defaultLayer), - ), + const defaultLayer = Layer.suspend(() => + layer.pipe( + Layer.provide(SessionRunState.defaultLayer), + Layer.provide(SessionStatus.defaultLayer), + Layer.provide(SessionCompaction.defaultLayer), + Layer.provide(SessionProcessor.defaultLayer), + Layer.provide(Command.defaultLayer), + Layer.provide(Permission.defaultLayer), + Layer.provide(MCP.defaultLayer), + Layer.provide(LSP.defaultLayer), + Layer.provide(FileTime.defaultLayer), + Layer.provide(ToolRegistry.defaultLayer), + Layer.provide(Truncate.defaultLayer), + Layer.provide(Provider.defaultLayer), + Layer.provide(Instruction.defaultLayer), + Layer.provide(AppFileSystem.defaultLayer), + Layer.provide(Plugin.defaultLayer), + Layer.provide(Session.defaultLayer), + Layer.provide(SessionRevert.defaultLayer), + Layer.provide(Agent.defaultLayer), + Layer.provide(Bus.layer), + Layer.provide(CrossSpawnSpawner.defaultLayer), ), ) const { runPromise } = makeRuntime(Service, defaultLayer) diff --git a/packages/opencode/src/session/revert.ts b/packages/opencode/src/session/revert.ts index a2d517f77c0a..1216362ca104 100644 --- a/packages/opencode/src/session/revert.ts +++ b/packages/opencode/src/session/revert.ts @@ -150,17 +150,14 @@ export namespace SessionRevert { }), ) - export const defaultLayer = Layer.unwrap( - Effect.sync(() => - layer.pipe( - Layer.provide(SessionRunState.layer), - Layer.provide(SessionStatus.layer), - Layer.provide(Session.defaultLayer), - Layer.provide(Snapshot.defaultLayer), - Layer.provide(Storage.defaultLayer), - Layer.provide(Bus.layer), - Layer.provide(SessionSummary.defaultLayer), - ), + export const defaultLayer = Layer.suspend(() => + layer.pipe( + Layer.provide(SessionRunState.defaultLayer), + Layer.provide(Session.defaultLayer), + Layer.provide(Snapshot.defaultLayer), + Layer.provide(Storage.defaultLayer), + Layer.provide(Bus.layer), + Layer.provide(SessionSummary.defaultLayer), ), ) diff --git a/packages/opencode/src/session/summary.ts b/packages/opencode/src/session/summary.ts index f2b53f3baf58..2f07a0f5d01f 100644 --- a/packages/opencode/src/session/summary.ts +++ b/packages/opencode/src/session/summary.ts @@ -150,14 +150,12 @@ export namespace SessionSummary { }), ) - export const defaultLayer = Layer.unwrap( - Effect.sync(() => - layer.pipe( - Layer.provide(Session.defaultLayer), - Layer.provide(Snapshot.defaultLayer), - Layer.provide(Storage.defaultLayer), - Layer.provide(Bus.layer), - ), + export const defaultLayer = Layer.suspend(() => + layer.pipe( + Layer.provide(Session.defaultLayer), + Layer.provide(Snapshot.defaultLayer), + Layer.provide(Storage.defaultLayer), + Layer.provide(Bus.layer), ), ) diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index dbb8fb28606b..9c0771b8df78 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -289,21 +289,18 @@ export namespace ToolRegistry { }), ) - export const defaultLayer = Layer.unwrap( - Effect.sync(() => - layer.pipe( - Layer.provide(Config.defaultLayer), - Layer.provide(Plugin.defaultLayer), - Layer.provide(Question.defaultLayer), - Layer.provide(Todo.defaultLayer), - Layer.provide(Skill.defaultLayer), - Layer.provide(Agent.defaultLayer), - Layer.provide(Skill.defaultLayer), - Layer.provide(LSP.defaultLayer), - Layer.provide(FileTime.defaultLayer), - Layer.provide(Instruction.defaultLayer), - Layer.provide(AppFileSystem.defaultLayer), - ), + export const defaultLayer = Layer.suspend(() => + layer.pipe( + Layer.provide(Config.defaultLayer), + Layer.provide(Plugin.defaultLayer), + Layer.provide(Question.defaultLayer), + Layer.provide(Todo.defaultLayer), + Layer.provide(Skill.defaultLayer), + Layer.provide(Agent.defaultLayer), + Layer.provide(LSP.defaultLayer), + Layer.provide(FileTime.defaultLayer), + Layer.provide(Instruction.defaultLayer), + Layer.provide(AppFileSystem.defaultLayer), ), ) diff --git a/packages/opencode/test/session/prompt-effect.test.ts b/packages/opencode/test/session/prompt-effect.test.ts index e9893760c9ec..215f6668cfb4 100644 --- a/packages/opencode/test/session/prompt-effect.test.ts +++ b/packages/opencode/test/session/prompt-effect.test.ts @@ -25,6 +25,7 @@ import { SessionCompaction } from "../../src/session/compaction" import { Instruction } from "../../src/session/instruction" import { SessionProcessor } from "../../src/session/processor" import { SessionPrompt } from "../../src/session/prompt" +import { SessionRevert } from "../../src/session/revert" import { SessionRunState } from "../../src/session/run-state" import { MessageID, PartID, SessionID } from "../../src/session/schema" import { SessionStatus } from "../../src/session/status" @@ -178,6 +179,7 @@ function makeHttp() { return Layer.mergeAll( TestLLMServer.layer, SessionPrompt.layer.pipe( + Layer.provide(SessionRevert.defaultLayer), Layer.provideMerge(run), Layer.provideMerge(compact), Layer.provideMerge(proc), diff --git a/packages/opencode/test/session/snapshot-tool-race.test.ts b/packages/opencode/test/session/snapshot-tool-race.test.ts index 75ba8ef16ce2..ae67983bf69f 100644 --- a/packages/opencode/test/session/snapshot-tool-race.test.ts +++ b/packages/opencode/test/session/snapshot-tool-race.test.ts @@ -18,6 +18,7 @@ import path from "path" import { Session } from "../../src/session" import { LLM } from "../../src/session/llm" import { SessionPrompt } from "../../src/session/prompt" +import { SessionRevert } from "../../src/session/revert" import { SessionSummary } from "../../src/session/summary" import { MessageV2 } from "../../src/session/message-v2" import { Log } from "../../src/util/log" @@ -143,6 +144,7 @@ function makeHttp() { return Layer.mergeAll( TestLLMServer.layer, SessionPrompt.layer.pipe( + Layer.provide(SessionRevert.defaultLayer), Layer.provideMerge(run), Layer.provideMerge(compact), Layer.provideMerge(proc), From 91786d2fc18c09a4b08846396e4d7f21b03e0c5c Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 9 Apr 2026 22:49:36 -0400 Subject: [PATCH 28/29] refactor(effect): use Git service in file and storage (#21803) --- packages/opencode/src/file/index.ts | 197 ++++++++---------- packages/opencode/src/file/watcher.ts | 11 +- packages/opencode/src/storage/storage.ts | 21 +- packages/opencode/test/file/watcher.test.ts | 2 + .../opencode/test/storage/storage.test.ts | 3 +- 5 files changed, 111 insertions(+), 123 deletions(-) diff --git a/packages/opencode/src/file/index.ts b/packages/opencode/src/file/index.ts index cdcf80a99e50..47d15fbb0019 100644 --- a/packages/opencode/src/file/index.ts +++ b/packages/opencode/src/file/index.ts @@ -11,7 +11,6 @@ import path from "path" import z from "zod" import { Global } from "../global" import { Instance } from "../project/instance" -import { Filesystem } from "../util/filesystem" import { Log } from "../util/log" import { Protected } from "./protected" import { Ripgrep } from "./ripgrep" @@ -344,6 +343,7 @@ export namespace File { Service, Effect.gen(function* () { const appFs = yield* AppFileSystem.Service + const git = yield* Git.Service const state = yield* InstanceState.make( Effect.fn("File.state")(() => @@ -410,6 +410,10 @@ export namespace File { cachedScan = yield* Effect.cached(scan().pipe(Effect.catchCause(() => Effect.void))) }) + const gitText = Effect.fnUntraced(function* (args: string[]) { + return (yield* git.run(args, { cwd: Instance.directory })).text() + }) + const init = Effect.fn("File.init")(function* () { yield* ensure() }) @@ -417,100 +421,87 @@ export namespace File { const status = Effect.fn("File.status")(function* () { if (Instance.project.vcs !== "git") return [] - return yield* Effect.promise(async () => { - const diffOutput = ( - await Git.run(["-c", "core.fsmonitor=false", "-c", "core.quotepath=false", "diff", "--numstat", "HEAD"], { - cwd: Instance.directory, + const diffOutput = yield* gitText([ + "-c", + "core.fsmonitor=false", + "-c", + "core.quotepath=false", + "diff", + "--numstat", + "HEAD", + ]) + + const changed: File.Info[] = [] + + if (diffOutput.trim()) { + for (const line of diffOutput.trim().split("\n")) { + const [added, removed, file] = line.split("\t") + changed.push({ + path: file, + added: added === "-" ? 0 : parseInt(added, 10), + removed: removed === "-" ? 0 : parseInt(removed, 10), + status: "modified", }) - ).text() - - const changed: File.Info[] = [] - - if (diffOutput.trim()) { - for (const line of diffOutput.trim().split("\n")) { - const [added, removed, file] = line.split("\t") - changed.push({ - path: file, - added: added === "-" ? 0 : parseInt(added, 10), - removed: removed === "-" ? 0 : parseInt(removed, 10), - status: "modified", - }) - } } + } - const untrackedOutput = ( - await Git.run( - [ - "-c", - "core.fsmonitor=false", - "-c", - "core.quotepath=false", - "ls-files", - "--others", - "--exclude-standard", - ], - { - cwd: Instance.directory, - }, - ) - ).text() - - if (untrackedOutput.trim()) { - for (const file of untrackedOutput.trim().split("\n")) { - try { - const content = await Filesystem.readText(path.join(Instance.directory, file)) - changed.push({ - path: file, - added: content.split("\n").length, - removed: 0, - status: "added", - }) - } catch { - continue - } - } + const untrackedOutput = yield* gitText([ + "-c", + "core.fsmonitor=false", + "-c", + "core.quotepath=false", + "ls-files", + "--others", + "--exclude-standard", + ]) + + if (untrackedOutput.trim()) { + for (const file of untrackedOutput.trim().split("\n")) { + const content = yield* appFs + .readFileString(path.join(Instance.directory, file)) + .pipe(Effect.catch(() => Effect.succeed(undefined))) + if (content === undefined) continue + changed.push({ + path: file, + added: content.split("\n").length, + removed: 0, + status: "added", + }) } + } - const deletedOutput = ( - await Git.run( - [ - "-c", - "core.fsmonitor=false", - "-c", - "core.quotepath=false", - "diff", - "--name-only", - "--diff-filter=D", - "HEAD", - ], - { - cwd: Instance.directory, - }, - ) - ).text() - - if (deletedOutput.trim()) { - for (const file of deletedOutput.trim().split("\n")) { - changed.push({ - path: file, - added: 0, - removed: 0, - status: "deleted", - }) - } + const deletedOutput = yield* gitText([ + "-c", + "core.fsmonitor=false", + "-c", + "core.quotepath=false", + "diff", + "--name-only", + "--diff-filter=D", + "HEAD", + ]) + + if (deletedOutput.trim()) { + for (const file of deletedOutput.trim().split("\n")) { + changed.push({ + path: file, + added: 0, + removed: 0, + status: "deleted", + }) } + } - return changed.map((item) => { - const full = path.isAbsolute(item.path) ? item.path : path.join(Instance.directory, item.path) - return { - ...item, - path: path.relative(Instance.directory, full), - } - }) + return changed.map((item) => { + const full = path.isAbsolute(item.path) ? item.path : path.join(Instance.directory, item.path) + return { + ...item, + path: path.relative(Instance.directory, full), + } }) }) - const read = Effect.fn("File.read")(function* (file: string) { + const read: Interface["read"] = Effect.fn("File.read")(function* (file: string) { using _ = log.time("read", { file }) const full = path.join(Instance.directory, file) @@ -558,27 +549,19 @@ export namespace File { ) if (Instance.project.vcs === "git") { - return yield* Effect.promise(async (): Promise => { - let diff = ( - await Git.run(["-c", "core.fsmonitor=false", "diff", "--", file], { cwd: Instance.directory }) - ).text() - if (!diff.trim()) { - diff = ( - await Git.run(["-c", "core.fsmonitor=false", "diff", "--staged", "--", file], { - cwd: Instance.directory, - }) - ).text() - } - if (diff.trim()) { - const original = (await Git.run(["show", `HEAD:${file}`], { cwd: Instance.directory })).text() - const patch = structuredPatch(file, file, original, content, "old", "new", { - context: Infinity, - ignoreWhitespace: true, - }) - return { type: "text", content, patch, diff: formatPatch(patch) } - } - return { type: "text", content } - }) + let diff = yield* gitText(["-c", "core.fsmonitor=false", "diff", "--", file]) + if (!diff.trim()) { + diff = yield* gitText(["-c", "core.fsmonitor=false", "diff", "--staged", "--", file]) + } + if (diff.trim()) { + const original = yield* git.show(Instance.directory, "HEAD", file) + const patch = structuredPatch(file, file, original, content, "old", "new", { + context: Infinity, + ignoreWhitespace: true, + }) + return { type: "text" as const, content, patch, diff: formatPatch(patch) } + } + return { type: "text" as const, content } } return { type: "text" as const, content } @@ -660,7 +643,7 @@ export namespace File { }), ) - export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer)) + export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer), Layer.provide(Git.defaultLayer)) const { runPromise } = makeRuntime(Service, defaultLayer) diff --git a/packages/opencode/src/file/watcher.ts b/packages/opencode/src/file/watcher.ts index b78b3a33a086..dd8b5798ca92 100644 --- a/packages/opencode/src/file/watcher.ts +++ b/packages/opencode/src/file/watcher.ts @@ -71,6 +71,7 @@ export namespace FileWatcher { Service, Effect.gen(function* () { const config = yield* Config.Service + const git = yield* Git.Service const state = yield* InstanceState.make( Effect.fn("FileWatcher.state")( @@ -131,11 +132,9 @@ export namespace FileWatcher { } if (Instance.project.vcs === "git") { - const result = yield* Effect.promise(() => - Git.run(["rev-parse", "--git-dir"], { - cwd: Instance.project.worktree, - }), - ) + const result = yield* git.run(["rev-parse", "--git-dir"], { + cwd: Instance.project.worktree, + }) const vcsDir = result.exitCode === 0 ? path.resolve(Instance.project.worktree, result.text().trim()) : undefined if (vcsDir && !cfgIgnores.includes(".git") && !cfgIgnores.includes(vcsDir)) { @@ -161,7 +160,7 @@ export namespace FileWatcher { }), ) - export const defaultLayer = layer.pipe(Layer.provide(Config.defaultLayer)) + export const defaultLayer = layer.pipe(Layer.provide(Config.defaultLayer), Layer.provide(Git.defaultLayer)) const { runPromise } = makeRuntime(Service, defaultLayer) diff --git a/packages/opencode/src/storage/storage.ts b/packages/opencode/src/storage/storage.ts index 0d0dce7264cf..c30089a18c9a 100644 --- a/packages/opencode/src/storage/storage.ts +++ b/packages/opencode/src/storage/storage.ts @@ -11,7 +11,11 @@ import { Git } from "@/git" export namespace Storage { const log = Log.create({ service: "storage" }) - type Migration = (dir: string, fs: AppFileSystem.Interface) => Effect.Effect + type Migration = ( + dir: string, + fs: AppFileSystem.Interface, + git: Git.Interface, + ) => Effect.Effect export const NotFoundError = NamedError.create( "NotFoundError", @@ -83,7 +87,7 @@ export namespace Storage { } const MIGRATIONS: Migration[] = [ - Effect.fn("Storage.migration.1")(function* (dir: string, fs: AppFileSystem.Interface) { + Effect.fn("Storage.migration.1")(function* (dir: string, fs: AppFileSystem.Interface, git: Git.Interface) { const project = path.resolve(dir, "../project") if (!(yield* fs.isDir(project))) return const projectDirs = yield* fs.glob("*", { @@ -110,11 +114,9 @@ export namespace Storage { } if (!worktree) continue if (!(yield* fs.isDir(worktree))) continue - const result = yield* Effect.promise(() => - Git.run(["rev-list", "--max-parents=0", "--all"], { - cwd: worktree, - }), - ) + const result = yield* git.run(["rev-list", "--max-parents=0", "--all"], { + cwd: worktree, + }) const [id] = result .text() .split("\n") @@ -220,6 +222,7 @@ export namespace Storage { Service, Effect.gen(function* () { const fs = yield* AppFileSystem.Service + const git = yield* Git.Service const locks = yield* RcMap.make({ lookup: () => TxReentrantLock.make(), idleTimeToLive: 0, @@ -236,7 +239,7 @@ export namespace Storage { for (let i = migration; i < MIGRATIONS.length; i++) { log.info("running migration", { index: i }) const step = MIGRATIONS[i]! - const exit = yield* Effect.exit(step(dir, fs)) + const exit = yield* Effect.exit(step(dir, fs, git)) if (Exit.isFailure(exit)) { log.error("failed to run migration", { index: i, cause: exit.cause }) break @@ -327,7 +330,7 @@ export namespace Storage { }), ) - export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer)) + export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer), Layer.provide(Git.defaultLayer)) const { runPromise } = makeRuntime(Service, defaultLayer) diff --git a/packages/opencode/test/file/watcher.test.ts b/packages/opencode/test/file/watcher.test.ts index 2224a80e680e..0c8968d94b05 100644 --- a/packages/opencode/test/file/watcher.test.ts +++ b/packages/opencode/test/file/watcher.test.ts @@ -7,6 +7,7 @@ import { tmpdir } from "../fixture/fixture" import { Bus } from "../../src/bus" import { Config } from "../../src/config/config" import { FileWatcher } from "../../src/file/watcher" +import { Git } from "../../src/git" import { Instance } from "../../src/project/instance" // Native @parcel/watcher bindings aren't reliably available in CI (missing on Linux, flaky on Windows) @@ -32,6 +33,7 @@ function withWatcher(directory: string, body: Effect.Effect) { fn: async () => { const layer: Layer.Layer = FileWatcher.layer.pipe( Layer.provide(Config.defaultLayer), + Layer.provide(Git.defaultLayer), Layer.provide(watcherConfigLayer), ) const rt = ManagedRuntime.make(layer) diff --git a/packages/opencode/test/storage/storage.test.ts b/packages/opencode/test/storage/storage.test.ts index e5a04c082dc5..1ff40b4b99ef 100644 --- a/packages/opencode/test/storage/storage.test.ts +++ b/packages/opencode/test/storage/storage.test.ts @@ -3,6 +3,7 @@ import fs from "fs/promises" import path from "path" import { Effect, Layer, ManagedRuntime } from "effect" import { AppFileSystem } from "../../src/filesystem" +import { Git } from "../../src/git" import { Global } from "../../src/global" import { Storage } from "../../src/storage/storage" import { tmpdir } from "../fixture/fixture" @@ -47,7 +48,7 @@ async function withStorage( root: string, fn: (run: (body: Effect.Effect) => Promise) => Promise, ) { - const rt = ManagedRuntime.make(Storage.layer.pipe(Layer.provide(layer(root)))) + const rt = ManagedRuntime.make(Storage.layer.pipe(Layer.provide(layer(root)), Layer.provide(Git.defaultLayer))) try { return await fn((body) => rt.runPromise(body)) } finally { From ce19c051be86b5d0fff458bf4d74803c40c06350 Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Fri, 10 Apr 2026 00:15:45 -0500 Subject: [PATCH 29/29] fix: ts lsp (#21827) --- packages/opencode/src/lsp/server.ts | 12 +--- packages/opencode/test/lsp/index.test.ts | 78 ------------------------ 2 files changed, 1 insertion(+), 89 deletions(-) diff --git a/packages/opencode/src/lsp/server.ts b/packages/opencode/src/lsp/server.ts index f50c858e912f..abfb31ead0eb 100644 --- a/packages/opencode/src/lsp/server.ts +++ b/packages/opencode/src/lsp/server.ts @@ -105,17 +105,7 @@ export namespace LSPServer { if (!tsserver) return const bin = await Npm.which("typescript-language-server") if (!bin) return - - const args = ["--stdio", "--tsserver-log-verbosity", "off", "--tsserver-path", tsserver] - - if ( - !(await pathExists(path.join(root, "tsconfig.json"))) && - !(await pathExists(path.join(root, "jsconfig.json"))) - ) { - args.push("--ignore-node-modules") - } - - const proc = spawn(bin, args, { + const proc = spawn(bin, ["--stdio"], { cwd: root, env: { ...process.env, diff --git a/packages/opencode/test/lsp/index.test.ts b/packages/opencode/test/lsp/index.test.ts index cfab72d83471..7e514e39b159 100644 --- a/packages/opencode/test/lsp/index.test.ts +++ b/packages/opencode/test/lsp/index.test.ts @@ -1,8 +1,6 @@ import { describe, expect, spyOn, test } from "bun:test" import path from "path" -import fs from "fs/promises" import * as Lsp from "../../src/lsp/index" -import * as launch from "../../src/lsp/launch" import { LSPServer } from "../../src/lsp/server" import { Instance } from "../../src/project/instance" import { tmpdir } from "../fixture/fixture" @@ -54,80 +52,4 @@ describe("lsp.spawn", () => { await Instance.disposeAll() } }) - - test("spawns builtin Typescript LSP with correct arguments", async () => { - await using tmp = await tmpdir() - - // Create dummy tsserver to satisfy Module.resolve - const tsdk = path.join(tmp.path, "node_modules", "typescript", "lib") - await fs.mkdir(tsdk, { recursive: true }) - await fs.writeFile(path.join(tsdk, "tsserver.js"), "") - - const spawnSpy = spyOn(launch, "spawn").mockImplementation( - () => - ({ - stdin: {}, - stdout: {}, - stderr: {}, - on: () => {}, - kill: () => {}, - }) as any, - ) - - try { - await Instance.provide({ - directory: tmp.path, - fn: async () => { - await LSPServer.Typescript.spawn(tmp.path) - }, - }) - - expect(spawnSpy).toHaveBeenCalled() - const args = spawnSpy.mock.calls[0][1] as string[] - - expect(args).toContain("--tsserver-path") - expect(args).toContain("--tsserver-log-verbosity") - expect(args).toContain("off") - } finally { - spawnSpy.mockRestore() - } - }) - - test("spawns builtin Typescript LSP with --ignore-node-modules if no config is found", async () => { - await using tmp = await tmpdir() - - // Create dummy tsserver to satisfy Module.resolve - const tsdk = path.join(tmp.path, "node_modules", "typescript", "lib") - await fs.mkdir(tsdk, { recursive: true }) - await fs.writeFile(path.join(tsdk, "tsserver.js"), "") - - // NO tsconfig.json or jsconfig.json created here - - const spawnSpy = spyOn(launch, "spawn").mockImplementation( - () => - ({ - stdin: {}, - stdout: {}, - stderr: {}, - on: () => {}, - kill: () => {}, - }) as any, - ) - - try { - await Instance.provide({ - directory: tmp.path, - fn: async () => { - await LSPServer.Typescript.spawn(tmp.path) - }, - }) - - expect(spawnSpy).toHaveBeenCalled() - const args = spawnSpy.mock.calls[0][1] as string[] - - expect(args).toContain("--ignore-node-modules") - } finally { - spawnSpy.mockRestore() - } - }) })