From 52a7a04ad807acc577672f379e8e0cb327602e9d Mon Sep 17 00:00:00 2001 From: Dax Date: Thu, 19 Mar 2026 21:17:06 -0400 Subject: [PATCH 01/12] 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 02/12] 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 03/12] 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 04/12] 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 05/12] 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 06/12] 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 07/12] 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 08/12] 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 09/12] 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