From 37b8662a9dec85a9c66e9679a34cbd09761a8b35 Mon Sep 17 00:00:00 2001 From: Dax Date: Thu, 19 Mar 2026 21:15:35 -0400 Subject: [PATCH 01/13] refactor: abstract SQLite behind runtime-conditional #db import (#18316) --- bun.lock | 20 ++++++++----- package.json | 4 +-- packages/opencode/package.json | 15 +++++++--- packages/opencode/src/storage/db.bun.ts | 8 ++++++ packages/opencode/src/storage/db.node.ts | 8 ++++++ packages/opencode/src/storage/db.ts | 36 ++++++++---------------- 6 files changed, 54 insertions(+), 37 deletions(-) create mode 100644 packages/opencode/src/storage/db.bun.ts create mode 100644 packages/opencode/src/storage/db.node.ts diff --git a/bun.lock b/bun.lock index 115100e104..91c007da30 100644 --- a/bun.lock +++ b/bun.lock @@ -355,7 +355,7 @@ "cross-spawn": "^7.0.6", "decimal.js": "10.5.0", "diff": "catalog:", - "drizzle-orm": "1.0.0-beta.16-ea816b6", + "drizzle-orm": "catalog:", "effect": "catalog:", "fuzzysort": "3.1.0", "glob": "13.0.5", @@ -409,8 +409,8 @@ "@types/which": "3.0.4", "@types/yargs": "17.0.33", "@typescript/native-preview": "catalog:", - "drizzle-kit": "1.0.0-beta.16-ea816b6", - "drizzle-orm": "1.0.0-beta.16-ea816b6", + "drizzle-kit": "catalog:", + "drizzle-orm": "catalog:", "typescript": "catalog:", "vscode-languageserver-types": "3.17.5", "why-is-node-running": "3.2.2", @@ -616,8 +616,8 @@ "ai": "5.0.124", "diff": "8.0.2", "dompurify": "3.3.1", - "drizzle-kit": "1.0.0-beta.16-ea816b6", - "drizzle-orm": "1.0.0-beta.16-ea816b6", + "drizzle-kit": "1.0.0-beta.19-d95b7a4", + "drizzle-orm": "1.0.0-beta.19-d95b7a4", "effect": "4.0.0-beta.35", "fuzzysort": "3.1.0", "hono": "4.10.7", @@ -2736,9 +2736,9 @@ "dotenv-expand": ["dotenv-expand@11.0.7", "", { "dependencies": { "dotenv": "^16.4.5" } }, "sha512-zIHwmZPRshsCdpMDyVsqGmgyP0yT8GAgXUnkdAoJisxvf33k7yO6OuoKmcTGuXPWSsm8Oh88nZicRLA9Y0rUeA=="], - "drizzle-kit": ["drizzle-kit@1.0.0-beta.16-ea816b6", "", { "dependencies": { "@drizzle-team/brocli": "^0.11.0", "@js-temporal/polyfill": "^0.5.1", "esbuild": "^0.25.10", "jiti": "^2.6.1" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-GiJQqCNPZP8Kk+i7/sFa3rtXbq26tLDNi3LbMx9aoLuwF2ofk8CS7cySUGdI+r4J3q0a568quC8FZeaFTCw4IA=="], + "drizzle-kit": ["drizzle-kit@1.0.0-beta.19-d95b7a4", "", { "dependencies": { "@drizzle-team/brocli": "^0.11.0", "@js-temporal/polyfill": "^0.5.1", "esbuild": "^0.25.10", "get-tsconfig": "^4.13.6", "jiti": "^2.6.1" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-M0sqc+42TYBod6kEZ3AsW6+JWe3+76gR1aDFbHH5DmuLKEwewmbzlhBG6qnvV6YA1cIIbkuam3dC7r6PREOCXw=="], - "drizzle-orm": ["drizzle-orm@1.0.0-beta.16-ea816b6", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@effect/sql": "^0.48.5", "@effect/sql-pg": "^0.49.7", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@prisma/client": "*", "@sinclair/typebox": ">=0.34.8", "@sqlitecloud/drivers": ">=1.0.653", "@tidbcloud/serverless": "*", "@tursodatabase/database": ">=0.2.1", "@tursodatabase/database-common": ">=0.2.1", "@tursodatabase/database-wasm": ">=0.2.1", "@types/better-sqlite3": "*", "@types/mssql": "^9.1.4", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "arktype": ">=2.0.0", "better-sqlite3": ">=9.3.0", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "mssql": "^11.0.1", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5", "typebox": ">=1.0.0", "valibot": ">=1.0.0-beta.7", "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@effect/sql", "@effect/sql-pg", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@sinclair/typebox", "@sqlitecloud/drivers", "@tidbcloud/serverless", "@tursodatabase/database", "@tursodatabase/database-common", "@tursodatabase/database-wasm", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "arktype", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "mysql2", "pg", "postgres", "sql.js", "sqlite3", "typebox", "valibot", "zod"] }, "sha512-k9gT4f0O9Qvah5YK/zL+FZonQ8TPyVxcG/ojN4dzO0fHP8hs8tBno8lqmJo53g0JLWv3Q2nsTUoyBRKM2TljFw=="], + "drizzle-orm": ["drizzle-orm@1.0.0-beta.19-d95b7a4", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@effect/sql": "^0.48.5", "@effect/sql-pg": "^0.49.7", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@sinclair/typebox": ">=0.34.8", "@sqlitecloud/drivers": ">=1.0.653", "@tidbcloud/serverless": "*", "@tursodatabase/database": ">=0.2.1", "@tursodatabase/database-common": ">=0.2.1", "@tursodatabase/database-wasm": ">=0.2.1", "@types/better-sqlite3": "*", "@types/mssql": "^9.1.4", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "arktype": ">=2.0.0", "better-sqlite3": ">=9.3.0", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "mssql": "^11.0.1", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5", "typebox": ">=1.0.0", "valibot": ">=1.0.0-beta.7", "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@effect/sql", "@effect/sql-pg", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@sinclair/typebox", "@sqlitecloud/drivers", "@tidbcloud/serverless", "@tursodatabase/database", "@tursodatabase/database-common", "@tursodatabase/database-wasm", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "arktype", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "mysql2", "pg", "postgres", "sql.js", "sqlite3", "typebox", "valibot", "zod"] }, "sha512-bZZKKeoRKrMVU6zKTscjrSH0+WNb1WEi3N0Jl4wEyQ7aQpTgHzdYY6IJQ1P0M74HuSJVeX4UpkFB/S6dtqLEJg=="], "dset": ["dset@3.1.4", "", {}, "sha512-2QF/g9/zTaPDc3BjNcVTGoBbXBgYfMTTceLaYcFJ/W9kggFUkhxD/hMEeuLKbugyef9SqAx8cpgwlIP/jinUTA=="], @@ -3020,6 +3020,8 @@ "get-symbol-description": ["get-symbol-description@1.1.0", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6" } }, "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg=="], + "get-tsconfig": ["get-tsconfig@4.13.6", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw=="], + "ghostty-web": ["ghostty-web@github:anomalyco/ghostty-web#4af877d", {}, "anomalyco-ghostty-web-4af877d", "sha512-fbEK8mtr7ar4ySsF+JUGjhaZrane7dKphanN+SxHt5XXI6yLMAh/Hpf6sNCOyyVa2UlGCd7YpXG/T2v2RUAX+A=="], "gifwrap": ["gifwrap@0.10.1", "", { "dependencies": { "image-q": "^4.0.0", "omggif": "^1.0.10" } }, "sha512-2760b1vpJHNmLzZ/ubTtNnEx5WApN/PYWJvXvgS+tL1egTTthayFYIQQNi136FLEDcN/IyEY2EcGpIITD6eYUw=="], @@ -4108,6 +4110,8 @@ "resolve-from": ["resolve-from@5.0.0", "", {}, "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw=="], + "resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="], + "responselike": ["responselike@2.0.1", "", { "dependencies": { "lowercase-keys": "^2.0.0" } }, "sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw=="], "restore-cursor": ["restore-cursor@3.1.0", "", { "dependencies": { "onetime": "^5.1.0", "signal-exit": "^3.0.2" } }, "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA=="], @@ -5386,6 +5390,8 @@ "cross-spawn/which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + "db0/drizzle-orm": ["drizzle-orm@1.0.0-beta.16-ea816b6", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@effect/sql": "^0.48.5", "@effect/sql-pg": "^0.49.7", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@prisma/client": "*", "@sinclair/typebox": ">=0.34.8", "@sqlitecloud/drivers": ">=1.0.653", "@tidbcloud/serverless": "*", "@tursodatabase/database": ">=0.2.1", "@tursodatabase/database-common": ">=0.2.1", "@tursodatabase/database-wasm": ">=0.2.1", "@types/better-sqlite3": "*", "@types/mssql": "^9.1.4", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "arktype": ">=2.0.0", "better-sqlite3": ">=9.3.0", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "mssql": "^11.0.1", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5", "typebox": ">=1.0.0", "valibot": ">=1.0.0-beta.7", "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@effect/sql", "@effect/sql-pg", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@sinclair/typebox", "@sqlitecloud/drivers", "@tidbcloud/serverless", "@tursodatabase/database", "@tursodatabase/database-common", "@tursodatabase/database-wasm", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "arktype", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "mysql2", "pg", "postgres", "sql.js", "sqlite3", "typebox", "valibot", "zod"] }, "sha512-k9gT4f0O9Qvah5YK/zL+FZonQ8TPyVxcG/ojN4dzO0fHP8hs8tBno8lqmJo53g0JLWv3Q2nsTUoyBRKM2TljFw=="], + "defaults/clone": ["clone@1.0.4", "", {}, "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg=="], "dir-compare/minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="], diff --git a/package.json b/package.json index b8329dd948..4d89daffd7 100644 --- a/package.json +++ b/package.json @@ -43,8 +43,8 @@ "@tailwindcss/vite": "4.1.11", "diff": "8.0.2", "dompurify": "3.3.1", - "drizzle-kit": "1.0.0-beta.16-ea816b6", - "drizzle-orm": "1.0.0-beta.16-ea816b6", + "drizzle-kit": "1.0.0-beta.19-d95b7a4", + "drizzle-orm": "1.0.0-beta.19-d95b7a4", "effect": "4.0.0-beta.35", "ai": "5.0.124", "hono": "4.10.7", diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 049573e3e5..d8392901e1 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -26,6 +26,13 @@ "exports": { "./*": "./src/*.ts" }, + "imports": { + "#db": { + "bun": "./src/storage/db.bun.ts", + "node": "./src/storage/db.node.ts", + "default": "./src/storage/db.bun.ts" + } + }, "devDependencies": { "@babel/core": "7.28.4", "@effect/language-service": "0.79.0", @@ -50,8 +57,8 @@ "@types/which": "3.0.4", "@types/yargs": "17.0.33", "@typescript/native-preview": "catalog:", - "drizzle-kit": "1.0.0-beta.16-ea816b6", - "drizzle-orm": "1.0.0-beta.16-ea816b6", + "drizzle-kit": "catalog:", + "drizzle-orm": "catalog:", "typescript": "catalog:", "vscode-languageserver-types": "3.17.5", "why-is-node-running": "3.2.2", @@ -113,7 +120,7 @@ "cross-spawn": "^7.0.6", "decimal.js": "10.5.0", "diff": "catalog:", - "drizzle-orm": "1.0.0-beta.16-ea816b6", + "drizzle-orm": "catalog:", "effect": "catalog:", "fuzzysort": "3.1.0", "glob": "13.0.5", @@ -144,6 +151,6 @@ "zod-to-json-schema": "3.24.5" }, "overrides": { - "drizzle-orm": "1.0.0-beta.16-ea816b6" + "drizzle-orm": "catalog:" } } diff --git a/packages/opencode/src/storage/db.bun.ts b/packages/opencode/src/storage/db.bun.ts new file mode 100644 index 0000000000..fa6190925a --- /dev/null +++ b/packages/opencode/src/storage/db.bun.ts @@ -0,0 +1,8 @@ +import { Database } from "bun:sqlite" +import { drizzle } from "drizzle-orm/bun-sqlite" + +export function init(path: string) { + const sqlite = new Database(path, { create: true }) + const db = drizzle({ client: sqlite }) + return db +} diff --git a/packages/opencode/src/storage/db.node.ts b/packages/opencode/src/storage/db.node.ts new file mode 100644 index 0000000000..0dba8dcef3 --- /dev/null +++ b/packages/opencode/src/storage/db.node.ts @@ -0,0 +1,8 @@ +import { DatabaseSync } from "node:sqlite" +import { drizzle } from "drizzle-orm/node-sqlite" + +export function init(path: string) { + const sqlite = new DatabaseSync(path) + const db = drizzle({ client: sqlite }) + return db +} diff --git a/packages/opencode/src/storage/db.ts b/packages/opencode/src/storage/db.ts index beb8e3eb52..dcf0942e12 100644 --- a/packages/opencode/src/storage/db.ts +++ b/packages/opencode/src/storage/db.ts @@ -1,5 +1,4 @@ -import { Database as BunDatabase } from "bun:sqlite" -import { drizzle, type SQLiteBunDatabase } from "drizzle-orm/bun-sqlite" +import { type SQLiteBunDatabase } from "drizzle-orm/bun-sqlite" import { migrate } from "drizzle-orm/bun-sqlite/migrator" import { type SQLiteTransaction } from "drizzle-orm/sqlite-core" export * from "drizzle-orm" @@ -11,10 +10,10 @@ import { NamedError } from "@opencode-ai/util/error" import z from "zod" import path from "path" import { readFileSync, readdirSync, existsSync } from "fs" -import * as schema from "./schema" import { Installation } from "../installation" import { Flag } from "../flag/flag" import { iife } from "@/util/iife" +import { init } from "#db" declare const OPENCODE_MIGRATIONS: { sql: string; timestamp: number; name: string }[] | undefined @@ -36,17 +35,12 @@ export namespace Database { return path.join(Global.Path.data, `opencode-${safe}.db`) }) - type Schema = typeof schema - export type Transaction = SQLiteTransaction<"sync", void, Schema> + export type Transaction = SQLiteTransaction<"sync", void> type Client = SQLiteBunDatabase type Journal = { sql: string; timestamp: number; name: string }[] - const state = { - sqlite: undefined as BunDatabase | undefined, - } - function time(tag: string) { const match = /^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})/.exec(tag) if (!match) return 0 @@ -83,17 +77,14 @@ export namespace Database { export const Client = lazy(() => { log.info("opening database", { path: Path }) - const sqlite = new BunDatabase(Path, { create: true }) - state.sqlite = sqlite + const db = init(Path) - sqlite.run("PRAGMA journal_mode = WAL") - sqlite.run("PRAGMA synchronous = NORMAL") - sqlite.run("PRAGMA busy_timeout = 5000") - sqlite.run("PRAGMA cache_size = -64000") - sqlite.run("PRAGMA foreign_keys = ON") - sqlite.run("PRAGMA wal_checkpoint(PASSIVE)") - - const db = drizzle({ client: sqlite }) + db.run("PRAGMA journal_mode = WAL") + db.run("PRAGMA synchronous = NORMAL") + db.run("PRAGMA busy_timeout = 5000") + db.run("PRAGMA cache_size = -64000") + db.run("PRAGMA foreign_keys = ON") + db.run("PRAGMA wal_checkpoint(PASSIVE)") // Apply schema migrations const entries = @@ -117,14 +108,11 @@ export namespace Database { }) export function close() { - const sqlite = state.sqlite - if (!sqlite) return - sqlite.close() - state.sqlite = undefined + Client().$client.close() Client.reset() } - export type TxOrDb = SQLiteTransaction<"sync", void, any, any> | Client + export type TxOrDb = Transaction | Client const ctx = Context.create<{ tx: TxOrDb From 52a7a04ad807acc577672f379e8e0cb327602e9d Mon Sep 17 00:00:00 2001 From: Dax Date: Thu, 19 Mar 2026 21:17:06 -0400 Subject: [PATCH 02/13] refactor: replace Bun shell execution with portable Process utilities (#18318) --- packages/opencode/src/mcp/index.ts | 11 ++++------- packages/opencode/src/session/prompt.ts | 16 +++++++--------- 2 files changed, 11 insertions(+), 16 deletions(-) diff --git a/packages/opencode/src/mcp/index.ts b/packages/opencode/src/mcp/index.ts index e48a42a8b3..bf5a0d3ce7 100644 --- a/packages/opencode/src/mcp/index.ts +++ b/packages/opencode/src/mcp/index.ts @@ -11,6 +11,7 @@ import { } from "@modelcontextprotocol/sdk/types.js" import { Config } from "../config/config" import { Log } from "../util/log" +import { Process } from "../util/process" import { NamedError } from "@opencode-ai/util/error" import z from "zod/v4" import { Instance } from "../project/instance" @@ -166,14 +167,10 @@ export namespace MCP { const queue = [pid] while (queue.length > 0) { const current = queue.shift()! - const proc = Bun.spawn(["pgrep", "-P", String(current)], { stdout: "pipe", stderr: "pipe" }) - const [code, out] = await Promise.all([proc.exited, new Response(proc.stdout).text()]).catch( - () => [-1, ""] as const, - ) - if (code !== 0) continue - for (const tok of out.trim().split(/\s+/)) { + const lines = await Process.lines(["pgrep", "-P", String(current)], { nothrow: true }) + for (const tok of lines) { const cpid = parseInt(tok, 10) - if (!isNaN(cpid) && pids.indexOf(cpid) === -1) { + if (!isNaN(cpid) && !pids.includes(cpid)) { pids.push(cpid) queue.push(cpid) } diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 36162656aa..1cc144c8d3 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -32,7 +32,6 @@ import { Flag } from "../flag/flag" import { ulid } from "ulid" import { spawn } from "child_process" import { Command } from "../command" -import { $ } from "bun" import { pathToFileURL, fileURLToPath } from "url" import { ConfigMarkdown } from "../config/markdown" import { SessionSummary } from "./summary" @@ -48,6 +47,7 @@ import { iife } from "@/util/iife" import { Shell } from "@/shell/shell" import { Truncate } from "@/tool/truncate" import { decodeDataUrl } from "@/util/data-url" +import { Process } from "@/util/process" // @ts-ignore globalThis.AI_SDK_LOG_WARNINGS = false @@ -1812,15 +1812,13 @@ NOTE: At any point in time through this workflow you should feel free to ask the template = template + "\n\n" + input.arguments } - const shell = ConfigMarkdown.shell(template) - if (shell.length > 0) { + const shellMatches = ConfigMarkdown.shell(template) + if (shellMatches.length > 0) { + const sh = Shell.preferred() const results = await Promise.all( - shell.map(async ([, cmd]) => { - try { - return await $`${{ raw: cmd }}`.quiet().nothrow().text() - } catch (error) { - return `Error executing command: ${error instanceof Error ? error.message : String(error)}` - } + shellMatches.map(async ([, cmd]) => { + const out = await Process.text([cmd], { shell: sh, nothrow: true }) + return out.text }), ) let index = 0 From 6fcc970def1434b095a20f5e79820fd3894883bd Mon Sep 17 00:00:00 2001 From: Dax Date: Thu, 19 Mar 2026 21:21:55 -0400 Subject: [PATCH 03/13] fix: include cache bin directory in which() lookups (#18320) --- packages/opencode/src/global/index.ts | 2 +- packages/opencode/src/util/which.ts | 6 +++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/global/index.ts b/packages/opencode/src/global/index.ts index 2913ac90fe..869019e2ce 100644 --- a/packages/opencode/src/global/index.ts +++ b/packages/opencode/src/global/index.ts @@ -18,7 +18,7 @@ export namespace Global { return process.env.OPENCODE_TEST_HOME || os.homedir() }, data, - bin: path.join(data, "bin"), + bin: path.join(cache, "bin"), log: path.join(data, "log"), cache, config, diff --git a/packages/opencode/src/util/which.ts b/packages/opencode/src/util/which.ts index 81da257217..2e40739148 100644 --- a/packages/opencode/src/util/which.ts +++ b/packages/opencode/src/util/which.ts @@ -1,9 +1,13 @@ import whichPkg from "which" +import path from "path" +import { Global } from "../global" export function which(cmd: string, env?: NodeJS.ProcessEnv) { + const base = env?.PATH ?? env?.Path ?? process.env.PATH ?? process.env.Path ?? "" + const full = base ? base + path.delimiter + Global.Path.bin : Global.Path.bin const result = whichPkg.sync(cmd, { nothrow: true, - path: env?.PATH ?? env?.Path ?? process.env.PATH ?? process.env.Path, + path: full, pathExt: env?.PATHEXT ?? env?.PathExt ?? process.env.PATHEXT ?? process.env.PathExt, }) return typeof result === "string" ? result : null From 92cd908fb54de951097efea8ad97ee4dc1b97c37 Mon Sep 17 00:00:00 2001 From: Dax Date: Thu, 19 Mar 2026 21:35:07 -0400 Subject: [PATCH 04/13] feat: add Node.js entry point and build script (#18324) --- packages/opencode/script/build-node.ts | 54 ++++++++++++++++++++++++++ packages/opencode/src/node.ts | 1 + 2 files changed, 55 insertions(+) create mode 100644 packages/opencode/script/build-node.ts create mode 100644 packages/opencode/src/node.ts diff --git a/packages/opencode/script/build-node.ts b/packages/opencode/script/build-node.ts new file mode 100644 index 0000000000..17bc86307a --- /dev/null +++ b/packages/opencode/script/build-node.ts @@ -0,0 +1,54 @@ +#!/usr/bin/env bun + +import fs from "fs" +import path from "path" +import { fileURLToPath } from "url" + +const __filename = fileURLToPath(import.meta.url) +const __dirname = path.dirname(__filename) +const dir = path.resolve(__dirname, "..") + +process.chdir(dir) + +// Load migrations from migration directories +const migrationDirs = ( + await fs.promises.readdir(path.join(dir, "migration"), { + withFileTypes: true, + }) +) + .filter((entry) => entry.isDirectory() && /^\d{4}\d{2}\d{2}\d{2}\d{2}\d{2}/.test(entry.name)) + .map((entry) => entry.name) + .sort() + +const migrations = await Promise.all( + migrationDirs.map(async (name) => { + const file = path.join(dir, "migration", name, "migration.sql") + const sql = await Bun.file(file).text() + const match = /^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})/.exec(name) + const timestamp = match + ? Date.UTC( + Number(match[1]), + Number(match[2]) - 1, + Number(match[3]), + Number(match[4]), + Number(match[5]), + Number(match[6]), + ) + : 0 + return { sql, timestamp, name } + }), +) +console.log(`Loaded ${migrations.length} migrations`) + +await Bun.build({ + target: "node", + entrypoints: ["./src/node.ts"], + outdir: "./dist", + format: "esm", + external: ["jsonc-parser"], + define: { + OPENCODE_MIGRATIONS: JSON.stringify(migrations), + }, +}) + +console.log("Build complete") diff --git a/packages/opencode/src/node.ts b/packages/opencode/src/node.ts new file mode 100644 index 0000000000..b0e653d6cc --- /dev/null +++ b/packages/opencode/src/node.ts @@ -0,0 +1 @@ +export { Server } from "./server/server" From 949191ab74424dc0da8d2c2c180b6c192e8132d9 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Fri, 20 Mar 2026 01:36:22 +0000 Subject: [PATCH 05/13] chore: update nix node_modules hashes --- nix/hashes.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/nix/hashes.json b/nix/hashes.json index c03eeddcd2..d1c1fcecf0 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,8 +1,8 @@ { "nodeModules": { - "x86_64-linux": "sha256-yfA50QKqylmaioxi+6d++W8Xv4Wix1hl3hEF6Zz7Ue0=", - "aarch64-linux": "sha256-b5sO7V+/zzJClHHKjkSz+9AUBYC8cb7S3m5ab1kpAyk=", - "aarch64-darwin": "sha256-V66nmRX6kAjrc41ARVeuTElWK7KD8qG/DVk9K7Fu+J8=", - "x86_64-darwin": "sha256-cFyh60WESiqZ5XWZi1+g3F/beSDL1+UPG8KhRivhK8w=" + "x86_64-linux": "sha256-Z5RYwq5ambJ5K5O6Iqfw+xQTR8U64pmtq/nedxrvvkU=", + "aarch64-linux": "sha256-nk6+PAAh2vy+0fSrWIMKwi/2+3YCVeDTved+2GZxksk=", + "aarch64-darwin": "sha256-3n3GMmrhn24d7lXnUNByZSXvfwcn+kDZfyRcldQiXlk=", + "x86_64-darwin": "sha256-vtxQdSC3VVXt/d3TVCIpZ7WDSRquwd/yPTQqapJWeT4=" } } From b3d0446d13504f63c6c26dfd040779a3ccd056cc Mon Sep 17 00:00:00 2001 From: Jaaneek <25470423+Jaaneek@users.noreply.github.com> Date: Fri, 20 Mar 2026 02:09:49 +0000 Subject: [PATCH 06/13] feat: switch xai provider to responses API (#18175) Co-authored-by: Jaaneek --- bun.lock | 1 + package.json | 3 +- packages/opencode/src/provider/provider.ts | 9 ++ patches/@ai-sdk%2Fxai@2.0.51.patch | 108 +++++++++++++++++++++ 4 files changed, 120 insertions(+), 1 deletion(-) create mode 100644 patches/@ai-sdk%2Fxai@2.0.51.patch diff --git a/bun.lock b/bun.lock index 91c007da30..6d82378458 100644 --- a/bun.lock +++ b/bun.lock @@ -586,6 +586,7 @@ ], "patchedDependencies": { "@openrouter/ai-sdk-provider@1.5.4": "patches/@openrouter%2Fai-sdk-provider@1.5.4.patch", + "@ai-sdk/xai@2.0.51": "patches/@ai-sdk%2Fxai@2.0.51.patch", "@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch", }, "overrides": { diff --git a/package.json b/package.json index 4d89daffd7..2875b9daa2 100644 --- a/package.json +++ b/package.json @@ -112,6 +112,7 @@ }, "patchedDependencies": { "@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch", - "@openrouter/ai-sdk-provider@1.5.4": "patches/@openrouter%2Fai-sdk-provider@1.5.4.patch" + "@openrouter/ai-sdk-provider@1.5.4": "patches/@openrouter%2Fai-sdk-provider@1.5.4.patch", + "@ai-sdk/xai@2.0.51": "patches/@ai-sdk%2Fxai@2.0.51.patch" } } diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index f7667fc2cb..9c9c8e8343 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -184,6 +184,15 @@ export namespace Provider { options: {}, } }, + xai: async () => { + return { + autoload: false, + async getModel(sdk: any, modelID: string, _options?: Record) { + return sdk.responses(modelID) + }, + options: {}, + } + }, "github-copilot": async () => { return { autoload: false, diff --git a/patches/@ai-sdk%2Fxai@2.0.51.patch b/patches/@ai-sdk%2Fxai@2.0.51.patch new file mode 100644 index 0000000000..8776cab483 --- /dev/null +++ b/patches/@ai-sdk%2Fxai@2.0.51.patch @@ -0,0 +1,108 @@ +diff --git a/dist/index.mjs b/dist/index.mjs +--- a/dist/index.mjs ++++ b/dist/index.mjs +@@ -959,7 +959,7 @@ + model: z4.string().nullish(), + object: z4.literal("response"), + output: z4.array(outputItemSchema), +- usage: xaiResponsesUsageSchema, ++ usage: xaiResponsesUsageSchema.nullish(), + status: z4.string() + }); + var xaiResponsesChunkSchema = z4.union([ +\ No newline at end of file +@@ -1143,6 +1143,18 @@ + z4.object({ + type: z4.literal("response.completed"), + response: xaiResponsesResponseSchema ++ }), ++ z4.object({ ++ type: z4.literal("response.function_call_arguments.delta"), ++ item_id: z4.string(), ++ output_index: z4.number(), ++ delta: z4.string() ++ }), ++ z4.object({ ++ type: z4.literal("response.function_call_arguments.done"), ++ item_id: z4.string(), ++ output_index: z4.number(), ++ arguments: z4.string() + }) + ]); + +\ No newline at end of file +@@ -1940,6 +1952,9 @@ + if (response2.status) { + finishReason = mapXaiResponsesFinishReason(response2.status); + } ++ if (seenToolCalls.size > 0 && finishReason !== "tool-calls") { ++ finishReason = "tool-calls"; ++ } + return; + } + if (event.type === "response.output_item.added" || event.type === "response.output_item.done") { +\ No newline at end of file +@@ -2024,7 +2039,7 @@ + } + } + } else if (part.type === "function_call") { +- if (!seenToolCalls.has(part.call_id)) { ++ if (event.type === "response.output_item.done" && !seenToolCalls.has(part.call_id)) { + seenToolCalls.add(part.call_id); + controller.enqueue({ + type: "tool-input-start", +\ No newline at end of file +diff --git a/dist/index.js b/dist/index.js +--- a/dist/index.js ++++ b/dist/index.js +@@ -964,7 +964,7 @@ + model: import_v44.z.string().nullish(), + object: import_v44.z.literal("response"), + output: import_v44.z.array(outputItemSchema), +- usage: xaiResponsesUsageSchema, ++ usage: xaiResponsesUsageSchema.nullish(), + status: import_v44.z.string() + }); + var xaiResponsesChunkSchema = import_v44.z.union([ +\ No newline at end of file +@@ -1148,6 +1148,18 @@ + import_v44.z.object({ + type: import_v44.z.literal("response.completed"), + response: xaiResponsesResponseSchema ++ }), ++ import_v44.z.object({ ++ type: import_v44.z.literal("response.function_call_arguments.delta"), ++ item_id: import_v44.z.string(), ++ output_index: import_v44.z.number(), ++ delta: import_v44.z.string() ++ }), ++ import_v44.z.object({ ++ type: import_v44.z.literal("response.function_call_arguments.done"), ++ item_id: import_v44.z.string(), ++ output_index: import_v44.z.number(), ++ arguments: import_v44.z.string() + }) + ]); + +\ No newline at end of file +@@ -1935,6 +1947,9 @@ + if (response2.status) { + finishReason = mapXaiResponsesFinishReason(response2.status); + } ++ if (seenToolCalls.size > 0 && finishReason !== "tool-calls") { ++ finishReason = "tool-calls"; ++ } + return; + } + if (event.type === "response.output_item.added" || event.type === "response.output_item.done") { +\ No newline at end of file +@@ -2019,7 +2034,7 @@ + } + } + } else if (part.type === "function_call") { +- if (!seenToolCalls.has(part.call_id)) { ++ if (event.type === "response.output_item.done" && !seenToolCalls.has(part.call_id)) { + seenToolCalls.add(part.call_id); + controller.enqueue({ + type: "tool-input-start", +\ No newline at end of file From 1071aca91fa69044f281c1e54107dfde9dce7c75 Mon Sep 17 00:00:00 2001 From: Dax Date: Thu, 19 Mar 2026 22:20:29 -0400 Subject: [PATCH 07/13] fix: miscellaneous small fixes (#18328) --- packages/opencode/src/server/routes/project.ts | 2 +- packages/opencode/src/session/message-v2.ts | 2 +- packages/opencode/src/tool/registry.ts | 2 +- packages/opencode/src/util/process.ts | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/opencode/src/server/routes/project.ts b/packages/opencode/src/server/routes/project.ts index 994d58b0ca..6cd51ac958 100644 --- a/packages/opencode/src/server/routes/project.ts +++ b/packages/opencode/src/server/routes/project.ts @@ -29,7 +29,7 @@ export const ProjectRoutes = lazy(() => }, }), async (c) => { - const projects = await Project.list() + const projects = Project.list() return c.json(projects) }, ) diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index 41e2d4efc7..f1335f6f21 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -13,7 +13,7 @@ import { STATUS_CODES } from "http" import { Storage } from "@/storage/storage" import { ProviderError } from "@/provider/error" import { iife } from "@/util/iife" -import { type SystemError } from "bun" +import type { SystemError } from "bun" import type { Provider } from "@/provider/provider" import { ModelID, ProviderID } from "@/provider/schema" diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index da9a897905..6d648a097a 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -46,7 +46,7 @@ export namespace ToolRegistry { if (matches.length) await Config.waitForDependencies() for (const match of matches) { const namespace = path.basename(match, path.extname(match)) - const mod = await import(pathToFileURL(match).href) + const mod = await import(process.platform === "win32" ? match : pathToFileURL(match).href) for (const [id, def] of Object.entries(mod)) { custom.push(fromPlugin(id === "default" ? namespace : `${namespace}_${id}`, def)) } diff --git a/packages/opencode/src/util/process.ts b/packages/opencode/src/util/process.ts index 7e6be0e20d..22dce37cb0 100644 --- a/packages/opencode/src/util/process.ts +++ b/packages/opencode/src/util/process.ts @@ -61,9 +61,9 @@ export namespace Process { const proc = launch(cmd[0], cmd.slice(1), { cwd: opts.cwd, + shell: opts.shell, env: opts.env === null ? {} : opts.env ? { ...process.env, ...opts.env } : undefined, stdio: [opts.stdin ?? "ignore", opts.stdout ?? "ignore", opts.stderr ?? "ignore"], - shell: opts.shell, windowsHide: process.platform === "win32", }) From e71a21e0a8ac721677ffdfd67fc0301a6d0a3716 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Fri, 20 Mar 2026 02:21:29 +0000 Subject: [PATCH 08/13] chore: update nix node_modules hashes --- nix/hashes.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/nix/hashes.json b/nix/hashes.json index d1c1fcecf0..3db81fb5a7 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,8 +1,8 @@ { "nodeModules": { - "x86_64-linux": "sha256-Z5RYwq5ambJ5K5O6Iqfw+xQTR8U64pmtq/nedxrvvkU=", - "aarch64-linux": "sha256-nk6+PAAh2vy+0fSrWIMKwi/2+3YCVeDTved+2GZxksk=", - "aarch64-darwin": "sha256-3n3GMmrhn24d7lXnUNByZSXvfwcn+kDZfyRcldQiXlk=", - "x86_64-darwin": "sha256-vtxQdSC3VVXt/d3TVCIpZ7WDSRquwd/yPTQqapJWeT4=" + "x86_64-linux": "sha256-xq0W2Ym0AzANLXnLyAL+IUwrFm0MKXwkJVdENowoPyY=", + "aarch64-linux": "sha256-RtpiGZXk+BboD9MjBetq5sInIbH/OPkLVNSFgN/0ehY=", + "aarch64-darwin": "sha256-cX6y262OzqRicH4m0/u1DCsMkpJfzCUOOBFQqtQLvls=", + "x86_64-darwin": "sha256-K4UmRKiEfKkvVeKUB85XjHJ1jf0ZUnjL0dWvx9TD4pI=" } } From 7866dbcfcc36a60d22ad466eddf54c54b21fabe3 Mon Sep 17 00:00:00 2001 From: Luke Parker <10430890+Hona@users.noreply.github.com> Date: Fri, 20 Mar 2026 13:52:04 +1000 Subject: [PATCH 09/13] fix: avoid truncate permission import cycle (#18292) --- packages/opencode/src/permission/evaluate.ts | 15 +++++++++++++++ packages/opencode/src/permission/index.ts | 9 +++------ packages/opencode/src/tool/truncate-effect.ts | 4 ++-- packages/opencode/test/tool/truncation.test.ts | 10 ++++++++++ 4 files changed, 30 insertions(+), 8 deletions(-) create mode 100644 packages/opencode/src/permission/evaluate.ts diff --git a/packages/opencode/src/permission/evaluate.ts b/packages/opencode/src/permission/evaluate.ts new file mode 100644 index 0000000000..2b0604f4ba --- /dev/null +++ b/packages/opencode/src/permission/evaluate.ts @@ -0,0 +1,15 @@ +import { Wildcard } from "@/util/wildcard" + +type Rule = { + permission: string + pattern: string + action: "allow" | "deny" | "ask" +} + +export function evaluate(permission: string, pattern: string, ...rulesets: Rule[][]): Rule { + const rules = rulesets.flat() + const match = rules.findLast( + (rule) => Wildcard.match(permission, rule.permission) && Wildcard.match(pattern, rule.pattern), + ) + return match ?? { action: "ask", permission, pattern: "*" } +} diff --git a/packages/opencode/src/permission/index.ts b/packages/opencode/src/permission/index.ts index 93a8c49b65..321c5c374e 100644 --- a/packages/opencode/src/permission/index.ts +++ b/packages/opencode/src/permission/index.ts @@ -13,6 +13,7 @@ import { Wildcard } from "@/util/wildcard" import { Deferred, Effect, Layer, Schema, ServiceMap } from "effect" import os from "os" import z from "zod" +import { evaluate as evalRule } from "./evaluate" import { PermissionID } from "./schema" export namespace PermissionNext { @@ -125,12 +126,8 @@ export namespace PermissionNext { } export function evaluate(permission: string, pattern: string, ...rulesets: Ruleset[]): Rule { - const rules = rulesets.flat() - log.info("evaluate", { permission, pattern, ruleset: rules }) - const match = rules.findLast( - (rule) => Wildcard.match(permission, rule.permission) && Wildcard.match(pattern, rule.pattern), - ) - return match ?? { action: "ask", permission, pattern: "*" } + log.info("evaluate", { permission, pattern, ruleset: rulesets.flat() }) + return evalRule(permission, pattern, ...rulesets) } export class Service extends ServiceMap.Service()("@opencode/PermissionNext") {} diff --git a/packages/opencode/src/tool/truncate-effect.ts b/packages/opencode/src/tool/truncate-effect.ts index 4431c18f83..a263cd2943 100644 --- a/packages/opencode/src/tool/truncate-effect.ts +++ b/packages/opencode/src/tool/truncate-effect.ts @@ -3,7 +3,7 @@ import { Cause, Duration, Effect, Layer, Schedule, ServiceMap } from "effect" import path from "path" import type { Agent } from "../agent/agent" import { AppFileSystem } from "@/filesystem" -import { PermissionNext } from "../permission" +import { evaluate } from "@/permission/evaluate" import { Identifier } from "../id/id" import { Log } from "../util/log" import { ToolID } from "./schema" @@ -28,7 +28,7 @@ export namespace TruncateEffect { function hasTaskTool(agent?: Agent.Info) { if (!agent?.permission) return false - return PermissionNext.evaluate("task", "*", agent.permission).action !== "deny" + return evaluate("task", "*", agent.permission).action !== "deny" } export interface Interface { diff --git a/packages/opencode/test/tool/truncation.test.ts b/packages/opencode/test/tool/truncation.test.ts index 71439f7604..a00e07e692 100644 --- a/packages/opencode/test/tool/truncation.test.ts +++ b/packages/opencode/test/tool/truncation.test.ts @@ -4,12 +4,14 @@ import { Effect, FileSystem, Layer } from "effect" import { Truncate } from "../../src/tool/truncate" import { TruncateEffect } from "../../src/tool/truncate-effect" import { Identifier } from "../../src/id/id" +import { Process } from "../../src/util/process" import { Filesystem } from "../../src/util/filesystem" import path from "path" import { testEffect } from "../lib/effect" import { writeFileStringScoped } from "../lib/filesystem" const FIXTURES_DIR = path.join(import.meta.dir, "fixtures") +const ROOT = path.resolve(import.meta.dir, "..", "..") describe("Truncate", () => { describe("output", () => { @@ -125,6 +127,14 @@ describe("Truncate", () => { if (result.truncated) throw new Error("expected not truncated") expect("outputPath" in result).toBe(false) }) + + test("loads truncate effect in a fresh process", async () => { + const out = await Process.run([process.execPath, "run", path.join(ROOT, "src", "tool", "truncate-effect.ts")], { + cwd: ROOT, + }) + + expect(out.code).toBe(0) + }, 20000) }) describe("cleanup", () => { From d460614cd7ad9e047a2792139ea67e16caa82ea7 Mon Sep 17 00:00:00 2001 From: Luke Parker <10430890+Hona@users.noreply.github.com> Date: Fri, 20 Mar 2026 14:12:06 +1000 Subject: [PATCH 10/13] fix: lots of desktop stability, better e2e error logging (#18300) --- .github/workflows/test.yml | 28 +- packages/app/e2e/actions.ts | 152 +++++++++- packages/app/e2e/fixtures.ts | 44 ++- .../app/e2e/projects/projects-switch.spec.ts | 53 +--- .../projects/workspace-new-session.spec.ts | 71 ++--- .../session/session-model-persistence.spec.ts | 38 +-- packages/app/src/context/global-sync.tsx | 1 + .../src/context/global-sync/child-store.ts | 10 + packages/app/src/pages/error.tsx | 10 +- packages/app/src/pages/layout.tsx | 47 +-- packages/app/src/pages/layout/helpers.ts | 4 +- .../app/src/pages/layout/sidebar-project.tsx | 7 +- .../src/pages/layout/sidebar-workspace.tsx | 4 +- packages/opencode/src/session/prompt.ts | 6 +- packages/opencode/src/session/summary.ts | 20 +- packages/ui/src/components/message-part.tsx | 277 +++++++++--------- 16 files changed, 458 insertions(+), 314 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index c928e82234..9c58be30ab 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -50,20 +50,17 @@ jobs: e2e: name: e2e (${{ matrix.settings.name }}) - needs: unit strategy: fail-fast: false matrix: settings: - name: linux host: blacksmith-4vcpu-ubuntu-2404 - playwright: bunx playwright install --with-deps - name: windows host: blacksmith-4vcpu-windows-2025 - playwright: bunx playwright install runs-on: ${{ matrix.settings.host }} env: - PLAYWRIGHT_BROWSERS_PATH: 0 + PLAYWRIGHT_BROWSERS_PATH: ${{ github.workspace }}/.playwright-browsers defaults: run: shell: bash @@ -76,9 +73,28 @@ jobs: - name: Setup Bun uses: ./.github/actions/setup-bun - - name: Install Playwright browsers + - name: Read Playwright version + id: playwright-version + run: | + version=$(node -e 'console.log(require("./packages/app/package.json").devDependencies["@playwright/test"])') + echo "version=$version" >> "$GITHUB_OUTPUT" + + - name: Cache Playwright browsers + id: playwright-cache + uses: actions/cache@v4 + with: + path: ${{ github.workspace }}/.playwright-browsers + key: ${{ runner.os }}-${{ runner.arch }}-playwright-${{ steps.playwright-version.outputs.version }}-chromium + + - name: Install Playwright system dependencies + if: runner.os == 'Linux' working-directory: packages/app - run: ${{ matrix.settings.playwright }} + run: bunx playwright install-deps chromium + + - name: Install Playwright browsers + if: steps.playwright-cache.outputs.cache-hit != 'true' + working-directory: packages/app + run: bunx playwright install chromium - name: Run app e2e tests run: bun --cwd packages/app test:e2e:local diff --git a/packages/app/e2e/actions.ts b/packages/app/e2e/actions.ts index 88d71f94cf..aced0756c0 100644 --- a/packages/app/e2e/actions.ts +++ b/packages/app/e2e/actions.ts @@ -9,6 +9,7 @@ import { createSdk, modKey, resolveDirectory, serverUrl } from "./utils" import { dropdownMenuTriggerSelector, dropdownMenuContentSelector, + projectSwitchSelector, projectMenuTriggerSelector, projectCloseMenuSelector, projectWorkspacesToggleSelector, @@ -23,6 +24,16 @@ import { workspaceMenuTriggerSelector, } from "./selectors" +const phase = new WeakMap() + +export function setHealthPhase(page: Page, value: "test" | "cleanup") { + phase.set(page, value) +} + +export function healthPhase(page: Page) { + return phase.get(page) ?? "test" +} + export async function defocus(page: Page) { await page .evaluate(() => { @@ -196,11 +207,51 @@ export async function closeDialog(page: Page, dialog: Locator) { } export async function isSidebarClosed(page: Page) { - const button = page.getByRole("button", { name: /toggle sidebar/i }).first() - await expect(button).toBeVisible() + const button = await waitSidebarButton(page, "isSidebarClosed") return (await button.getAttribute("aria-expanded")) !== "true" } +async function errorBoundaryText(page: Page) { + const title = page.getByRole("heading", { name: /something went wrong/i }).first() + if (!(await title.isVisible().catch(() => false))) return + + const description = await page + .getByText(/an error occurred while loading the application\./i) + .first() + .textContent() + .catch(() => "") + const detail = await page + .getByRole("textbox", { name: /error details/i }) + .first() + .inputValue() + .catch(async () => + ( + (await page + .getByRole("textbox", { name: /error details/i }) + .first() + .textContent() + .catch(() => "")) ?? "" + ).trim(), + ) + + return [title ? "Error boundary" : "", description ?? "", detail ?? ""].filter(Boolean).join("\n") +} + +export async function assertHealthy(page: Page, context: string) { + const text = await errorBoundaryText(page) + if (!text) return + console.log(`[e2e:error-boundary][${context}]\n${text}`) + throw new Error(`Error boundary during ${context}\n${text}`) +} + +async function waitSidebarButton(page: Page, context: string) { + const button = page.getByRole("button", { name: /toggle sidebar/i }).first() + const boundary = page.getByRole("heading", { name: /something went wrong/i }).first() + await button.or(boundary).first().waitFor({ state: "visible", timeout: 10_000 }) + await assertHealthy(page, context) + return button +} + export async function toggleSidebar(page: Page) { await defocus(page) await page.keyboard.press(`${modKey}+B`) @@ -209,7 +260,7 @@ export async function toggleSidebar(page: Page) { export async function openSidebar(page: Page) { if (!(await isSidebarClosed(page))) return - const button = page.getByRole("button", { name: /toggle sidebar/i }).first() + const button = await waitSidebarButton(page, "openSidebar") await button.click() const opened = await expect(button) @@ -226,7 +277,7 @@ export async function openSidebar(page: Page) { export async function closeSidebar(page: Page) { if (await isSidebarClosed(page)) return - const button = page.getByRole("button", { name: /toggle sidebar/i }).first() + const button = await waitSidebarButton(page, "closeSidebar") await button.click() const closed = await expect(button) @@ -241,6 +292,7 @@ export async function closeSidebar(page: Page) { } export async function openSettings(page: Page) { + await assertHealthy(page, "openSettings") await defocus(page) const dialog = page.getByRole("dialog") @@ -253,6 +305,8 @@ export async function openSettings(page: Page) { if (opened) return dialog + await assertHealthy(page, "openSettings") + await page.getByRole("button", { name: "Settings" }).first().click() await expect(dialog).toBeVisible() return dialog @@ -314,10 +368,12 @@ export async function seedProjects(page: Page, input: { directory: string; extra export async function createTestProject() { const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-e2e-project-")) + const id = `e2e-${path.basename(root)}` - await fs.writeFile(path.join(root, "README.md"), "# e2e\n") + await fs.writeFile(path.join(root, "README.md"), `# e2e\n\n${id}\n`) execSync("git init", { cwd: root, stdio: "ignore" }) + await fs.writeFile(path.join(root, ".git", "opencode"), id) execSync("git config core.fsmonitor false", { cwd: root, stdio: "ignore" }) execSync("git add -A", { cwd: root, stdio: "ignore" }) execSync('git -c user.name="e2e" -c user.email="e2e@example.com" commit -m "init" --allow-empty', { @@ -339,12 +395,24 @@ export function slugFromUrl(url: string) { return /\/([^/]+)\/session(?:[/?#]|$)/.exec(url)?.[1] ?? "" } +async function probeSession(page: Page) { + return page + .evaluate(() => { + const win = window as E2EWindow + const current = win.__opencode_e2e?.model?.current + if (!current) return null + return { dir: current.dir, sessionID: current.sessionID } + }) + .catch(() => null as { dir?: string; sessionID?: string } | null) +} + export async function waitSlug(page: Page, skip: string[] = []) { let prev = "" let next = "" await expect .poll( - () => { + async () => { + await assertHealthy(page, "waitSlug") const slug = slugFromUrl(page.url()) if (!slug) return "" if (skip.includes(slug)) return "" @@ -374,6 +442,7 @@ export async function waitDir(page: Page, directory: string) { await expect .poll( async () => { + await assertHealthy(page, "waitDir") const slug = slugFromUrl(page.url()) if (!slug) return "" return resolveSlug(slug) @@ -386,6 +455,69 @@ export async function waitDir(page: Page, directory: string) { return { directory: target, slug: base64Encode(target) } } +export async function waitSession(page: Page, input: { directory: string; sessionID?: string }) { + const target = await resolveDirectory(input.directory) + await expect + .poll( + async () => { + await assertHealthy(page, "waitSession") + const slug = slugFromUrl(page.url()) + if (!slug) return false + const resolved = await resolveSlug(slug).catch(() => undefined) + if (!resolved || resolved.directory !== target) return false + if (input.sessionID && sessionIDFromUrl(page.url()) !== input.sessionID) return false + + const state = await probeSession(page) + if (input.sessionID && (!state || state.sessionID !== input.sessionID)) return false + if (state?.dir) { + const dir = await resolveDirectory(state.dir).catch(() => state.dir ?? "") + if (dir !== target) return false + } + + return page + .locator(promptSelector) + .first() + .isVisible() + .catch(() => false) + }, + { timeout: 45_000 }, + ) + .toBe(true) + return { directory: target, slug: base64Encode(target) } +} + +export async function waitSessionSaved(directory: string, sessionID: string, timeout = 30_000) { + const sdk = createSdk(directory) + const target = await resolveDirectory(directory) + + await expect + .poll( + async () => { + const data = await sdk.session + .get({ sessionID }) + .then((x) => x.data) + .catch(() => undefined) + if (!data?.directory) return "" + return resolveDirectory(data.directory).catch(() => data.directory) + }, + { timeout }, + ) + .toBe(target) + + await expect + .poll( + async () => { + const items = await sdk.session + .messages({ sessionID, limit: 20 }) + .then((x) => x.data ?? []) + .catch(() => []) + return items.some((item) => item.info.role === "user") + }, + { timeout }, + ) + .toBe(true) +} + export function sessionIDFromUrl(url: string) { const match = /\/session\/([^/?#]+)/.exec(url) return match?.[1] @@ -797,8 +929,14 @@ export async function openStatusPopover(page: Page) { } export async function openProjectMenu(page: Page, projectSlug: string) { + await openSidebar(page) + const item = page.locator(projectSwitchSelector(projectSlug)).first() + await expect(item).toBeVisible() + await item.hover() + const trigger = page.locator(projectMenuTriggerSelector(projectSlug)).first() await expect(trigger).toHaveCount(1) + await expect(trigger).toBeVisible() const menu = page .locator(dropdownMenuContentSelector) @@ -807,7 +945,7 @@ export async function openProjectMenu(page: Page, projectSlug: string) { const close = menu.locator(projectCloseMenuSelector(projectSlug)).first() const clicked = await trigger - .click({ timeout: 1500 }) + .click({ force: true, timeout: 1500 }) .then(() => true) .catch(() => false) diff --git a/packages/app/e2e/fixtures.ts b/packages/app/e2e/fixtures.ts index 7bc994e507..7232df6877 100644 --- a/packages/app/e2e/fixtures.ts +++ b/packages/app/e2e/fixtures.ts @@ -1,7 +1,16 @@ import { test as base, expect, type Page } from "@playwright/test" import type { E2EWindow } from "../src/testing/terminal" -import { cleanupSession, cleanupTestProject, createTestProject, seedProjects, sessionIDFromUrl } from "./actions" -import { promptSelector } from "./selectors" +import { + healthPhase, + cleanupSession, + cleanupTestProject, + createTestProject, + setHealthPhase, + seedProjects, + sessionIDFromUrl, + waitSlug, + waitSession, +} from "./actions" import { createSdk, dirSlug, getWorktree, sessionPath } from "./utils" export const settingsKey = "settings.v3" @@ -27,6 +36,29 @@ type WorkerFixtures = { } export const test = base.extend({ + page: async ({ page }, use) => { + let boundary: string | undefined + setHealthPhase(page, "test") + const consoleHandler = (msg: { text(): string }) => { + const text = msg.text() + if (!text.includes("[e2e:error-boundary]")) return + if (healthPhase(page) === "cleanup") { + console.warn(`[e2e:error-boundary][cleanup-warning]\n${text}`) + return + } + boundary ||= text + console.log(text) + } + const pageErrorHandler = (err: Error) => { + console.log(`[e2e:pageerror] ${err.stack || err.message}`) + } + page.on("console", consoleHandler) + page.on("pageerror", pageErrorHandler) + await use(page) + page.off("console", consoleHandler) + page.off("pageerror", pageErrorHandler) + if (boundary) throw new Error(boundary) + }, directory: [ async ({}, use) => { const directory = await getWorktree() @@ -48,21 +80,20 @@ export const test = base.extend({ const gotoSession = async (sessionID?: string) => { await page.goto(sessionPath(directory, sessionID)) - await expect(page.locator(promptSelector)).toBeVisible() + await waitSession(page, { directory, sessionID }) } await use(gotoSession) }, withProject: async ({ page }, use) => { await use(async (callback, options) => { const root = await createTestProject() - const slug = dirSlug(root) const sessions = new Map() const dirs = new Set() await seedStorage(page, { directory: root, extra: options?.extra }) const gotoSession = async (sessionID?: string) => { await page.goto(sessionPath(root, sessionID)) - await expect(page.locator(promptSelector)).toBeVisible() + await waitSession(page, { directory: root, sessionID }) const current = sessionIDFromUrl(page.url()) if (current) trackSession(current) } @@ -77,13 +108,16 @@ export const test = base.extend({ try { await gotoSession() + const slug = await waitSlug(page) return await callback({ directory: root, slug, gotoSession, trackSession, trackDirectory }) } finally { + setHealthPhase(page, "cleanup") await Promise.allSettled( Array.from(sessions, ([sessionID, directory]) => cleanupSession({ sessionID, directory })), ) await Promise.allSettled(Array.from(dirs, (directory) => cleanupTestProject(directory))) await cleanupTestProject(root) + setHealthPhase(page, "test") } }) }, diff --git a/packages/app/e2e/projects/projects-switch.spec.ts b/packages/app/e2e/projects/projects-switch.spec.ts index e9cbf868df..b46c1b407e 100644 --- a/packages/app/e2e/projects/projects-switch.spec.ts +++ b/packages/app/e2e/projects/projects-switch.spec.ts @@ -1,5 +1,4 @@ import { base64Decode } from "@opencode-ai/util/encode" -import type { Page } from "@playwright/test" import { test, expect } from "../fixtures" import { defocus, @@ -7,43 +6,14 @@ import { cleanupTestProject, openSidebar, sessionIDFromUrl, - waitDir, + setWorkspacesEnabled, + waitSession, + waitSessionSaved, waitSlug, } from "../actions" import { projectSwitchSelector, promptSelector, workspaceItemSelector, workspaceNewSessionSelector } from "../selectors" import { dirSlug, resolveDirectory } from "../utils" -async function workspaces(page: Page, directory: string, enabled: boolean) { - await page.evaluate( - ({ directory, enabled }: { directory: string; enabled: boolean }) => { - const key = "opencode.global.dat:layout" - const raw = localStorage.getItem(key) - const data = raw ? JSON.parse(raw) : {} - const sidebar = data.sidebar && typeof data.sidebar === "object" ? data.sidebar : {} - const current = - sidebar.workspaces && typeof sidebar.workspaces === "object" && !Array.isArray(sidebar.workspaces) - ? sidebar.workspaces - : {} - const next = { ...current } - - if (enabled) next[directory] = true - if (!enabled) delete next[directory] - - localStorage.setItem( - key, - JSON.stringify({ - ...data, - sidebar: { - ...sidebar, - workspaces: next, - }, - }), - ) - }, - { directory, enabled }, - ) -} - test("can switch between projects from sidebar", async ({ page, withProject }) => { await page.setViewportSize({ width: 1400, height: 800 }) @@ -84,9 +54,7 @@ test("switching back to a project opens the latest workspace session", async ({ await withProject( async ({ directory, slug, trackSession, trackDirectory }) => { await defocus(page) - await workspaces(page, directory, true) - await page.reload() - await expect(page.locator(promptSelector)).toBeVisible() + await setWorkspacesEnabled(page, slug, true) await openSidebar(page) await expect(page.getByRole("button", { name: "New workspace" }).first()).toBeVisible() @@ -108,8 +76,7 @@ test("switching back to a project opens the latest workspace session", async ({ await expect(btn).toBeVisible() await btn.click({ force: true }) - await waitSlug(page) - await waitDir(page, space) + await waitSession(page, { directory: space }) // Create a session by sending a prompt const prompt = page.locator(promptSelector) @@ -123,6 +90,7 @@ test("switching back to a project opens the latest workspace session", async ({ const created = sessionIDFromUrl(page.url()) if (!created) throw new Error(`Failed to get session ID from url: ${page.url()}`) trackSession(created, space) + await waitSessionSaved(space, created) await expect(page).toHaveURL(new RegExp(`/${next}/session/${created}(?:[/?#]|$)`)) @@ -130,15 +98,14 @@ test("switching back to a project opens the latest workspace session", async ({ const otherButton = page.locator(projectSwitchSelector(otherSlug)).first() await expect(otherButton).toBeVisible() - await otherButton.click() - await expect(page).toHaveURL(new RegExp(`/${otherSlug}/session`)) + await otherButton.click({ force: true }) + await waitSession(page, { directory: other }) const rootButton = page.locator(projectSwitchSelector(slug)).first() await expect(rootButton).toBeVisible() - await rootButton.click() + await rootButton.click({ force: true }) - await waitDir(page, space) - await expect.poll(() => sessionIDFromUrl(page.url()) ?? "").toBe(created) + await waitSession(page, { directory: space, sessionID: created }) await expect(page).toHaveURL(new RegExp(`/session/${created}(?:[/?#]|$)`)) }, { extra: [other] }, diff --git a/packages/app/e2e/projects/workspace-new-session.spec.ts b/packages/app/e2e/projects/workspace-new-session.spec.ts index 0858f26273..3a7a6bbc22 100644 --- a/packages/app/e2e/projects/workspace-new-session.spec.ts +++ b/packages/app/e2e/projects/workspace-new-session.spec.ts @@ -1,6 +1,15 @@ import type { Page } from "@playwright/test" import { test, expect } from "../fixtures" -import { openSidebar, resolveSlug, sessionIDFromUrl, setWorkspacesEnabled, waitDir, waitSlug } from "../actions" +import { + openSidebar, + resolveSlug, + sessionIDFromUrl, + setWorkspacesEnabled, + waitDir, + waitSession, + waitSessionSaved, + waitSlug, +} from "../actions" import { promptSelector, workspaceItemSelector, workspaceNewSessionSelector } from "../selectors" import { createSdk } from "../utils" @@ -14,20 +23,7 @@ function button(space: { slug: string; raw: string }) { async function waitWorkspaceReady(page: Page, space: { slug: string; raw: string }) { await openSidebar(page) - await expect - .poll( - async () => { - const row = page.locator(item(space)).first() - try { - await row.hover({ timeout: 500 }) - return true - } catch { - return false - } - }, - { timeout: 60_000 }, - ) - .toBe(true) + await expect(page.locator(item(space)).first()).toBeVisible({ timeout: 60_000 }) } async function createWorkspace(page: Page, root: string, seen: string[]) { @@ -49,7 +45,8 @@ async function openWorkspaceNewSession(page: Page, space: { slug: string; raw: s await expect(next).toBeVisible() await next.click({ force: true }) - return waitDir(page, space.directory) + await waitSession(page, { directory: space.directory }) + await expect.poll(() => sessionIDFromUrl(page.url()) ?? "").toBe("") } async function createSessionFromWorkspace( @@ -57,39 +54,28 @@ async function createSessionFromWorkspace( space: { slug: string; raw: string; directory: string }, text: string, ) { - const next = await openWorkspaceNewSession(page, space) + await openWorkspaceNewSession(page, space) const prompt = page.locator(promptSelector) await expect(prompt).toBeVisible() - await expect(prompt).toBeEditable() - await prompt.click() - await expect(prompt).toBeFocused() await prompt.fill(text) - await expect.poll(async () => ((await prompt.textContent()) ?? "").trim()).toContain(text) - await prompt.press("Enter") - - await waitDir(page, next.directory) - await expect.poll(() => sessionIDFromUrl(page.url()) ?? "", { timeout: 30_000 }).not.toBe("") + await page.keyboard.press("Enter") + await expect.poll(() => sessionIDFromUrl(page.url()) ?? "", { timeout: 15_000 }).not.toBe("") const sessionID = sessionIDFromUrl(page.url()) if (!sessionID) throw new Error(`Failed to parse session id from url: ${page.url()}`) - await expect(page).toHaveURL(new RegExp(`/session/${sessionID}(?:[/?#]|$)`)) - return { sessionID, slug: next.slug } -} -async function sessionDirectory(directory: string, sessionID: string) { - const info = await createSdk(directory) - .session.get({ sessionID }) - .then((x) => x.data) + await waitSessionSaved(space.directory, sessionID) + await createSdk(space.directory) + .session.abort({ sessionID }) .catch(() => undefined) - if (!info) return "" - return info.directory + return sessionID } test("new sessions from sidebar workspace actions stay in selected workspace", async ({ page, withProject }) => { await page.setViewportSize({ width: 1400, height: 800 }) - await withProject(async ({ directory, slug: root, trackSession, trackDirectory }) => { + await withProject(async ({ slug: root, trackDirectory, trackSession }) => { await openSidebar(page) await setWorkspacesEnabled(page, root, true) @@ -101,17 +87,8 @@ test("new sessions from sidebar workspace actions stay in selected workspace", a trackDirectory(second.directory) await waitWorkspaceReady(page, second) - const firstSession = await createSessionFromWorkspace(page, first.slug, `workspace one ${Date.now()}`) - trackSession(firstSession.sessionID, first.directory) - - const secondSession = await createSessionFromWorkspace(page, second.slug, `workspace two ${Date.now()}`) - trackSession(secondSession.sessionID, second.directory) - - const thirdSession = await createSessionFromWorkspace(page, first.slug, `workspace one again ${Date.now()}`) - trackSession(thirdSession.sessionID, first.directory) - - await expect.poll(() => sessionDirectory(first.directory, firstSession.sessionID)).toBe(first.directory) - await expect.poll(() => sessionDirectory(second.directory, secondSession.sessionID)).toBe(second.directory) - await expect.poll(() => sessionDirectory(first.directory, thirdSession.sessionID)).toBe(first.directory) + trackSession(await createSessionFromWorkspace(page, first, `workspace one ${Date.now()}`), first.directory) + trackSession(await createSessionFromWorkspace(page, second, `workspace two ${Date.now()}`), second.directory) + trackSession(await createSessionFromWorkspace(page, first, `workspace one again ${Date.now()}`), first.directory) }) }) diff --git a/packages/app/e2e/session/session-model-persistence.spec.ts b/packages/app/e2e/session/session-model-persistence.spec.ts index 2c2e4e886d..b758a3b3d8 100644 --- a/packages/app/e2e/session/session-model-persistence.spec.ts +++ b/packages/app/e2e/session/session-model-persistence.spec.ts @@ -1,6 +1,14 @@ import type { Locator, Page } from "@playwright/test" import { test, expect } from "../fixtures" -import { openSidebar, resolveSlug, sessionIDFromUrl, setWorkspacesEnabled, waitSessionIdle, waitSlug } from "../actions" +import { + openSidebar, + resolveSlug, + sessionIDFromUrl, + setWorkspacesEnabled, + waitSession, + waitSessionIdle, + waitSlug, +} from "../actions" import { promptAgentSelector, promptModelSelector, @@ -29,8 +37,6 @@ const text = async (locator: Locator) => ((await locator.textContent()) ?? "").t const modelKey = (state: Probe | null) => (state?.model ? `${state.model.providerID}:${state.model.modelID}` : null) -const dirKey = (state: Probe | null) => state?.dir ?? "" - async function probe(page: Page): Promise { return page.evaluate(() => { const win = window as Window & { @@ -44,21 +50,6 @@ async function probe(page: Page): Promise { }) } -async function currentDir(page: Page) { - let hit = "" - await expect - .poll( - async () => { - const next = dirKey(await probe(page)) - if (next) hit = next - return next - }, - { timeout: 30_000 }, - ) - .not.toBe("") - return hit -} - async function read(page: Page): Promise