From e36bc20f844fe3aa1ee581502cc921ebba072d3d Mon Sep 17 00:00:00 2001 From: James Long Date: Tue, 12 May 2026 00:30:03 -0400 Subject: [PATCH 01/70] fix(tui): fix flicker by avoiding redundant workspace session sync (#26997) --- packages/opencode/src/cli/cmd/tui/context/sync.tsx | 5 ----- packages/opencode/src/cli/cmd/tui/routes/session/index.tsx | 3 ++- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/context/sync.tsx b/packages/opencode/src/cli/cmd/tui/context/sync.tsx index 76b1807abd..31104ddd9c 100644 --- a/packages/opencode/src/cli/cmd/tui/context/sync.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/sync.tsx @@ -113,7 +113,6 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ const kv = useKV() const fullSyncedSessions = new Set() - let syncedWorkspace = project.workspace.current() function sessionListQuery(): { scope?: "project"; path?: string } { if (!kv.get("session_directory_filter_enabled", true)) return { scope: "project" } @@ -378,10 +377,6 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ async function bootstrap(input: { fatal?: boolean } = {}) { const fatal = input.fatal ?? true const workspace = project.workspace.current() - if (workspace !== syncedWorkspace) { - fullSyncedSessions.clear() - syncedWorkspace = workspace - } const projectPromise = project.sync() const sessionListPromise = projectPromise.then(() => listSessions()) diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index b2ee3af622..3e966d9a58 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -10,6 +10,7 @@ import { onMount, Show, Switch, + untrack, useContext, } from "solid-js" import { Dynamic } from "solid-js/web" @@ -242,7 +243,7 @@ export function Session() { createEffect(() => { const sessionID = route.sessionID void (async () => { - const previousWorkspace = project.workspace.current() + const previousWorkspace = untrack(() => project.workspace.current()) const result = await sdk.client.session.get({ sessionID }, { throwOnError: true }) if (!result.data) { toast.show({ From 36d40fee4dec052e5c81664390e61a74705dfa13 Mon Sep 17 00:00:00 2001 From: Dax Date: Tue, 12 May 2026 01:18:57 -0400 Subject: [PATCH 02/70] Track session usage totals (#26644) --- .opencode/opencode.jsonc | 6 +- .../migration.sql | 6 + .../snapshot.json | 1591 +++++++++++++++++ packages/opencode/src/cli/cmd/stats.ts | 12 +- .../cli/cmd/tui/component/prompt/index.tsx | 3 +- .../opencode/src/cli/cmd/tui/context/sync.tsx | 3 +- .../tui/feature-plugins/sidebar/context.tsx | 3 +- .../opencode/src/cli/cmd/tui/plugin/api.tsx | 3 + .../tui/routes/session/subagent-footer.tsx | 2 +- packages/opencode/src/data-migration.ts | 100 +- packages/opencode/src/session/projectors.ts | 51 + packages/opencode/src/session/session.sql.ts | 10 +- packages/opencode/src/session/session.ts | 34 + .../opencode/src/storage/json-migration.ts | 6 + packages/opencode/src/v2/session.ts | 20 + packages/opencode/test/effect/runner.test.ts | 13 +- packages/opencode/test/fixture/tui-plugin.ts | 1 + .../test/session/session-schema.test.ts | 2 + packages/plugin/src/tui.ts | 2 + packages/sdk/js/src/v2/gen/types.gen.ts | 40 + 20 files changed, 1882 insertions(+), 26 deletions(-) create mode 100644 packages/opencode/migration/20260510033149_session_usage/migration.sql create mode 100644 packages/opencode/migration/20260510033149_session_usage/snapshot.json diff --git a/.opencode/opencode.jsonc b/.opencode/opencode.jsonc index dab531d337..0ae2fbe26b 100644 --- a/.opencode/opencode.jsonc +++ b/.opencode/opencode.jsonc @@ -1,11 +1,7 @@ { "$schema": "https://opencode.ai/config.json", "provider": {}, - "permission": { - "edit": { - "packages/opencode/migration/*": "ask", - }, - }, + "permission": {}, "mcp": {}, "tools": { "github-triage": false, diff --git a/packages/opencode/migration/20260510033149_session_usage/migration.sql b/packages/opencode/migration/20260510033149_session_usage/migration.sql new file mode 100644 index 0000000000..68e12aad09 --- /dev/null +++ b/packages/opencode/migration/20260510033149_session_usage/migration.sql @@ -0,0 +1,6 @@ +ALTER TABLE `session` ADD `cost` real DEFAULT 0 NOT NULL;--> statement-breakpoint +ALTER TABLE `session` ADD `tokens_input` integer DEFAULT 0 NOT NULL;--> statement-breakpoint +ALTER TABLE `session` ADD `tokens_output` integer DEFAULT 0 NOT NULL;--> statement-breakpoint +ALTER TABLE `session` ADD `tokens_reasoning` integer DEFAULT 0 NOT NULL;--> statement-breakpoint +ALTER TABLE `session` ADD `tokens_cache_read` integer DEFAULT 0 NOT NULL;--> statement-breakpoint +ALTER TABLE `session` ADD `tokens_cache_write` integer DEFAULT 0 NOT NULL; diff --git a/packages/opencode/migration/20260510033149_session_usage/snapshot.json b/packages/opencode/migration/20260510033149_session_usage/snapshot.json new file mode 100644 index 0000000000..4ec5dbc52c --- /dev/null +++ b/packages/opencode/migration/20260510033149_session_usage/snapshot.json @@ -0,0 +1,1591 @@ +{ + "version": "7", + "dialect": "sqlite", + "id": "be5eae31-b7f8-4292-8827-c36a524abd1b", + "prevIds": [ + "630a93f2-c6c6-4191-a351-868d8f3a05d4" + ], + "ddl": [ + { + "name": "account_state", + "entityType": "tables" + }, + { + "name": "account", + "entityType": "tables" + }, + { + "name": "control_account", + "entityType": "tables" + }, + { + "name": "workspace", + "entityType": "tables" + }, + { + "name": "project", + "entityType": "tables" + }, + { + "name": "message", + "entityType": "tables" + }, + { + "name": "part", + "entityType": "tables" + }, + { + "name": "permission", + "entityType": "tables" + }, + { + "name": "session_message", + "entityType": "tables" + }, + { + "name": "session", + "entityType": "tables" + }, + { + "name": "todo", + "entityType": "tables" + }, + { + "name": "session_share", + "entityType": "tables" + }, + { + "name": "event_sequence", + "entityType": "tables" + }, + { + "name": "event", + "entityType": "tables" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "account_state" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "active_account_id", + "entityType": "columns", + "table": "account_state" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "active_org_id", + "entityType": "columns", + "table": "account_state" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "email", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "url", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "access_token", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "refresh_token", + "entityType": "columns", + "table": "account" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "token_expiry", + "entityType": "columns", + "table": "account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "email", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "url", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "access_token", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "refresh_token", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "token_expiry", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "active", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "type", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": "''", + "generated": null, + "name": "name", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "branch", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "directory", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "extra", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "project_id", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_used", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "worktree", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "vcs", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "name", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "icon_url", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "icon_url_override", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "icon_color", + "entityType": "columns", + "table": "project" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "project" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "project" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_initialized", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "sandboxes", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "commands", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "message" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "message" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "message" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "message" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "message" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "part" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "message_id", + "entityType": "columns", + "table": "part" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "part" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "part" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "part" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "part" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "project_id", + "entityType": "columns", + "table": "permission" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "permission" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "permission" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "permission" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "session_message" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "session_message" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "type", + "entityType": "columns", + "table": "session_message" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "session_message" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "session_message" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "session_message" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "project_id", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "parent_id", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "slug", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "directory", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "path", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "title", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "version", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "share_url", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "summary_additions", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "summary_deletions", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "summary_files", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "summary_diffs", + "entityType": "columns", + "table": "session" + }, + { + "type": "real", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "cost", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "tokens_input", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "tokens_output", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "tokens_reasoning", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "tokens_cache_read", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "tokens_cache_write", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "revert", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "permission", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "agent", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "model", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_compacting", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_archived", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "todo" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "content", + "entityType": "columns", + "table": "todo" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "status", + "entityType": "columns", + "table": "todo" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "priority", + "entityType": "columns", + "table": "todo" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "position", + "entityType": "columns", + "table": "todo" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "todo" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "todo" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "secret", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "url", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "aggregate_id", + "entityType": "columns", + "table": "event_sequence" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "seq", + "entityType": "columns", + "table": "event_sequence" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "owner_id", + "entityType": "columns", + "table": "event_sequence" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "event" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "aggregate_id", + "entityType": "columns", + "table": "event" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "seq", + "entityType": "columns", + "table": "event" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "type", + "entityType": "columns", + "table": "event" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "event" + }, + { + "columns": [ + "active_account_id" + ], + "tableTo": "account", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "nameExplicit": false, + "name": "fk_account_state_active_account_id_account_id_fk", + "entityType": "fks", + "table": "account_state" + }, + { + "columns": [ + "project_id" + ], + "tableTo": "project", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_workspace_project_id_project_id_fk", + "entityType": "fks", + "table": "workspace" + }, + { + "columns": [ + "session_id" + ], + "tableTo": "session", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_message_session_id_session_id_fk", + "entityType": "fks", + "table": "message" + }, + { + "columns": [ + "message_id" + ], + "tableTo": "message", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_part_message_id_message_id_fk", + "entityType": "fks", + "table": "part" + }, + { + "columns": [ + "project_id" + ], + "tableTo": "project", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_permission_project_id_project_id_fk", + "entityType": "fks", + "table": "permission" + }, + { + "columns": [ + "session_id" + ], + "tableTo": "session", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_session_message_session_id_session_id_fk", + "entityType": "fks", + "table": "session_message" + }, + { + "columns": [ + "project_id" + ], + "tableTo": "project", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_session_project_id_project_id_fk", + "entityType": "fks", + "table": "session" + }, + { + "columns": [ + "session_id" + ], + "tableTo": "session", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_todo_session_id_session_id_fk", + "entityType": "fks", + "table": "todo" + }, + { + "columns": [ + "session_id" + ], + "tableTo": "session", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_session_share_session_id_session_id_fk", + "entityType": "fks", + "table": "session_share" + }, + { + "columns": [ + "aggregate_id" + ], + "tableTo": "event_sequence", + "columnsTo": [ + "aggregate_id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_event_aggregate_id_event_sequence_aggregate_id_fk", + "entityType": "fks", + "table": "event" + }, + { + "columns": [ + "email", + "url" + ], + "nameExplicit": false, + "name": "control_account_pk", + "entityType": "pks", + "table": "control_account" + }, + { + "columns": [ + "session_id", + "position" + ], + "nameExplicit": false, + "name": "todo_pk", + "entityType": "pks", + "table": "todo" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "account_state_pk", + "table": "account_state", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "account_pk", + "table": "account", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "workspace_pk", + "table": "workspace", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "project_pk", + "table": "project", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "message_pk", + "table": "message", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "part_pk", + "table": "part", + "entityType": "pks" + }, + { + "columns": [ + "project_id" + ], + "nameExplicit": false, + "name": "permission_pk", + "table": "permission", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "session_message_pk", + "table": "session_message", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "session_pk", + "table": "session", + "entityType": "pks" + }, + { + "columns": [ + "session_id" + ], + "nameExplicit": false, + "name": "session_share_pk", + "table": "session_share", + "entityType": "pks" + }, + { + "columns": [ + "aggregate_id" + ], + "nameExplicit": false, + "name": "event_sequence_pk", + "table": "event_sequence", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "event_pk", + "table": "event", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + }, + { + "value": "time_created", + "isExpression": false + }, + { + "value": "id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "message_session_time_created_id_idx", + "entityType": "indexes", + "table": "message" + }, + { + "columns": [ + { + "value": "message_id", + "isExpression": false + }, + { + "value": "id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "part_message_id_id_idx", + "entityType": "indexes", + "table": "part" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "part_session_idx", + "entityType": "indexes", + "table": "part" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_message_session_idx", + "entityType": "indexes", + "table": "session_message" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + }, + { + "value": "type", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_message_session_type_idx", + "entityType": "indexes", + "table": "session_message" + }, + { + "columns": [ + { + "value": "time_created", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_message_time_created_idx", + "entityType": "indexes", + "table": "session_message" + }, + { + "columns": [ + { + "value": "project_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_project_idx", + "entityType": "indexes", + "table": "session" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_workspace_idx", + "entityType": "indexes", + "table": "session" + }, + { + "columns": [ + { + "value": "parent_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_parent_idx", + "entityType": "indexes", + "table": "session" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "todo_session_idx", + "entityType": "indexes", + "table": "todo" + } + ], + "renames": [] +} \ No newline at end of file diff --git a/packages/opencode/src/cli/cmd/stats.ts b/packages/opencode/src/cli/cmd/stats.ts index 0124a26932..3dadea9dd0 100644 --- a/packages/opencode/src/cli/cmd/stats.ts +++ b/packages/opencode/src/cli/cmd/stats.ts @@ -164,8 +164,8 @@ const aggregateSessionStats = Effect.fn("Cli.stats.aggregate")(function* ( Effect.gen(function* () { const messages = yield* svc.messages({ sessionID: session.id }) - let sessionCost = 0 - let sessionTokens = { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } } + const sessionCost = session.cost ?? 0 + const sessionTokens = session.tokens ?? { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } } let sessionToolUsage: Record = {} let sessionModelUsage: Record< string, @@ -178,8 +178,6 @@ const aggregateSessionStats = Effect.fn("Cli.stats.aggregate")(function* ( for (const message of messages) { if (message.info.role === "assistant") { - sessionCost += message.info.cost || 0 - const modelKey = `${message.info.providerID}/${message.info.modelID}` if (!sessionModelUsage[modelKey]) { sessionModelUsage[modelKey] = { @@ -192,12 +190,6 @@ const aggregateSessionStats = Effect.fn("Cli.stats.aggregate")(function* ( sessionModelUsage[modelKey].cost += message.info.cost || 0 if (message.info.tokens) { - sessionTokens.input += message.info.tokens.input || 0 - sessionTokens.output += message.info.tokens.output || 0 - sessionTokens.reasoning += message.info.tokens.reasoning || 0 - sessionTokens.cache.read += message.info.tokens.cache?.read || 0 - sessionTokens.cache.write += message.info.tokens.cache?.write || 0 - sessionModelUsage[modelKey].tokens.input += message.info.tokens.input || 0 sessionModelUsage[modelKey].tokens.output += (message.info.tokens.output || 0) + (message.info.tokens.reasoning || 0) diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx index 3bbfc261b6..c80daf9cff 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -337,6 +337,7 @@ export function Prompt(props: PromptProps) { const usage = createMemo(() => { if (!props.sessionID) return + const session = sync.session.get(props.sessionID) const msg = sync.data.message[props.sessionID] ?? [] const last = msg.findLast((item): item is AssistantMessage => item.role === "assistant" && item.tokens.output > 0) if (!last) return @@ -347,7 +348,7 @@ export function Prompt(props: PromptProps) { const model = sync.data.provider.find((item) => item.id === last.providerID)?.models[last.modelID] const pct = model?.limit.context ? `${Math.round((tokens / model.limit.context) * 100)}%` : undefined - const cost = msg.reduce((sum, item) => sum + (item.role === "assistant" ? item.cost : 0), 0) + const cost = session?.cost ?? 0 return { context: pct ? `${Locale.number(tokens)} (${pct})` : Locale.number(tokens), cost: cost > 0 ? money.format(cost) : undefined, diff --git a/packages/opencode/src/cli/cmd/tui/context/sync.tsx b/packages/opencode/src/cli/cmd/tui/context/sync.tsx index 31104ddd9c..9f8a384f77 100644 --- a/packages/opencode/src/cli/cmd/tui/context/sync.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/sync.tsx @@ -345,7 +345,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ case "message.part.removed": { const parts = store.part[event.properties.messageID] const result = Binary.search(parts, event.properties.partID, (p) => p.id) - if (result.found) + if (result.found) { setStore( "part", event.properties.messageID, @@ -353,6 +353,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ draft.splice(result.index, 1) }), ) + } break } diff --git a/packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/context.tsx b/packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/context.tsx index b3cf2beb44..405e8c1458 100644 --- a/packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/context.tsx +++ b/packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/context.tsx @@ -13,7 +13,8 @@ const money = new Intl.NumberFormat("en-US", { function View(props: { api: TuiPluginApi; session_id: string }) { const theme = () => props.api.theme.current const msg = createMemo(() => props.api.state.session.messages(props.session_id)) - const cost = createMemo(() => msg().reduce((sum, item) => sum + (item.role === "assistant" ? item.cost : 0), 0)) + const session = createMemo(() => props.api.state.session.get(props.session_id)) + const cost = createMemo(() => session()?.cost ?? 0) const state = createMemo(() => { const last = msg().findLast((item): item is AssistantMessage => item.role === "assistant" && item.tokens.output > 0) diff --git a/packages/opencode/src/cli/cmd/tui/plugin/api.tsx b/packages/opencode/src/cli/cmd/tui/plugin/api.tsx index 54059f4a2d..8958a92853 100644 --- a/packages/opencode/src/cli/cmd/tui/plugin/api.tsx +++ b/packages/opencode/src/cli/cmd/tui/plugin/api.tsx @@ -147,6 +147,9 @@ function stateApi(sync: ReturnType): TuiPluginApi["state"] { count() { return sync.data.session.length }, + get(sessionID) { + return sync.session.get(sessionID) + }, diff(sessionID) { return (sync.data.session_diff[sessionID] ?? []).flatMap((item) => item.file === undefined ? [] : [{ ...item, file: item.file }], diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/subagent-footer.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/subagent-footer.tsx index 2a6813ffbe..f4a458b63d 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/subagent-footer.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/subagent-footer.tsx @@ -42,7 +42,7 @@ export function SubagentFooter() { const model = sync.data.provider.find((item) => item.id === last.providerID)?.models[last.modelID] const pct = model?.limit.context ? `${Math.round((tokens / model.limit.context) * 100)}%` : undefined - const cost = msg.reduce((sum, item) => sum + (item.role === "assistant" ? item.cost : 0), 0) + const cost = session()?.cost ?? 0 const money = new Intl.NumberFormat("en-US", { style: "currency", diff --git a/packages/opencode/src/data-migration.ts b/packages/opencode/src/data-migration.ts index 0a2973de5d..f2268b4c84 100644 --- a/packages/opencode/src/data-migration.ts +++ b/packages/opencode/src/data-migration.ts @@ -2,7 +2,9 @@ import { Context, Effect, Layer } from "effect" import { Database } from "./storage/db" import { DataMigrationTable } from "./data-migration.sql" import * as Log from "@opencode-ai/core/util/log" -import { eq } from "drizzle-orm" +import { and, asc, eq, gt, inArray, sql } from "drizzle-orm" +import { MessageTable, SessionTable } from "./session/session.sql" +import type { SessionID } from "./session/schema" export type Migration = { name: string @@ -18,7 +20,101 @@ export class Service extends Context.Service()("@opencode/Da export const layer = Layer.effect( Service, Effect.gen(function* () { - const migrations: Migration[] = [] + const migrations: Migration[] = [ + { + name: "session_usage_from_messages", + run: Effect.gen(function* () { + type Usage = { + cost: number + tokens: { input: number; output: number; reasoning: number; cache: { read: number; write: number } } + } + + for (let cursor: SessionID | undefined, page = 1; ; page++) { + const next = yield* Effect.gen(function* () { + const sessions = yield* Effect.sync(() => + Database.use((db) => + db + .select({ id: SessionTable.id }) + .from(SessionTable) + .where(cursor ? gt(SessionTable.id, cursor) : undefined) + .orderBy(asc(SessionTable.id)) + .limit(100) + .all(), + ), + ) + if (sessions.length === 0) return + + yield* Effect.sync(() => + Database.transaction((db) => { + const usageBySession = new Map( + sessions.map((session) => [ + session.id, + { cost: 0, tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } } }, + ]), + ) + + for (const row of db + .select({ + session_id: MessageTable.session_id, + cost: sql`coalesce(sum(coalesce(json_extract(${MessageTable.data}, '$.cost'), 0)), 0)`, + tokens_input: sql`coalesce(sum(coalesce(json_extract(${MessageTable.data}, '$.tokens.input'), 0)), 0)`, + tokens_output: sql`coalesce(sum(coalesce(json_extract(${MessageTable.data}, '$.tokens.output'), 0)), 0)`, + tokens_reasoning: sql`coalesce(sum(coalesce(json_extract(${MessageTable.data}, '$.tokens.reasoning'), 0)), 0)`, + tokens_cache_read: sql`coalesce(sum(coalesce(json_extract(${MessageTable.data}, '$.tokens.cache.read'), 0)), 0)`, + tokens_cache_write: sql`coalesce(sum(coalesce(json_extract(${MessageTable.data}, '$.tokens.cache.write'), 0)), 0)`, + }) + .from(MessageTable) + .where( + and( + inArray(MessageTable.session_id, sessions.map((session) => session.id)), + sql`json_extract(${MessageTable.data}, '$.role') = 'assistant'`, + ), + ) + .groupBy(MessageTable.session_id) + .all()) { + const current = usageBySession.get(row.session_id) + if (!current) continue + current.cost = row.cost + current.tokens.input = row.tokens_input + current.tokens.output = row.tokens_output + current.tokens.reasoning = row.tokens_reasoning + current.tokens.cache.read = row.tokens_cache_read + current.tokens.cache.write = row.tokens_cache_write + } + + for (const [sessionID, value] of usageBySession) { + db.update(SessionTable) + .set({ + cost: value.cost, + tokens_input: value.tokens.input, + tokens_output: value.tokens.output, + tokens_reasoning: value.tokens.reasoning, + tokens_cache_read: value.tokens.cache.read, + tokens_cache_write: value.tokens.cache.write, + }) + .where(eq(SessionTable.id, sessionID)) + .run() + } + }), + ) + + return sessions.at(-1)?.id + }).pipe( + Effect.withSpan("DataMigration.sessionUsage.page", { + attributes: { + "data_migration.name": "session_usage_from_messages", + "data_migration.page": page, + "data_migration.cursor": cursor ?? "", + }, + }), + ) + if (!next) return + cursor = next + yield* Effect.sleep("10 millis") + } + }), + }, + ] yield* Effect.gen(function* () { if (migrations.length === 0) return diff --git a/packages/opencode/src/session/projectors.ts b/packages/opencode/src/session/projectors.ts index 93acd4546d..8b5cc2bdcc 100644 --- a/packages/opencode/src/session/projectors.ts +++ b/packages/opencode/src/session/projectors.ts @@ -1,6 +1,8 @@ import { NotFoundError } from "@/storage/storage" import { eq } from "drizzle-orm" import { and } from "drizzle-orm" +import { sql } from "drizzle-orm" +import type { TxOrDb } from "@/storage/db" import { SyncEvent } from "@/sync" import * as Session from "./session" import { MessageV2 } from "./message-v2" @@ -19,6 +21,28 @@ function foreign(err: unknown) { export type DeepPartial = T extends object ? { [K in keyof T]?: DeepPartial | null } : T +type Usage = Pick + +function usage(part: MessageV2.Part | (typeof PartTable.$inferSelect)["data"]): Usage | undefined { + if (part.type !== "step-finish") return undefined + if (!("cost" in part) || !("tokens" in part)) return undefined + return { cost: part.cost, tokens: part.tokens } +} + +function applyUsage(db: TxOrDb, sessionID: Session.Info["id"], value: Usage, sign = 1) { + db.update(SessionTable) + .set({ + cost: sql`${SessionTable.cost} + ${value.cost * sign}`, + tokens_input: sql`${SessionTable.tokens_input} + ${value.tokens.input * sign}`, + tokens_output: sql`${SessionTable.tokens_output} + ${value.tokens.output * sign}`, + tokens_reasoning: sql`${SessionTable.tokens_reasoning} + ${value.tokens.reasoning * sign}`, + tokens_cache_read: sql`${SessionTable.tokens_cache_read} + ${value.tokens.cache.read * sign}`, + tokens_cache_write: sql`${SessionTable.tokens_cache_write} + ${value.tokens.cache.write * sign}`, + }) + .where(eq(SessionTable.id, sessionID)) + .run() +} + function grab( obj: T, field1: K1, @@ -54,6 +78,12 @@ export function toPartialRow(info: DeepPartial) { summary_deletions: grab(info, "summary", (v) => grab(v, "deletions")), summary_files: grab(info, "summary", (v) => grab(v, "files")), summary_diffs: grab(info, "summary", (v) => grab(v, "diffs")), + cost: grab(info, "cost"), + tokens_input: grab(info, "tokens", (v) => grab(v, "input")), + tokens_output: grab(info, "tokens", (v) => grab(v, "output")), + tokens_reasoning: grab(info, "tokens", (v) => grab(v, "reasoning")), + tokens_cache_read: grab(info, "tokens", (v) => grab(v, "cache", (cache) => grab(cache, "read"))), + tokens_cache_write: grab(info, "tokens", (v) => grab(v, "cache", (cache) => grab(cache, "write"))), revert: grab(info, "revert"), permission: grab(info, "permission"), time_created: grab(info, "time", (v) => grab(v, "created")), @@ -112,12 +142,28 @@ export default [ }), SyncEvent.project(MessageV2.Event.Removed, (db, data) => { + for (const row of db + .select() + .from(PartTable) + .where(and(eq(PartTable.message_id, data.messageID), eq(PartTable.session_id, data.sessionID))) + .all()) { + const previous = usage(row.data) + if (previous) applyUsage(db, data.sessionID, previous, -1) + } db.delete(MessageTable) .where(and(eq(MessageTable.id, data.messageID), eq(MessageTable.session_id, data.sessionID))) .run() }), SyncEvent.project(MessageV2.Event.PartRemoved, (db, data) => { + const row = db + .select() + .from(PartTable) + .where(and(eq(PartTable.id, data.partID), eq(PartTable.session_id, data.sessionID))) + .get() + const previous = row && usage(row.data) + if (previous) applyUsage(db, data.sessionID, previous, -1) + db.delete(PartTable) .where(and(eq(PartTable.id, data.partID), eq(PartTable.session_id, data.sessionID))) .run() @@ -125,6 +171,7 @@ export default [ SyncEvent.project(MessageV2.Event.PartUpdated, (db, data) => { const { id, messageID, sessionID, ...rest } = data.part + const row = db.select().from(PartTable).where(eq(PartTable.id, id)).get() try { db.insert(PartTable) @@ -137,6 +184,10 @@ export default [ }) .onConflictDoUpdate({ target: PartTable.id, set: { data: rest } }) .run() + const previous = row && usage(row.data) + const next = usage(data.part) + if (previous) applyUsage(db, row.session_id, previous, -1) + if (next) applyUsage(db, sessionID, next) } catch (err) { if (!foreign(err)) throw err log.warn("ignored late part update", { partID: id, messageID, sessionID }) diff --git a/packages/opencode/src/session/session.sql.ts b/packages/opencode/src/session/session.sql.ts index 421fa68694..18d041f458 100644 --- a/packages/opencode/src/session/session.sql.ts +++ b/packages/opencode/src/session/session.sql.ts @@ -1,4 +1,4 @@ -import { sqliteTable, text, integer, index, primaryKey } from "drizzle-orm/sqlite-core" +import { sqliteTable, text, integer, index, primaryKey, real } from "drizzle-orm/sqlite-core" import { ProjectTable } from "../project/project.sql" import type { MessageV2 } from "./message-v2" import type { SessionMessage } from "../v2/session-message" @@ -10,7 +10,7 @@ import type { WorkspaceID } from "../control-plane/schema" import { Timestamps } from "../storage/schema.sql" type PartData = Omit -type InfoData = Omit +type InfoData = T extends unknown ? Omit : never type SessionMessageData = Omit<(typeof SessionMessage.Message)["Encoded"], "type" | "id"> export const SessionTable = sqliteTable( @@ -33,6 +33,12 @@ export const SessionTable = sqliteTable( summary_deletions: integer(), summary_files: integer(), summary_diffs: text({ mode: "json" }).$type(), + cost: real().notNull().default(0), + tokens_input: integer().notNull().default(0), + tokens_output: integer().notNull().default(0), + tokens_reasoning: integer().notNull().default(0), + tokens_cache_read: integer().notNull().default(0), + tokens_cache_write: integer().notNull().default(0), revert: text({ mode: "json" }).$type<{ messageID: MessageID; partID?: PartID; snapshot?: string; diff?: string }>(), permission: text({ mode: "json" }).$type(), agent: text(), diff --git a/packages/opencode/src/session/session.ts b/packages/opencode/src/session/session.ts index 92b4329e6f..eff027579a 100644 --- a/packages/opencode/src/session/session.ts +++ b/packages/opencode/src/session/session.ts @@ -87,6 +87,16 @@ export function fromRow(row: SessionRow): Info { : undefined, version: row.version, summary, + cost: row.cost, + tokens: { + input: row.tokens_input, + output: row.tokens_output, + reasoning: row.tokens_reasoning, + cache: { + read: row.tokens_cache_read, + write: row.tokens_cache_write, + }, + }, share, revert, permission: row.permission ?? undefined, @@ -117,6 +127,12 @@ export function toRow(info: Info) { summary_deletions: info.summary?.deletions, summary_files: info.summary?.files, summary_diffs: info.summary?.diffs, + cost: info.cost ?? 0, + tokens_input: (info.tokens ?? EmptyTokens).input, + tokens_output: (info.tokens ?? EmptyTokens).output, + tokens_reasoning: (info.tokens ?? EmptyTokens).reasoning, + tokens_cache_read: (info.tokens ?? EmptyTokens).cache.read, + tokens_cache_write: (info.tokens ?? EmptyTokens).cache.write, revert: info.revert ?? null, permission: info.permission, time_created: info.time.created, @@ -147,6 +163,18 @@ const Summary = Schema.Struct({ diffs: optionalOmitUndefined(Schema.Array(Snapshot.FileDiff)), }) +const Tokens = Schema.Struct({ + input: Schema.Finite, + output: Schema.Finite, + reasoning: Schema.Finite, + cache: Schema.Struct({ + read: Schema.Finite, + write: Schema.Finite, + }), +}) + +const EmptyTokens = { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } } + const Share = Schema.Struct({ url: Schema.String, }) @@ -184,6 +212,8 @@ export const Info = Schema.Struct({ path: optionalOmitUndefined(Schema.String), parentID: optionalOmitUndefined(SessionID), summary: optionalOmitUndefined(Summary), + cost: optionalOmitUndefined(Schema.Finite), + tokens: optionalOmitUndefined(Tokens), share: optionalOmitUndefined(Share), title: Schema.String, agent: optionalOmitUndefined(Schema.String), @@ -281,6 +311,8 @@ const UpdatedInfo = Schema.Struct({ path: Schema.optional(Schema.NullOr(Schema.String)), parentID: Schema.optional(Schema.NullOr(SessionID)), summary: Schema.optional(Schema.NullOr(Summary)), + cost: Schema.optional(Schema.Finite), + tokens: Schema.optional(Tokens), share: Schema.optional(UpdatedShare), title: Schema.optional(Schema.NullOr(Schema.String)), agent: Schema.optional(Schema.NullOr(Schema.String)), @@ -503,6 +535,8 @@ export const layer: Layer.Layer | NodeSQLiteDatabase("Session.Info")({ path: optionalOmitUndefined(Schema.String), agent: optionalOmitUndefined(Schema.String), model: Modelv2.Ref.pipe(optionalOmitUndefined), + cost: Schema.Finite, + tokens: Schema.Struct({ + input: Schema.Finite, + output: Schema.Finite, + reasoning: Schema.Finite, + cache: Schema.Struct({ + read: Schema.Finite, + write: Schema.Finite, + }), + }), time: Schema.Struct({ created: V2Schema.DateTimeUtcFromMillis, updated: V2Schema.DateTimeUtcFromMillis, @@ -136,6 +146,16 @@ export const layer = Layer.effect( variant: Modelv2.VariantID.make(row.model.variant ?? "default"), } : undefined, + cost: row.cost, + tokens: { + input: row.tokens_input, + output: row.tokens_output, + reasoning: row.tokens_reasoning, + cache: { + read: row.tokens_cache_read, + write: row.tokens_cache_write, + }, + }, time: { created: DateTime.makeUnsafe(row.time_created), updated: DateTime.makeUnsafe(row.time_updated), diff --git a/packages/opencode/test/effect/runner.test.ts b/packages/opencode/test/effect/runner.test.ts index 0f5783bfc4..c37cb276b6 100644 --- a/packages/opencode/test/effect/runner.test.ts +++ b/packages/opencode/test/effect/runner.test.ts @@ -1,5 +1,5 @@ import { describe, expect } from "bun:test" -import { Deferred, Effect, Exit, Fiber, Ref, Scope } from "effect" +import { Deferred, Effect, Exit, Fiber, Latch, Ref, Scope } from "effect" import { Runner } from "@/effect/runner" import { it } from "../lib/effect" @@ -352,11 +352,18 @@ describe("Runner", () => { Effect.gen(function* () { const s = yield* Scope.Scope const runner = Runner.make(s, { onInterrupt: Effect.succeed("interrupted") }) + const ready = yield* Latch.make() const sh = yield* runner - .startShell(Effect.never.pipe(Effect.ensuring(Effect.die("boom")), Effect.as("ignored"))) + .startShell( + Effect.gen(function* () { + yield* ready.open + return yield* Effect.never.pipe(Effect.as("ignored")) + }).pipe(Effect.ensuring(Effect.die("boom"))), + ready, + ) .pipe(Effect.forkChild) - yield* Effect.sleep("10 millis") + yield* ready.await.pipe(Effect.timeout("250 millis")) yield* runner.cancel expect(Exit.isFailure(yield* Fiber.await(sh))).toBe(true) diff --git a/packages/opencode/test/fixture/tui-plugin.ts b/packages/opencode/test/fixture/tui-plugin.ts index 62a3ae6e6b..3d894bd0ae 100644 --- a/packages/opencode/test/fixture/tui-plugin.ts +++ b/packages/opencode/test/fixture/tui-plugin.ts @@ -292,6 +292,7 @@ export function createTuiPluginApi(opts: Opts = {}): HostPluginApi { }, session: { count: opts.state?.session?.count ?? (() => 0), + get: opts.state?.session?.get ?? (() => undefined), diff: opts.state?.session?.diff ?? (() => []), todo: opts.state?.session?.todo ?? (() => []), messages: opts.state?.session?.messages ?? (() => []), diff --git a/packages/opencode/test/session/session-schema.test.ts b/packages/opencode/test/session/session-schema.test.ts index 38531d15b4..906414fdbe 100644 --- a/packages/opencode/test/session/session-schema.test.ts +++ b/packages/opencode/test/session/session-schema.test.ts @@ -12,6 +12,8 @@ const info = { directory: "/tmp/opencode", parentID: undefined, summary: undefined, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, share: undefined, title: "Test session", version: "1.0.0", diff --git a/packages/plugin/src/tui.ts b/packages/plugin/src/tui.ts index 851b0476e5..d4c2261b28 100644 --- a/packages/plugin/src/tui.ts +++ b/packages/plugin/src/tui.ts @@ -11,6 +11,7 @@ import type { Provider, PermissionRequest, QuestionRequest, + Session, SessionStatus, TextPart, Config as SdkConfig, @@ -310,6 +311,7 @@ export type TuiState = { readonly vcs: { branch?: string } | undefined session: { count: () => number + get: (sessionID: string) => Session | undefined diff: (sessionID: string) => ReadonlyArray todo: (sessionID: string) => ReadonlyArray messages: (sessionID: string) => ReadonlyArray diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index c7a479f5ac..fd57836604 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -741,6 +741,16 @@ export type Session = { files: number diffs?: Array } + cost?: number + tokens?: { + input: number + output: number + reasoning: number + cache: { + read: number + write: number + } + } share?: { url: string } @@ -1430,6 +1440,16 @@ export type GlobalSession = { files: number diffs?: Array } + cost?: number + tokens?: { + input: number + output: number + reasoning: number + cache: { + read: number + write: number + } + } share?: { url: string } @@ -1893,6 +1913,16 @@ export type SyncEventSessionUpdated = { files: number diffs?: Array } | null + cost?: number | null + tokens?: { + input: number + output: number + reasoning: number + cache: { + read: number + write: number + } + } | null share?: { url?: string | null } @@ -3085,6 +3115,16 @@ export type SessionInfo = { providerID: string variant: string } + cost: number + tokens: { + input: number + output: number + reasoning: number + cache: { + read: number + write: number + } + } time: { created: number updated: number From ea6eabe1d93f6acbf8ebbcbee95eba61de1bb2cf Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Tue, 12 May 2026 05:20:22 +0000 Subject: [PATCH 03/70] chore: generate --- .../snapshot.json | 144 +++++------------- packages/opencode/src/data-migration.ts | 5 +- packages/sdk/openapi.json | 144 +++++++++++++++++- 3 files changed, 183 insertions(+), 110 deletions(-) diff --git a/packages/opencode/migration/20260510033149_session_usage/snapshot.json b/packages/opencode/migration/20260510033149_session_usage/snapshot.json index 4ec5dbc52c..ce5e56f48c 100644 --- a/packages/opencode/migration/20260510033149_session_usage/snapshot.json +++ b/packages/opencode/migration/20260510033149_session_usage/snapshot.json @@ -2,9 +2,7 @@ "version": "7", "dialect": "sqlite", "id": "be5eae31-b7f8-4292-8827-c36a524abd1b", - "prevIds": [ - "630a93f2-c6c6-4191-a351-868d8f3a05d4" - ], + "prevIds": ["630a93f2-c6c6-4191-a351-868d8f3a05d4"], "ddl": [ { "name": "account_state", @@ -1153,13 +1151,9 @@ "table": "event" }, { - "columns": [ - "active_account_id" - ], + "columns": ["active_account_id"], "tableTo": "account", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "NO ACTION", "onDelete": "SET NULL", "nameExplicit": false, @@ -1168,13 +1162,9 @@ "table": "account_state" }, { - "columns": [ - "project_id" - ], + "columns": ["project_id"], "tableTo": "project", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1183,13 +1173,9 @@ "table": "workspace" }, { - "columns": [ - "session_id" - ], + "columns": ["session_id"], "tableTo": "session", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1198,13 +1184,9 @@ "table": "message" }, { - "columns": [ - "message_id" - ], + "columns": ["message_id"], "tableTo": "message", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1213,13 +1195,9 @@ "table": "part" }, { - "columns": [ - "project_id" - ], + "columns": ["project_id"], "tableTo": "project", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1228,13 +1206,9 @@ "table": "permission" }, { - "columns": [ - "session_id" - ], + "columns": ["session_id"], "tableTo": "session", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1243,13 +1217,9 @@ "table": "session_message" }, { - "columns": [ - "project_id" - ], + "columns": ["project_id"], "tableTo": "project", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1258,13 +1228,9 @@ "table": "session" }, { - "columns": [ - "session_id" - ], + "columns": ["session_id"], "tableTo": "session", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1273,13 +1239,9 @@ "table": "todo" }, { - "columns": [ - "session_id" - ], + "columns": ["session_id"], "tableTo": "session", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1288,13 +1250,9 @@ "table": "session_share" }, { - "columns": [ - "aggregate_id" - ], + "columns": ["aggregate_id"], "tableTo": "event_sequence", - "columnsTo": [ - "aggregate_id" - ], + "columnsTo": ["aggregate_id"], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1303,128 +1261,98 @@ "table": "event" }, { - "columns": [ - "email", - "url" - ], + "columns": ["email", "url"], "nameExplicit": false, "name": "control_account_pk", "entityType": "pks", "table": "control_account" }, { - "columns": [ - "session_id", - "position" - ], + "columns": ["session_id", "position"], "nameExplicit": false, "name": "todo_pk", "entityType": "pks", "table": "todo" }, { - "columns": [ - "id" - ], + "columns": ["id"], "nameExplicit": false, "name": "account_state_pk", "table": "account_state", "entityType": "pks" }, { - "columns": [ - "id" - ], + "columns": ["id"], "nameExplicit": false, "name": "account_pk", "table": "account", "entityType": "pks" }, { - "columns": [ - "id" - ], + "columns": ["id"], "nameExplicit": false, "name": "workspace_pk", "table": "workspace", "entityType": "pks" }, { - "columns": [ - "id" - ], + "columns": ["id"], "nameExplicit": false, "name": "project_pk", "table": "project", "entityType": "pks" }, { - "columns": [ - "id" - ], + "columns": ["id"], "nameExplicit": false, "name": "message_pk", "table": "message", "entityType": "pks" }, { - "columns": [ - "id" - ], + "columns": ["id"], "nameExplicit": false, "name": "part_pk", "table": "part", "entityType": "pks" }, { - "columns": [ - "project_id" - ], + "columns": ["project_id"], "nameExplicit": false, "name": "permission_pk", "table": "permission", "entityType": "pks" }, { - "columns": [ - "id" - ], + "columns": ["id"], "nameExplicit": false, "name": "session_message_pk", "table": "session_message", "entityType": "pks" }, { - "columns": [ - "id" - ], + "columns": ["id"], "nameExplicit": false, "name": "session_pk", "table": "session", "entityType": "pks" }, { - "columns": [ - "session_id" - ], + "columns": ["session_id"], "nameExplicit": false, "name": "session_share_pk", "table": "session_share", "entityType": "pks" }, { - "columns": [ - "aggregate_id" - ], + "columns": ["aggregate_id"], "nameExplicit": false, "name": "event_sequence_pk", "table": "event_sequence", "entityType": "pks" }, { - "columns": [ - "id" - ], + "columns": ["id"], "nameExplicit": false, "name": "event_pk", "table": "event", @@ -1588,4 +1516,4 @@ } ], "renames": [] -} \ No newline at end of file +} diff --git a/packages/opencode/src/data-migration.ts b/packages/opencode/src/data-migration.ts index f2268b4c84..53e3196b7a 100644 --- a/packages/opencode/src/data-migration.ts +++ b/packages/opencode/src/data-migration.ts @@ -66,7 +66,10 @@ export const layer = Layer.effect( .from(MessageTable) .where( and( - inArray(MessageTable.session_id, sessions.map((session) => session.id)), + inArray( + MessageTable.session_id, + sessions.map((session) => session.id), + ), sql`json_extract(${MessageTable.data}, '$.role') = 'assistant'`, ), ) diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 3d452cc9c0..9850c83412 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -11018,6 +11018,38 @@ "required": ["additions", "deletions", "files"], "additionalProperties": false }, + "cost": { + "type": "number" + }, + "tokens": { + "type": "object", + "properties": { + "input": { + "type": "number" + }, + "output": { + "type": "number" + }, + "reasoning": { + "type": "number" + }, + "cache": { + "type": "object", + "properties": { + "read": { + "type": "number" + }, + "write": { + "type": "number" + } + }, + "required": ["read", "write"], + "additionalProperties": false + } + }, + "required": ["input", "output", "reasoning", "cache"], + "additionalProperties": false + }, "share": { "type": "object", "properties": { @@ -12891,6 +12923,38 @@ "required": ["additions", "deletions", "files"], "additionalProperties": false }, + "cost": { + "type": "number" + }, + "tokens": { + "type": "object", + "properties": { + "input": { + "type": "number" + }, + "output": { + "type": "number" + }, + "reasoning": { + "type": "number" + }, + "cache": { + "type": "object", + "properties": { + "read": { + "type": "number" + }, + "write": { + "type": "number" + } + }, + "required": ["read", "write"], + "additionalProperties": false + } + }, + "required": ["input", "output", "reasoning", "cache"], + "additionalProperties": false + }, "share": { "type": "object", "properties": { @@ -14389,6 +14453,52 @@ } ] }, + "cost": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + }, + "tokens": { + "anyOf": [ + { + "type": "object", + "properties": { + "input": { + "type": "number" + }, + "output": { + "type": "number" + }, + "reasoning": { + "type": "number" + }, + "cache": { + "type": "object", + "properties": { + "read": { + "type": "number" + }, + "write": { + "type": "number" + } + }, + "required": ["read", "write"], + "additionalProperties": false + } + }, + "required": ["input", "output", "reasoning", "cache"], + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, "share": { "type": "object", "properties": { @@ -18138,6 +18248,38 @@ "required": ["id", "providerID", "variant"], "additionalProperties": false }, + "cost": { + "type": "number" + }, + "tokens": { + "type": "object", + "properties": { + "input": { + "type": "number" + }, + "output": { + "type": "number" + }, + "reasoning": { + "type": "number" + }, + "cache": { + "type": "object", + "properties": { + "read": { + "type": "number" + }, + "write": { + "type": "number" + } + }, + "required": ["read", "write"], + "additionalProperties": false + } + }, + "required": ["input", "output", "reasoning", "cache"], + "additionalProperties": false + }, "time": { "type": "object", "properties": { @@ -18158,7 +18300,7 @@ "type": "string" } }, - "required": ["id", "projectID", "time", "title"], + "required": ["id", "projectID", "cost", "tokens", "time", "title"], "additionalProperties": false }, "SessionDelivery": { From 3992e2a76b3e0d064cc8250d5b6008d7dd5dbb4e Mon Sep 17 00:00:00 2001 From: Brendan Allan <14191578+Brendonovich@users.noreply.github.com> Date: Tue, 12 May 2026 14:43:07 +0800 Subject: [PATCH 04/70] feat(app): add ctrl/cmd+number keybinds to switch projects (#26280) --- .../app/src/components/dialog-select-file.tsx | 2 +- .../app/src/components/settings-keybinds.tsx | 2 ++ packages/app/src/context/command.tsx | 17 ++++++++------ packages/app/src/i18n/en.ts | 1 + packages/app/src/pages/layout.tsx | 22 +++++++++++++++++++ 5 files changed, 36 insertions(+), 8 deletions(-) diff --git a/packages/app/src/components/dialog-select-file.tsx b/packages/app/src/components/dialog-select-file.tsx index 63a321e46a..c5ac52919e 100644 --- a/packages/app/src/components/dialog-select-file.tsx +++ b/packages/app/src/components/dialog-select-file.tsx @@ -107,7 +107,7 @@ function createCommandEntries(props: { const allowed = createMemo(() => { if (props.filesOnly()) return [] return props.command.options.filter( - (option) => !option.disabled && !option.id.startsWith("suggested.") && option.id !== "file.open", + (option) => !option.disabled && !option.hidden && !option.id.startsWith("suggested.") && option.id !== "file.open", ) }) diff --git a/packages/app/src/components/settings-keybinds.tsx b/packages/app/src/components/settings-keybinds.tsx index 7d2dfaa636..149a0309b5 100644 --- a/packages/app/src/components/settings-keybinds.tsx +++ b/packages/app/src/components/settings-keybinds.tsx @@ -123,11 +123,13 @@ function listFor(command: CommandContext, map: KeybindMap, palette: string) { for (const opt of command.catalog) { if (opt.id.startsWith("suggested.")) continue + if (opt.hidden) continue out.set(opt.id, { title: opt.title, group: groupFor(opt.id) }) } for (const opt of command.options) { if (opt.id.startsWith("suggested.")) continue + if (opt.hidden) continue out.set(opt.id, { title: opt.title, group: groupFor(opt.id) }) } diff --git a/packages/app/src/context/command.tsx b/packages/app/src/context/command.tsx index d2238828c6..e979ad6a05 100644 --- a/packages/app/src/context/command.tsx +++ b/packages/app/src/context/command.tsx @@ -81,6 +81,7 @@ export interface CommandOption { slash?: string suggested?: boolean disabled?: boolean + hidden?: boolean onSelect?: (source?: "palette" | "keybind" | "slash") => void onHighlight?: () => (() => void) | void } @@ -93,6 +94,7 @@ export type CommandCatalogItem = { category?: string keybind?: KeybindConfig slash?: string + hidden?: boolean } export type CommandRegistration = { @@ -279,13 +281,14 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex setCatalog( registered().reduce((acc, opt) => { const id = actionId(opt.id) - acc[id] = { - title: opt.title, - description: opt.description, - category: opt.category, - keybind: opt.keybind, - slash: opt.slash, - } + if (opt.title) + acc[id] = { + title: opt.title, + description: opt.description, + category: opt.category, + keybind: opt.keybind, + slash: opt.slash, + } return acc }, {} as CommandCatalog), ) diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index 250d26edbe..a42bb62610 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -25,6 +25,7 @@ export const dict = { "command.project.open": "Open project", "command.project.previous": "Previous project", "command.project.next": "Next project", + "command.project.index": "Switch to project {{index}}", "command.provider.connect": "Connect provider", "command.server.switch": "Switch server", "command.settings.open": "Open settings", diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index 11bc4fdb5d..31d3e5dccd 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -960,6 +960,15 @@ export default function Layout(props: ParentProps) { void openProject(target.worktree) } + function navigateToProjectIndex(index: number) { + const projects = layout.projects.list() + const target = projects[index] + if (!target) return + + globalSync.child(target.worktree) + void openProject(target.worktree) + } + function navigateSessionByUnseen(offset: number) { const sessions = currentSessions() if (sessions.length === 0) return @@ -1040,6 +1049,19 @@ export default function Layout(props: ParentProps) { keybind: "mod+alt+arrowdown", onSelect: () => navigateProjectByOffset(1), }, + ...Array.from({ length: 9 }, (_, i) => { + const index = i + const number = index + 1 + return { + id: `project.${number}`, + category: language.t("command.category.project"), + title: `Open Project {number}`, + keybind: `mod+${number}`, + disabled: layout.projects.list().length <= index, + hidden: true, + onSelect: () => navigateToProjectIndex(index), + } + }), { id: "provider.connect", title: language.t("command.provider.connect"), From 907281a80a61d57f06354a74ff1c8195e0778c76 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Tue, 12 May 2026 06:44:09 +0000 Subject: [PATCH 05/70] chore: generate --- packages/app/src/components/dialog-select-file.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/app/src/components/dialog-select-file.tsx b/packages/app/src/components/dialog-select-file.tsx index c5ac52919e..ac3bc03e44 100644 --- a/packages/app/src/components/dialog-select-file.tsx +++ b/packages/app/src/components/dialog-select-file.tsx @@ -107,7 +107,8 @@ function createCommandEntries(props: { const allowed = createMemo(() => { if (props.filesOnly()) return [] return props.command.options.filter( - (option) => !option.disabled && !option.hidden && !option.id.startsWith("suggested.") && option.id !== "file.open", + (option) => + !option.disabled && !option.hidden && !option.id.startsWith("suggested.") && option.id !== "file.open", ) }) From 61174b787855a582f5adcce484b0d2cde4e2682b Mon Sep 17 00:00:00 2001 From: Matt H Date: Tue, 12 May 2026 04:01:16 -0400 Subject: [PATCH 06/70] fix(tui): make websearch provider label reactive (#26943) --- packages/opencode/src/cli/cmd/tui/routes/session/index.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index 3e966d9a58..95d1b072f1 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -1985,11 +1985,11 @@ function WebFetch(props: ToolProps) { } function WebSearch(props: ToolProps) { - const metadata = props.metadata as { numResults?: number; provider?: unknown } + const metadata = () => props.metadata as { numResults?: number; provider?: unknown } return ( - {webSearchProviderLabel(metadata.provider)} "{props.input.query}"{" "} - ({metadata.numResults} results) + {webSearchProviderLabel(metadata().provider)} "{props.input.query}"{" "} + ({metadata().numResults} results) ) } From 2481dde36d9aeb48de9fb21ffd2cfdc1d42804f1 Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Tue, 12 May 2026 13:44:02 +0530 Subject: [PATCH 07/70] chore: remove codesearch tool (#27019) --- packages/opencode/src/agent/agent.ts | 1 - .../tui/feature-plugins/system/session-v2.tsx | 12 ---- packages/opencode/src/config/permission.ts | 1 - .../src/skill/prompt/customize-opencode.md | 6 +- packages/opencode/src/tool/codesearch.ts | 63 ------------------- packages/opencode/src/tool/codesearch.txt | 12 ---- packages/opencode/src/tool/mcp-websearch.ts | 5 -- packages/opencode/src/tool/registry.ts | 5 +- packages/opencode/test/tool/registry.test.ts | 2 - packages/sdk/js/src/v2/gen/types.gen.ts | 1 - packages/sdk/openapi.json | 3 - 11 files changed, 4 insertions(+), 107 deletions(-) delete mode 100644 packages/opencode/src/tool/codesearch.ts delete mode 100644 packages/opencode/src/tool/codesearch.txt diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index b9b56396fa..c1a644282b 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -206,7 +206,6 @@ export const layer = Layer.effect( glob: "allow", webfetch: "allow", websearch: "allow", - codesearch: "allow", read: "allow", repo_clone: "allow", repo_overview: "allow", diff --git a/packages/opencode/src/cli/cmd/tui/feature-plugins/system/session-v2.tsx b/packages/opencode/src/cli/cmd/tui/feature-plugins/system/session-v2.tsx index 8b741ccb49..bcf3032ea3 100644 --- a/packages/opencode/src/cli/cmd/tui/feature-plugins/system/session-v2.tsx +++ b/packages/opencode/src/cli/cmd/tui/feature-plugins/system/session-v2.tsx @@ -438,9 +438,6 @@ function AssistantTool(props: { part: SessionMessageAssistantTool; sessionID: st - - - @@ -773,15 +770,6 @@ function WebFetch(props: ToolProps) { ) } -function CodeSearch(props: ToolProps) { - return ( - - Exa Code Search "{stringValue(props.input.query) ?? pendingInput(props.part)}"{" "} - {(results) => <>({results()} results)} - - ) -} - function WebSearch(props: ToolProps) { const label = createMemo(() => webSearchProviderLabel(props.metadata.provider)) return ( diff --git a/packages/opencode/src/config/permission.ts b/packages/opencode/src/config/permission.ts index a04b404e86..1092ae2b7e 100644 --- a/packages/opencode/src/config/permission.ts +++ b/packages/opencode/src/config/permission.ts @@ -27,7 +27,6 @@ const InputObject = Schema.StructWithRest( question: Schema.optional(Action), webfetch: Schema.optional(Action), websearch: Schema.optional(Action), - codesearch: Schema.optional(Action), repo_clone: Schema.optional(Rule), repo_overview: Schema.optional(Rule), lsp: Schema.optional(Rule), diff --git a/packages/opencode/src/skill/prompt/customize-opencode.md b/packages/opencode/src/skill/prompt/customize-opencode.md index 744690b15a..4ba118b090 100644 --- a/packages/opencode/src/skill/prompt/customize-opencode.md +++ b/packages/opencode/src/skill/prompt/customize-opencode.md @@ -335,9 +335,9 @@ rules last. everything" and is rarely what the user wants. Known permission keys: `read, edit, glob, grep, list, bash, task, -external_directory, todowrite, question, webfetch, websearch, codesearch, -repo_clone, repo_overview, lsp, doom_loop, skill`. Some of these (`todowrite, -question, webfetch, websearch, codesearch, doom_loop`) only accept a flat +external_directory, todowrite, question, webfetch, websearch, repo_clone, +repo_overview, lsp, doom_loop, skill`. Some of these (`todowrite, +question, webfetch, websearch, doom_loop`) only accept a flat action, not a per-pattern object. `external_directory` patterns are filesystem paths (use `~/`, absolute paths, diff --git a/packages/opencode/src/tool/codesearch.ts b/packages/opencode/src/tool/codesearch.ts deleted file mode 100644 index 4616d5900a..0000000000 --- a/packages/opencode/src/tool/codesearch.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { Effect, Schema } from "effect" -import { HttpClient } from "effect/unstable/http" -import * as Tool from "./tool" -import * as McpWebSearch from "./mcp-websearch" -import DESCRIPTION from "./codesearch.txt" - -export const Parameters = Schema.Struct({ - query: Schema.String.annotate({ - description: - "Search query to find relevant context for APIs, Libraries, and SDKs. For example, 'React useState hook examples', 'Python pandas dataframe filtering', 'Express.js middleware', 'Next js partial prerendering configuration'", - }), - tokensNum: Schema.Number.check(Schema.isGreaterThanOrEqualTo(1000)) - .check(Schema.isLessThanOrEqualTo(50000)) - .pipe(Schema.optional, Schema.withDecodingDefault(Effect.succeed(5000))) - .annotate({ - description: - "Number of tokens to return (1000-50000). Default is 5000 tokens. Adjust this value based on how much context you need - use lower values for focused queries and higher values for comprehensive documentation.", - }), -}) - -export const CodeSearchTool = Tool.define( - "codesearch", - Effect.gen(function* () { - const http = yield* HttpClient.HttpClient - - return { - description: DESCRIPTION, - parameters: Parameters, - execute: (params: { query: string; tokensNum: number }, ctx: Tool.Context) => - Effect.gen(function* () { - yield* ctx.ask({ - permission: "codesearch", - patterns: [params.query], - always: ["*"], - metadata: { - query: params.query, - tokensNum: params.tokensNum, - }, - }) - - const result = yield* McpWebSearch.call( - http, - McpWebSearch.EXA_URL, - "get_code_context_exa", - McpWebSearch.CodeArgs, - { - query: params.query, - tokensNum: params.tokensNum, - }, - "30 seconds", - ) - - return { - output: - result ?? - "No code snippets or documentation found. Please try a different query, be more specific about the library or programming concept, or check the spelling of framework names.", - title: `Code search: ${params.query}`, - metadata: {}, - } - }).pipe(Effect.orDie), - } - }), -) diff --git a/packages/opencode/src/tool/codesearch.txt b/packages/opencode/src/tool/codesearch.txt deleted file mode 100644 index 4187f08d12..0000000000 --- a/packages/opencode/src/tool/codesearch.txt +++ /dev/null @@ -1,12 +0,0 @@ -- Search and get relevant context for any programming task using Exa Code API -- Provides the highest quality and freshest context for libraries, SDKs, and APIs -- Use this tool for ANY question or task related to programming -- Returns comprehensive code examples, documentation, and API references -- Optimized for finding specific programming patterns and solutions - -Usage notes: - - Adjustable token count (1000-50000) for focused or comprehensive results - - Default 5000 tokens provides balanced context for most queries - - Use lower values for specific questions, higher values for comprehensive documentation - - Supports queries about frameworks, libraries, APIs, and programming concepts - - Examples: 'React useState hook examples', 'Python pandas dataframe filtering', 'Express.js middleware' diff --git a/packages/opencode/src/tool/mcp-websearch.ts b/packages/opencode/src/tool/mcp-websearch.ts index 42b864c6fa..208924cba5 100644 --- a/packages/opencode/src/tool/mcp-websearch.ts +++ b/packages/opencode/src/tool/mcp-websearch.ts @@ -48,11 +48,6 @@ export const SearchArgs = Schema.Struct({ contextMaxCharacters: Schema.optional(Schema.Number), }) -export const CodeArgs = Schema.Struct({ - query: Schema.String, - tokensNum: Schema.Number, -}) - export const ParallelSearchArgs = Schema.Struct({ objective: Schema.String, search_queries: Schema.Array(Schema.String), diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index a7411a077b..f72f10dd1f 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -22,7 +22,6 @@ import { Plugin } from "../plugin" import { Provider } from "@/provider/provider" import { ProviderID, type ModelID } from "../provider/schema" import { WebSearchTool } from "./websearch" -import { CodeSearchTool } from "./codesearch" import { RepoCloneTool } from "./repo_clone" import { RepoOverviewTool } from "./repo_overview" import { Flag } from "@opencode-ai/core/flag/flag" @@ -120,7 +119,6 @@ export const layer: Layer.Layer< const plan = yield* PlanExitTool const webfetch = yield* WebFetchTool const websearch = yield* WebSearchTool - const codesearch = yield* CodeSearchTool const repoClone = yield* RepoCloneTool const repoOverview = yield* RepoOverviewTool const shell = yield* ShellTool @@ -224,7 +222,6 @@ export const layer: Layer.Layer< fetch: Tool.init(webfetch), todo: Tool.init(todo), search: Tool.init(websearch), - code: Tool.init(codesearch), repo_clone: Tool.init(repoClone), repo_overview: Tool.init(repoOverview), skill: Tool.init(skilltool), @@ -249,7 +246,7 @@ export const layer: Layer.Layer< tool.fetch, tool.todo, tool.search, - ...(Flag.OPENCODE_EXPERIMENTAL_SCOUT ? [tool.code, tool.repo_clone, tool.repo_overview] : []), + ...(Flag.OPENCODE_EXPERIMENTAL_SCOUT ? [tool.repo_clone, tool.repo_overview] : []), tool.skill, tool.patch, ...(Flag.OPENCODE_EXPERIMENTAL_LSP_TOOL ? [tool.lsp] : []), diff --git a/packages/opencode/test/tool/registry.test.ts b/packages/opencode/test/tool/registry.test.ts index 37cb7a43d8..5ee56300c4 100644 --- a/packages/opencode/test/tool/registry.test.ts +++ b/packages/opencode/test/tool/registry.test.ts @@ -72,7 +72,6 @@ describe("tool.registry", () => { const registry = yield* ToolRegistry.Service const ids = yield* registry.ids() - expect(ids).not.toContain("codesearch") expect(ids).not.toContain("repo_clone") expect(ids).not.toContain("repo_overview") }), @@ -84,7 +83,6 @@ describe("tool.registry", () => { const registry = yield* ToolRegistry.Service const ids = yield* registry.ids() - expect(ids).toContain("codesearch") expect(ids).toContain("repo_clone") expect(ids).toContain("repo_overview") }), diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index fd57836604..f062700b7d 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -955,7 +955,6 @@ export type PermissionConfig = question?: PermissionActionConfig webfetch?: PermissionActionConfig websearch?: PermissionActionConfig - codesearch?: PermissionActionConfig repo_clone?: PermissionRuleConfig repo_overview?: PermissionRuleConfig lsp?: PermissionRuleConfig diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 9850c83412..7005382f6b 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -11631,9 +11631,6 @@ "websearch": { "$ref": "#/components/schemas/PermissionActionConfig" }, - "codesearch": { - "$ref": "#/components/schemas/PermissionActionConfig" - }, "repo_clone": { "$ref": "#/components/schemas/PermissionRuleConfig" }, From ff38bbeeeb64c7f2faaae54430b5bfa3a2f5435f Mon Sep 17 00:00:00 2001 From: Brendan Allan <14191578+Brendonovich@users.noreply.github.com> Date: Tue, 12 May 2026 16:39:56 +0800 Subject: [PATCH 08/70] refactor(desktop): remove configureEnv callback from spawnLocalServer (#27022) --- packages/desktop/src/main/index.ts | 28 +++++++++++----------------- packages/desktop/src/main/server.ts | 2 -- 2 files changed, 11 insertions(+), 19 deletions(-) diff --git a/packages/desktop/src/main/index.ts b/packages/desktop/src/main/index.ts index 1b624800e8..23f2d7027a 100644 --- a/packages/desktop/src/main/index.ts +++ b/packages/desktop/src/main/index.ts @@ -291,25 +291,19 @@ const main = Effect.gen(function* () { if (mainWindow) sendSqliteMigrationProgress(mainWindow, progress) }) + ensureLoopbackNoProxy() + useEnvProxy() + logger.log("spawning sidecar", { url }) const { listener, health } = yield* Effect.promise(() => - spawnLocalServer( - hostname, - port, - password, - () => { - ensureLoopbackNoProxy() - useEnvProxy() - }, - { - needsMigration, - userDataPath: app.getPath("userData"), - onSqliteProgress: (progress) => initEmitter.emit("sqlite", progress), - onStdout: (message) => logger.log("sidecar stdout", { message }), - onStderr: (message) => logger.warn("sidecar stderr", { message }), - onExit: (code) => logger.warn("sidecar exited", { code }), - }, - ), + spawnLocalServer(hostname, port, password, { + needsMigration, + userDataPath: app.getPath("userData"), + onSqliteProgress: (progress) => initEmitter.emit("sqlite", progress), + onStdout: (message) => logger.log("sidecar stdout", { message }), + onStderr: (message) => logger.warn("sidecar stderr", { message }), + onExit: (code) => logger.warn("sidecar exited", { code }), + }), ) server = listener yield* Deferred.succeed(serverReady, { diff --git a/packages/desktop/src/main/server.ts b/packages/desktop/src/main/server.ts index 909138b89c..cfdafdc67b 100644 --- a/packages/desktop/src/main/server.ts +++ b/packages/desktop/src/main/server.ts @@ -70,10 +70,8 @@ export async function spawnLocalServer( hostname: string, port: number, password: string, - configureEnv: () => void, options: SpawnLocalServerOptions, ) { - configureEnv?.() const sidecar = join(dirname(fileURLToPath(import.meta.url)), "sidecar.js") const child = utilityProcess.fork(sidecar, [], { cwd: process.cwd(), From caf1151cb5d574d2aac2ed6ccb20a9121880c18a Mon Sep 17 00:00:00 2001 From: Luke Parker <10430890+Hona@users.noreply.github.com> Date: Tue, 12 May 2026 18:40:21 +1000 Subject: [PATCH 09/70] refactor(app): centralize sync query options (#25941) Co-authored-by: Brendan Allan Co-authored-by: Brendan Allan <14191578+Brendonovich@users.noreply.github.com> --- .../app/src/components/dialog-select-mcp.tsx | 6 +- packages/app/src/components/prompt-input.tsx | 12 ++-- .../src/components/status-popover-body.tsx | 6 +- packages/app/src/context/global-sync.tsx | 71 +++++++++++-------- .../context/global-sync/child-store.test.ts | 2 +- .../src/context/global-sync/child-store.ts | 17 ++--- .../src/pages/layout/sidebar-workspace.tsx | 8 ++- 7 files changed, 70 insertions(+), 52 deletions(-) diff --git a/packages/app/src/components/dialog-select-mcp.tsx b/packages/app/src/components/dialog-select-mcp.tsx index 576ec8fec4..cc841e2782 100644 --- a/packages/app/src/components/dialog-select-mcp.tsx +++ b/packages/app/src/components/dialog-select-mcp.tsx @@ -6,7 +6,8 @@ import { Dialog } from "@opencode-ai/ui/dialog" import { List } from "@opencode-ai/ui/list" import { Switch } from "@opencode-ai/ui/switch" import { useLanguage } from "@/context/language" -import { mcpQueryKey } from "@/context/global-sync" +import { useQueryOptions } from "@/context/global-sync" +import { pathKey } from "@/utils/path-key" const statusLabels = { connected: "mcp.status.connected", @@ -20,6 +21,7 @@ export const DialogSelectMcp: Component = () => { const sdk = useSDK() const language = useLanguage() const queryClient = useQueryClient() + const queryOptions = useQueryOptions() const items = createMemo(() => Object.entries(sync.data.mcp ?? {}) @@ -32,7 +34,7 @@ export const DialogSelectMcp: Component = () => { if (sync.data.mcp[name]?.status === "connected") await sdk.client.mcp.disconnect({ name }) else await sdk.client.mcp.connect({ name }) }, - onSuccess: () => queryClient.refetchQueries({ queryKey: mcpQueryKey(sync.directory) }), + onSuccess: () => queryClient.refetchQueries(queryOptions.mcp(pathKey(sync.directory))), })) const enabledCount = createMemo(() => items().filter((i) => i.status === "connected").length) diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index 2417fa98e2..eaeedf087e 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -16,7 +16,6 @@ import { } from "@/context/prompt" import { useLayout } from "@/context/layout" import { useSDK } from "@/context/sdk" -import { useGlobalSDK } from "@/context/global-sdk" import { useSync } from "@/context/sync" import { useComments } from "@/context/comments" import { Button } from "@opencode-ai/ui/button" @@ -56,7 +55,8 @@ import { PromptDragOverlay } from "./prompt-input/drag-overlay" import { promptPlaceholder } from "./prompt-input/placeholder" import { ImagePreview } from "@opencode-ai/ui/image-preview" import { useQueries } from "@tanstack/solid-query" -import { loadAgentsQuery, loadProvidersQuery } from "@/context/global-sync/bootstrap" +import { useQueryOptions } from "@/context/global-sync" +import { pathKey } from "@/utils/path-key" interface PromptInputProps { class?: string @@ -103,7 +103,7 @@ const NON_EMPTY_TEXT = /[^\s\u200B]/ export const PromptInput: Component = (props) => { const sdk = useSDK() - const globalSDK = useGlobalSDK() + const queryOptions = useQueryOptions() const sync = useSync() const local = useLocal() @@ -1256,9 +1256,9 @@ export const PromptInput: Component = (props) => { const [agentsQuery, globalProvidersQuery, providersQuery] = useQueries(() => ({ queries: [ - loadAgentsQuery(sdk.directory, sdk.client), - loadProvidersQuery(null, globalSDK.client), - loadProvidersQuery(sdk.directory, sdk.client), + queryOptions.agents(pathKey(sdk.directory)), + queryOptions.providers(null), + queryOptions.providers(pathKey(sdk.directory)), ], })) diff --git a/packages/app/src/components/status-popover-body.tsx b/packages/app/src/components/status-popover-body.tsx index bbac562784..405c7538c7 100644 --- a/packages/app/src/components/status-popover-body.tsx +++ b/packages/app/src/components/status-popover-body.tsx @@ -15,7 +15,8 @@ import { useSDK } from "@/context/sdk" import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server" import { useSync } from "@/context/sync" import { useCheckServerHealth, type ServerHealth } from "@/utils/server-health" -import { mcpQueryKey } from "@/context/global-sync" +import { useQueryOptions } from "@/context/global-sync" +import { pathKey } from "@/utils/path-key" const pollMs = 10_000 @@ -139,13 +140,14 @@ const useMcpToggleMutation = () => { const sdk = useSDK() const language = useLanguage() const queryClient = useQueryClient() + const queryOptions = useQueryOptions() return useMutation(() => ({ mutationFn: async (name: string) => { const status = sync.data.mcp[name] await (status?.status === "connected" ? sdk.client.mcp.disconnect({ name }) : sdk.client.mcp.connect({ name })) }, - onSuccess: () => queryClient.refetchQueries({ queryKey: mcpQueryKey(sync.directory) }), + onSuccess: () => queryClient.refetchQueries(queryOptions.mcp(pathKey(sync.directory))), onError: (err) => { showToast({ variant: "error", diff --git a/packages/app/src/context/global-sync.tsx b/packages/app/src/context/global-sync.tsx index 31c90463d8..594f94fb62 100644 --- a/packages/app/src/context/global-sync.tsx +++ b/packages/app/src/context/global-sync.tsx @@ -18,8 +18,10 @@ import { bootstrapDirectory, bootstrapGlobal, clearProviderRev, + loadAgentsQuery, loadGlobalConfigQuery, loadPathQuery, + loadProjectsQuery, loadProvidersQuery, } from "./global-sync/bootstrap" import { createChildStoreManager } from "./global-sync/child-store" @@ -33,6 +35,7 @@ import { formatServerError } from "@/utils/server-errors" import { queryOptions, useMutation, useQueries, useQuery, useQueryClient } from "@tanstack/solid-query" import { createRefreshQueue } from "./global-sync/queue" import { directoryKey } from "./global-sync/utils" +import { PathKey } from "@/utils/path-key" type GlobalStore = { ready: boolean @@ -48,24 +51,33 @@ type GlobalStore = { reload: undefined | "pending" | "complete" } -export const loadSessionsQueryKey = (directory: string) => [directory, "loadSessions"] as const - -export const mcpQueryKey = (directory: string) => [directory, "mcp"] as const - export const loadMcpQuery = (directory: string, sdk: OpencodeClient) => queryOptions({ - queryKey: mcpQueryKey(directory), + queryKey: [directory, "mcp"] as const, queryFn: () => sdk.mcp.status().then((r) => r.data ?? {}), }) -export const lspQueryKey = (directory: string) => [directory, "lsp"] as const - export const loadLspQuery = (directory: string, sdk: OpencodeClient) => queryOptions({ - queryKey: lspQueryKey(directory), + queryKey: [directory, "lsp"] as const, queryFn: () => sdk.lsp.status().then((r) => r.data ?? []), }) +function makeQueryOptionsApi(globalSDK: () => OpencodeClient, sdkFor: (dir: PathKey) => OpencodeClient) { + return { + globalConfig: () => loadGlobalConfigQuery(globalSDK()), + projects: () => loadProjectsQuery(globalSDK()), + providers: (directory: PathKey | null) => + loadProvidersQuery(directory, directory === null ? globalSDK() : sdkFor(directory)), + path: (directory: PathKey | null) => loadPathQuery(directory, directory === null ? globalSDK() : sdkFor(directory)), + agents: (directory: PathKey) => loadAgentsQuery(directory, sdkFor(directory)), + mcp: (directory: PathKey) => loadMcpQuery(directory, sdkFor(directory)), + lsp: (directory: PathKey) => loadLspQuery(directory, sdkFor(directory)), + sessions: (directory: PathKey) => ({ queryKey: [directory, "loadSessions"] as const }), + } +} +export type QueryOptionsApi = ReturnType + function createGlobalSync() { const globalSDK = useGlobalSDK() const language = useLanguage() @@ -77,12 +89,22 @@ function createGlobalSync() { const sessionLoads = new Map>() const sessionMeta = new Map() + const sdkFor = (directory: string) => { + const key = directoryKey(directory) + const cached = sdkCache.get(key) + if (cached) return cached + const sdk = globalSDK.createClient({ + directory, + throwOnError: true, + }) + sdkCache.set(key, sdk) + return sdk + } + + const queryOptionsApi = makeQueryOptionsApi(() => globalSDK.client, sdkFor) + const [configQuery, providerQuery, pathQuery] = useQueries(() => ({ - queries: [ - loadGlobalConfigQuery(globalSDK.client), - loadProvidersQuery(null, globalSDK.client), - loadPathQuery(null, globalSDK.client), - ], + queries: [queryOptionsApi.globalConfig(), queryOptionsApi.providers(null), queryOptionsApi.path(null)], })) const [globalStore, setGlobalStore] = createStore({ @@ -181,18 +203,6 @@ function createGlobalSync() { bootstrapInstance, }) - const sdkFor = (directory: string) => { - const key = directoryKey(directory) - const cached = sdkCache.get(key) - if (cached) return cached - const sdk = globalSDK.createClient({ - directory, - throwOnError: true, - }) - sdkCache.set(key, sdk) - return sdk - } - const children = createChildStoreManager({ owner, isBooting: (directory) => booting.has(directory), @@ -209,7 +219,7 @@ function createGlobalSync() { clearSessionPrefetchDirectory(key) }, translate: language.t, - getSdk: sdkFor, + queryOptions: queryOptionsApi, global: { provider: globalStore.provider, }, @@ -239,7 +249,7 @@ function createGlobalSync() { const limit = Math.max(store.limit + SESSION_RECENT_LIMIT, SESSION_RECENT_LIMIT) const promise = queryClient .fetchQuery({ - queryKey: loadSessionsQueryKey(key), + ...queryOptionsApi.sessions(key), queryFn: () => loadRootSessionsWithFallback({ directory, @@ -368,7 +378,7 @@ function createGlobalSync() { setSessionTodo, vcsCache: children.vcsCache.get(key), loadLsp: () => { - void queryClient.fetchQuery(loadLspQuery(key, sdkFor(directory))) + void queryClient.fetchQuery(queryOptionsApi.lsp(key)) }, }) }) @@ -426,6 +436,7 @@ function createGlobalSync() { }, child: children.child, peek: children.peek, + queryOptions: queryOptionsApi, // bootstrap, updateConfig: updateConfigMutation.mutateAsync, project: projectApi, @@ -447,3 +458,7 @@ export function useGlobalSync() { if (!context) throw new Error("useGlobalSync must be used within GlobalSyncProvider") return context } + +export function useQueryOptions() { + return useGlobalSync().queryOptions +} diff --git a/packages/app/src/context/global-sync/child-store.test.ts b/packages/app/src/context/global-sync/child-store.test.ts index 30dda86919..bb8eb7ce7f 100644 --- a/packages/app/src/context/global-sync/child-store.test.ts +++ b/packages/app/src/context/global-sync/child-store.test.ts @@ -22,7 +22,7 @@ describe("createChildStoreManager", () => { onBootstrap() {}, onDispose() {}, translate: (key) => key, - getSdk: () => null!, + queryOptions: {} as any, global: { provider: null! }, }) diff --git a/packages/app/src/context/global-sync/child-store.ts b/packages/app/src/context/global-sync/child-store.ts index 737c6bedc9..e8ca597d15 100644 --- a/packages/app/src/context/global-sync/child-store.ts +++ b/packages/app/src/context/global-sync/child-store.ts @@ -1,7 +1,7 @@ import { createRoot, getOwner, onCleanup, runWithOwner, type Owner } from "solid-js" import { createStore, type SetStoreFunction, type Store } from "solid-js/store" import { Persist, persisted } from "@/utils/persist" -import type { OpencodeClient, ProviderListResponse, VcsInfo } from "@opencode-ai/sdk/v2/client" +import type { ProviderListResponse, VcsInfo } from "@opencode-ai/sdk/v2/client" import { DIR_IDLE_TTL_MS, MAX_DIR_STORES, @@ -15,8 +15,7 @@ import { } from "./types" import { canDisposeDirectory, pickDirectoriesToEvict } from "./eviction" import { useQueries } from "@tanstack/solid-query" -import { loadPathQuery, loadProvidersQuery } from "./bootstrap" -import { loadLspQuery, loadMcpQuery } from "../global-sync" +import { QueryOptionsApi } from "../global-sync" import { directoryKey, type DirectoryKey } from "./utils" export function createChildStoreManager(input: { @@ -26,7 +25,7 @@ export function createChildStoreManager(input: { onBootstrap: (directory: string) => void onDispose: (directory: string) => void translate: (key: string, vars?: Record) => string - getSdk: (directory: string) => OpencodeClient + queryOptions: QueryOptionsApi global: { provider: ProviderListResponse } @@ -171,17 +170,15 @@ export function createChildStoreManager(input: { const init = () => createRoot((dispose) => { - const sdk = input.getSdk(directory) - const initialMeta = meta[0].value const initialIcon = icon[0].value const [pathQuery, mcpQuery, lspQuery, providerQuery] = useQueries(() => ({ queries: [ - loadPathQuery(key, sdk), - loadMcpQuery(key, sdk), - loadLspQuery(key, sdk), - loadProvidersQuery(key, sdk), + input.queryOptions.path(key), + input.queryOptions.mcp(key), + input.queryOptions.lsp(key), + input.queryOptions.providers(key), ], })) diff --git a/packages/app/src/pages/layout/sidebar-workspace.tsx b/packages/app/src/pages/layout/sidebar-workspace.tsx index 9b80adac29..f423c13d1e 100644 --- a/packages/app/src/pages/layout/sidebar-workspace.tsx +++ b/packages/app/src/pages/layout/sidebar-workspace.tsx @@ -14,7 +14,7 @@ import { Spinner } from "@opencode-ai/ui/spinner" import { Tooltip } from "@opencode-ai/ui/tooltip" import { type Session } from "@opencode-ai/sdk/v2/client" import { type LocalProject } from "@/context/layout" -import { loadSessionsQueryKey, useGlobalSync } from "@/context/global-sync" +import { useGlobalSync, useQueryOptions } from "@/context/global-sync" import { useLanguage } from "@/context/language" import { pathKey } from "@/utils/path-key" import { NewSessionItem, SessionItem, SessionSkeleton } from "./sidebar-items" @@ -300,6 +300,7 @@ export const SortableWorkspace = (props: { const navigate = useNavigate() const params = useParams() const globalSync = useGlobalSync() + const queryOptions = useQueryOptions() const language = useLanguage() const sortable = createSortable(props.directory) const [workspaceStore, setWorkspaceStore] = globalSync.child(props.directory, { bootstrap: false }) @@ -320,7 +321,7 @@ export const SortableWorkspace = (props: { const boot = createMemo(() => open() || active()) const count = createMemo(() => sessions()?.length ?? 0) const hasMore = createMemo(() => workspaceStore.sessionTotal > count()) - const fetching = useIsFetching(() => ({ queryKey: loadSessionsQueryKey(props.directory) })) + const fetching = useIsFetching(() => queryOptions.sessions(pathKey(props.directory))) const busy = createMemo(() => props.ctx.isBusy(props.directory)) const loading = () => fetching() > 0 && count() === 0 const touch = createMediaQuery("(hover: none)") @@ -446,6 +447,7 @@ export const LocalWorkspace = (props: { mobile?: boolean }): JSX.Element => { const globalSync = useGlobalSync() + const queryOptions = useQueryOptions() const language = useLanguage() const workspace = createMemo(() => { const [store, setStore] = globalSync.child(props.project.worktree) @@ -454,7 +456,7 @@ export const LocalWorkspace = (props: { const slug = createMemo(() => base64Encode(props.project.worktree)) const sessions = createMemo(() => sortedRootSessions(workspace().store, props.sortNow())) const count = createMemo(() => sessions()?.length ?? 0) - const fetching = useIsFetching(() => ({ queryKey: loadSessionsQueryKey(props.project.worktree) })) + const fetching = useIsFetching(() => queryOptions.sessions(pathKey(props.project.worktree))) const hasMore = createMemo(() => workspace().store.sessionTotal > count()) const loading = () => fetching() > 0 && count() === 0 const loadMore = async () => { From d276d96cdfc03dc64cef820c5751d723915d9476 Mon Sep 17 00:00:00 2001 From: Brendan Allan <14191578+Brendonovich@users.noreply.github.com> Date: Tue, 12 May 2026 17:44:50 +0800 Subject: [PATCH 10/70] fix(app): remember selected model variant when switching sessions/projects (#27029) --- packages/app/src/context/local.tsx | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/packages/app/src/context/local.tsx b/packages/app/src/context/local.tsx index f467e9034f..4465a0261d 100644 --- a/packages/app/src/context/local.tsx +++ b/packages/app/src/context/local.tsx @@ -44,7 +44,7 @@ const migrate = (value: unknown) => { } const clone = (value: State | undefined) => { - if (!value) return undefined + if (!value) return return { ...value, model: value.model ? { ...value.model } : undefined, @@ -104,7 +104,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ const pickAgent = (name: string | undefined) => { const items = list() - if (items.length === 0) return undefined + if (items.length === 0) return return items.find((item) => item.name === name) ?? items[0] } @@ -227,14 +227,14 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ () => agent.current()?.model, fallback, ) - if (!item) return undefined + if (!item) return return models.find(item) } const configured = () => { const item = agent.current() const model = current() - if (!item || !model) return undefined + if (!item || !model) return return getConfiguredAgentVariant({ agent: { model: item.model, variant: item.variant }, model: { providerID: model.provider.id, modelID: model.id, variants: model.variants }, @@ -314,11 +314,16 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ configured, selected, current() { - return resolveModelVariant({ + const resolved = resolveModelVariant({ variants: this.list(), selected: this.selected(), configured: this.configured(), }) + if (resolved) return resolved + const model = current() + if (!model) return + const saved = models.variant.get({ providerID: model.provider.id, modelID: model.id }) + if (saved && this.list().includes(saved)) return saved }, list() { const item = current() @@ -335,6 +340,9 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ variant: value ?? null, }) write({ variant: value ?? null }) + if (model) { + models.variant.set({ providerID: model.provider.id, modelID: model.id }, value ?? undefined) + } }) }, cycle() { From 8f05bbfaa62192b357d460e1d2d2b34f13f8dec7 Mon Sep 17 00:00:00 2001 From: Simon Klee Date: Tue, 12 May 2026 11:45:28 +0200 Subject: [PATCH 11/70] prompt: fix cursor math for wide characters (#27017) String.length counts code points, not display columns, so CJK characters and emoji that occupy two terminal cells caused misaligned cursors, broken mention triggers, and incorrect history restoration offsets. Use Bun.stringWidth for now, we need an alternative for this. Fix #26716 Close #26922 --- .../opencode/src/cli/cmd/prompt-display.ts | 39 +++++++++++++++ .../src/cli/cmd/run/footer.prompt.tsx | 34 ++++++------- .../opencode/src/cli/cmd/run/prompt.shared.ts | 7 +-- .../cmd/tui/component/prompt/autocomplete.tsx | 12 ++--- .../test/cli/run/prompt.shared.test.ts | 50 +++++++++++++++++++ 5 files changed, 113 insertions(+), 29 deletions(-) create mode 100644 packages/opencode/src/cli/cmd/prompt-display.ts diff --git a/packages/opencode/src/cli/cmd/prompt-display.ts b/packages/opencode/src/cli/cmd/prompt-display.ts new file mode 100644 index 0000000000..7ec4bc0af5 --- /dev/null +++ b/packages/opencode/src/cli/cmd/prompt-display.ts @@ -0,0 +1,39 @@ +const graphemes = new Intl.Segmenter(undefined, { granularity: "grapheme" }) + +function displayOffsetIndex(value: string, offset: number) { + if (offset <= 0) return 0 + + let width = 0 + for (const part of graphemes.segment(value)) { + const next = width + Bun.stringWidth(part.segment) + if (next > offset) return part.index + width = next + } + + return value.length +} + +export function displaySlice(value: string, start = 0, end = Bun.stringWidth(value)) { + return value.slice(displayOffsetIndex(value, start), displayOffsetIndex(value, end)) +} + +export function displayCharAt(value: string, offset: number) { + let width = 0 + for (const part of graphemes.segment(value)) { + const next = width + Bun.stringWidth(part.segment) + if (offset === width || offset < next) return part.segment + width = next + } +} + +export function mentionTriggerIndex(value: string, offset = Bun.stringWidth(value)) { + const text = displaySlice(value, 0, offset) + const index = text.lastIndexOf("@") + if (index === -1) return + + const before = index === 0 ? undefined : text[index - 1] + const query = text.slice(index) + if ((before === undefined || /\s/.test(before)) && !/\s/.test(query)) { + return Bun.stringWidth(text.slice(0, index)) + } +} diff --git a/packages/opencode/src/cli/cmd/run/footer.prompt.tsx b/packages/opencode/src/cli/cmd/run/footer.prompt.tsx index 8cd4fbfcf5..54f20dbc07 100644 --- a/packages/opencode/src/cli/cmd/run/footer.prompt.tsx +++ b/packages/opencode/src/cli/cmd/run/footer.prompt.tsx @@ -14,7 +14,10 @@ import { createEffect, createMemo, createResource, createSignal, onCleanup, onMo import * as Locale from "@/util/locale" import { createPromptHistory, + displayCharAt, + displaySlice, isExitCommand, + mentionTriggerIndex, isNewCommand, movePromptHistory, promptCycle, @@ -537,7 +540,7 @@ export function createPromptState(input: PromptInput): PromptState { }) } - const restore = (value: RunPrompt, cursor = value.text.length) => { + const restore = (value: RunPrompt, cursor = Bun.stringWidth(value.text)) => { draft = clonePrompt(value) if (!area || area.isDestroyed) { return @@ -546,7 +549,7 @@ export function createPromptState(input: PromptInput): PromptState { hide() area.setText(value.text) restoreParts(value.parts) - area.cursorOffset = Math.min(cursor, area.plainText.length) + area.cursorOffset = Math.min(cursor, Bun.stringWidth(area.plainText)) scheduleRows() area.focus() } @@ -577,7 +580,7 @@ export function createPromptState(input: PromptInput): PromptState { area.setText(text) clearParts() draft = { text: area.plainText, parts: [] } - area.cursorOffset = Math.min(text.length, area.plainText.length) + area.cursorOffset = Math.min(Bun.stringWidth(text), Bun.stringWidth(area.plainText)) scheduleRows() area.focus() } @@ -610,12 +613,13 @@ export function createPromptState(input: PromptInput): PromptState { } if (visible() && mode() === "mention") { - if (cursor <= at() || /\s/.test(text.slice(at(), cursor))) { + const query = displaySlice(text, at(), cursor) + if (cursor <= at() || /\s/.test(query)) { hide() return } - setQuery(text.slice(at() + 1, cursor)) + setQuery(displaySlice(text, at() + 1, cursor)) return } @@ -623,19 +627,12 @@ export function createPromptState(input: PromptInput): PromptState { return } - const head = text.slice(0, cursor) - const idx = head.lastIndexOf("@") - if (idx === -1) { - return - } - - const before = idx === 0 ? undefined : head[idx - 1] - const tail = head.slice(idx) - if ((before === undefined || /\s/.test(before)) && !/\s/.test(tail)) { + const idx = mentionTriggerIndex(text, cursor) + if (idx !== undefined) { setAt(idx) menu.reset() setMode("mention") - setQuery(head.slice(idx + 1)) + setQuery(displaySlice(text, idx + 1, cursor)) } } @@ -782,7 +779,7 @@ export function createPromptState(input: PromptInput): PromptState { } const cursor = area.cursorOffset - const tail = area.plainText.at(cursor) + const tail = displayCharAt(area.plainText, cursor) const append = "@" + next.value + (tail === " " ? "" : " ") area.cursorOffset = at() const start = area.logicalCursor @@ -941,7 +938,8 @@ export function createPromptState(input: PromptInput): PromptState { } const dir = up ? -1 : 1 - if ((dir === -1 && area.cursorOffset === 0) || (dir === 1 && area.cursorOffset === area.plainText.length)) { + const endOffset = Bun.stringWidth(area.plainText) + if ((dir === -1 && area.cursorOffset === 0) || (dir === 1 && area.cursorOffset === endOffset)) { move(dir, event) return } @@ -955,7 +953,7 @@ export function createPromptState(input: PromptInput): PromptState { ? area.height - 1 : Math.max(0, (area.virtualLineCount ?? 1) - 1) if (dir === 1 && area.visualCursor.visualRow === end) { - area.cursorOffset = area.plainText.length + area.cursorOffset = endOffset } } diff --git a/packages/opencode/src/cli/cmd/run/prompt.shared.ts b/packages/opencode/src/cli/cmd/run/prompt.shared.ts index 1b639e6e7e..0da787cb3c 100644 --- a/packages/opencode/src/cli/cmd/run/prompt.shared.ts +++ b/packages/opencode/src/cli/cmd/run/prompt.shared.ts @@ -12,6 +12,7 @@ // The leader-key cycle (promptCycle) uses a two-step pattern: first press // arms the leader, second press within the timeout fires the action. import type { KeyBinding } from "@opentui/core" +export { displayCharAt, displaySlice, mentionTriggerIndex } from "../prompt-display" import { formatBinding, parseBindings } from "./keymap.shared" import type { FooterKeybinds, RunPrompt } from "./types" @@ -275,7 +276,7 @@ export function movePromptHistory(state: PromptHistoryState, dir: -1 | 1, text: return { state, apply: false } } - if (dir === 1 && cursor !== text.length) { + if (dir === 1 && cursor !== Bun.stringWidth(text)) { return { state, apply: false } } @@ -309,7 +310,7 @@ export function movePromptHistory(state: PromptHistoryState, dir: -1 | 1, text: index: null, }, text: state.draft, - cursor: state.draft.length, + cursor: Bun.stringWidth(state.draft), apply: true, } } @@ -320,7 +321,7 @@ export function movePromptHistory(state: PromptHistoryState, dir: -1 | 1, text: index: idx, }, text: state.items[idx].text, - cursor: dir === -1 ? 0 : state.items[idx].text.length, + cursor: dir === -1 ? 0 : Bun.stringWidth(state.items[idx].text), apply: true, } } diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx index 3242de94d6..3f7604653c 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx @@ -20,6 +20,7 @@ import { useFrecency } from "./frecency" import { useBindings } from "../../keymap" import { Reference } from "@/reference/reference" import type { Config } from "@/config/config" +import { displayCharAt, mentionTriggerIndex } from "@/cli/cmd/prompt-display" function removeLineRange(input: string) { const hashIndex = input.lastIndexOf("#") @@ -159,7 +160,7 @@ export function Autocomplete(props: { const input = props.input() const currentCursorOffset = input.cursorOffset - const charAfterCursor = props.value.at(currentCursorOffset) + const charAfterCursor = displayCharAt(props.value, currentCursorOffset) const needsSpace = charAfterCursor !== " " const append = "@" + text + (needsSpace ? " " : "") @@ -787,13 +788,8 @@ export function Autocomplete(props: { } // Check for "@" trigger - find the nearest "@" before cursor with no whitespace between - const text = value.slice(0, offset) - const idx = text.lastIndexOf("@") - if (idx === -1) return - - const between = text.slice(idx) - const before = idx === 0 ? undefined : value[idx - 1] - if ((before === undefined || /\s/.test(before)) && !between.match(/\s/)) { + const idx = mentionTriggerIndex(value, offset) + if (idx !== undefined) { show("@") setStore("index", idx) } diff --git a/packages/opencode/test/cli/run/prompt.shared.test.ts b/packages/opencode/test/cli/run/prompt.shared.test.ts index 85a9dfa406..299751eaa3 100644 --- a/packages/opencode/test/cli/run/prompt.shared.test.ts +++ b/packages/opencode/test/cli/run/prompt.shared.test.ts @@ -1,8 +1,11 @@ import { describe, expect, test } from "bun:test" import { createPromptHistory, + displayCharAt, + displaySlice, isExitCommand, isNewCommand, + mentionTriggerIndex, movePromptHistory, printableBinding, promptCycle, @@ -85,6 +88,53 @@ describe("run prompt shared", () => { expect(draft.state.index).toBeNull() }) + test("uses display-width cursors for history restoration", () => { + const base = createPromptHistory([prompt("one"), prompt("中文")]) + + const latest = movePromptHistory(base, -1, "草稿", 0) + expect(latest.apply).toBe(true) + expect(latest.text).toBe("中文") + expect(latest.cursor).toBe(0) + + const older = movePromptHistory(latest.state, -1, "中文", 0) + expect(older.apply).toBe(true) + expect(older.text).toBe("one") + expect(older.cursor).toBe(0) + + const newer = movePromptHistory(older.state, 1, "one", Bun.stringWidth("one")) + expect(newer.apply).toBe(true) + expect(newer.text).toBe("中文") + expect(newer.cursor).toBe(Bun.stringWidth("中文")) + + const draft = movePromptHistory(newer.state, 1, "中文", Bun.stringWidth("中文")) + expect(draft.apply).toBe(true) + expect(draft.text).toBe("草稿") + expect(draft.cursor).toBe(Bun.stringWidth("草稿")) + }) + + test("uses display-width offsets for mention helpers", () => { + expect(mentionTriggerIndex("@")).toBe(0) + expect(mentionTriggerIndex("test @")).toBe(5) + expect(mentionTriggerIndex("中文 @")).toBe(5) + expect(mentionTriggerIndex("こんにちは @")).toBe(11) + expect(mentionTriggerIndex("한국어 @")).toBe(7) + expect(mentionTriggerIndex("🙂 @")).toBe(3) + expect(mentionTriggerIndex("中文 @src file", Bun.stringWidth("中文 @src"))).toBe(5) + expect(displayCharAt("中文 @src", Bun.stringWidth("中文 @"))).toBe("s") + expect(displaySlice("中文 @src", 5, Bun.stringWidth("中文 @src"))).toBe("@src") + expect(displaySlice("中文 @src", 6, Bun.stringWidth("中文 @src"))).toBe("src") + expect(mentionTriggerIndex("👨‍👩‍👧‍👦 @src", Bun.stringWidth("👨‍👩‍👧‍👦 @src"))).toBe(3) + expect(displayCharAt("👨‍👩‍👧‍👦 @src", Bun.stringWidth("👨‍👩‍👧‍👦 @"))).toBe("s") + expect(displaySlice("👨‍👩‍👧‍👦 @src", 3, Bun.stringWidth("👨‍👩‍👧‍👦 @src"))).toBe("@src") + expect(mentionTriggerIndex("中文@")).toBeUndefined() + expect(mentionTriggerIndex("こんにちは@")).toBeUndefined() + expect(mentionTriggerIndex("한국어@")).toBeUndefined() + expect(mentionTriggerIndex("🙂@")).toBeUndefined() + expect(mentionTriggerIndex("hello@")).toBeUndefined() + expect(mentionTriggerIndex("foo@bar.com")).toBeUndefined() + expect(mentionTriggerIndex("中文 @src file")).toBeUndefined() + }) + test("handles direct and leader-based variant cycling", () => { const keys = promptKeys(keybinds) From 8feb4a31c75e8bd3bd8f84ec860cfd4d326479b4 Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Tue, 12 May 2026 15:22:38 +0530 Subject: [PATCH 12/70] feat(core): add background job service (#27033) --- packages/opencode/src/background/job.ts | 200 ++++++++++++++++++ packages/opencode/src/effect/app-runtime.ts | 2 + packages/opencode/src/id/id.ts | 1 + packages/opencode/test/background/job.test.ts | 127 +++++++++++ 4 files changed, 330 insertions(+) create mode 100644 packages/opencode/src/background/job.ts create mode 100644 packages/opencode/test/background/job.test.ts diff --git a/packages/opencode/src/background/job.ts b/packages/opencode/src/background/job.ts new file mode 100644 index 0000000000..3ea228f048 --- /dev/null +++ b/packages/opencode/src/background/job.ts @@ -0,0 +1,200 @@ +import { InstanceState } from "@/effect/instance-state" +import { Identifier } from "@/id/id" +import { Cause, Clock, Context, Deferred, Effect, Fiber, Layer, Scope, SynchronizedRef } from "effect" + +export type Status = "running" | "completed" | "error" | "cancelled" + +export type Info = { + id: string + type: string + title?: string + status: Status + started_at: number + completed_at?: number + output?: string + error?: string + metadata?: Record +} + +type Active = { + info: Info + done: Deferred.Deferred + fiber?: Fiber.Fiber +} + +type State = { + jobs: SynchronizedRef.SynchronizedRef> + scope: Scope.Scope +} + +type FinishResult = { + info?: Info + done?: Deferred.Deferred +} + +export type StartInput = { + id?: string + type: string + title?: string + metadata?: Record + run: Effect.Effect +} + +export type WaitInput = { + id: string + timeout?: number +} + +export type WaitResult = { + info?: Info + timedOut: boolean +} + +export interface Interface { + readonly list: () => Effect.Effect + readonly get: (id: string) => Effect.Effect + readonly start: (input: StartInput) => Effect.Effect + readonly wait: (input: WaitInput) => Effect.Effect + readonly cancel: (id: string) => Effect.Effect +} + +export class Service extends Context.Service()("@opencode/BackgroundJob") {} + +function snapshot(job: Active): Info { + return { + ...job.info, + ...(job.info.metadata ? { metadata: { ...job.info.metadata } } : {}), + } +} + +function errorText(error: unknown) { + if (error instanceof Error) return error.message + return String(error) +} + +export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const state = yield* InstanceState.make( + Effect.fn("BackgroundJob.state")(function* () { + return { + jobs: yield* SynchronizedRef.make(new Map()), + scope: yield* Scope.Scope, + } + }), + ) + + const finish = Effect.fn("BackgroundJob.finish")(function* ( + id: string, + status: Exclude, + data?: { output?: string; error?: string }, + ) { + const completed_at = yield* Clock.currentTimeMillis + const result = yield* SynchronizedRef.modify( + (yield* InstanceState.get(state)).jobs, + (jobs): readonly [FinishResult, Map] => { + const job = jobs.get(id) + if (!job) return [{}, jobs] + if (job.info.status !== "running") return [{ info: snapshot(job) }, jobs] + const next = { + ...job, + fiber: undefined, + info: { + ...job.info, + status, + completed_at, + ...(data?.output !== undefined ? { output: data.output } : {}), + ...(data?.error !== undefined ? { error: data.error } : {}), + }, + } + return [{ info: snapshot(next), done: job.done }, new Map(jobs).set(id, next)] + }, + ) + if (result.info && result.done) yield* Deferred.succeed(result.done, result.info).pipe(Effect.ignore) + return result.info + }) + + const list: Interface["list"] = Effect.fn("BackgroundJob.list")(function* () { + return Array.from((yield* SynchronizedRef.get((yield* InstanceState.get(state)).jobs)).values()) + .map(snapshot) + .toSorted((a, b) => a.started_at - b.started_at) + }) + + const get: Interface["get"] = Effect.fn("BackgroundJob.get")(function* (id) { + const job = (yield* SynchronizedRef.get((yield* InstanceState.get(state)).jobs)).get(id) + if (!job) return + return snapshot(job) + }) + + const start: Interface["start"] = Effect.fn("BackgroundJob.start")(function* (input) { + return yield* Effect.uninterruptibleMask((restore) => + Effect.gen(function* () { + const s = yield* InstanceState.get(state) + const id = input.id ?? Identifier.ascending("job") + const started_at = yield* Clock.currentTimeMillis + const done = yield* Deferred.make() + return yield* SynchronizedRef.modifyEffect( + s.jobs, + Effect.fnUntraced(function* (jobs) { + const existing = jobs.get(id) + if (existing?.info.status === "running") return [snapshot(existing), jobs] as const + const fiber = yield* restore(input.run).pipe( + Effect.matchCauseEffect({ + onSuccess: (output) => finish(id, "completed", { output }), + onFailure: (cause) => + finish(id, Cause.hasInterruptsOnly(cause) ? "cancelled" : "error", { + error: errorText(Cause.squash(cause)), + }), + }), + Effect.asVoid, + Effect.forkIn(s.scope, { startImmediately: true }), + ) + const job = { + info: { + id, + type: input.type, + title: input.title, + status: "running" as const, + started_at, + metadata: input.metadata, + }, + done, + fiber, + } + return [snapshot(job), new Map(jobs).set(id, job)] as const + }), + ) + }), + ) + }) + + const wait: Interface["wait"] = Effect.fn("BackgroundJob.wait")(function* (input) { + const job = (yield* SynchronizedRef.get((yield* InstanceState.get(state)).jobs)).get(input.id) + if (!job) return { timedOut: false } + if (job.info.status !== "running") return { info: snapshot(job), timedOut: false } + if (input.timeout === undefined) return { info: yield* Deferred.await(job.done), timedOut: false } + if (input.timeout <= 0) return { info: snapshot(job), timedOut: true } + const info = yield* Deferred.await(job.done).pipe(Effect.timeoutOption(input.timeout)) + if (info._tag === "Some") return { info: info.value, timedOut: false } + return { info: snapshot(job), timedOut: true } + }) + + const cancel: Interface["cancel"] = Effect.fn("BackgroundJob.cancel")(function* (id) { + const job = (yield* SynchronizedRef.get((yield* InstanceState.get(state)).jobs)).get(id) + if (!job) return + if (job.info.status !== "running") return snapshot(job) + if (job.fiber) { + yield* Fiber.interrupt(job.fiber).pipe(Effect.ignore) + yield* Fiber.await(job.fiber).pipe(Effect.ignore) + } + const info = yield* finish(id, "cancelled") + return info + }) + + return Service.of({ list, get, start, wait, cancel }) + }), +) + +export const defaultLayer = layer + +export * as BackgroundJob from "./job" diff --git a/packages/opencode/src/effect/app-runtime.ts b/packages/opencode/src/effect/app-runtime.ts index 4c1637006c..b0efab1ae9 100644 --- a/packages/opencode/src/effect/app-runtime.ts +++ b/packages/opencode/src/effect/app-runtime.ts @@ -55,6 +55,7 @@ import { SyncEvent } from "@/sync" import { Npm } from "@opencode-ai/core/npm" import { memoMap } from "@opencode-ai/core/effect/memo-map" import { DataMigration } from "@/data-migration" +import { BackgroundJob } from "@/background/job" export const AppLayer = Layer.mergeAll( Npm.defaultLayer, @@ -81,6 +82,7 @@ export const AppLayer = Layer.mergeAll( Todo.defaultLayer, Session.defaultLayer, SessionStatus.defaultLayer, + BackgroundJob.defaultLayer, SessionRunState.defaultLayer, SessionProcessor.defaultLayer, SessionCompaction.defaultLayer, diff --git a/packages/opencode/src/id/id.ts b/packages/opencode/src/id/id.ts index 9e163cd6b8..847a5c0329 100644 --- a/packages/opencode/src/id/id.ts +++ b/packages/opencode/src/id/id.ts @@ -1,6 +1,7 @@ import { randomBytes } from "crypto" const prefixes = { + job: "job", event: "evt", session: "ses", message: "msg", diff --git a/packages/opencode/test/background/job.test.ts b/packages/opencode/test/background/job.test.ts new file mode 100644 index 0000000000..afc7260bb8 --- /dev/null +++ b/packages/opencode/test/background/job.test.ts @@ -0,0 +1,127 @@ +import { describe, expect } from "bun:test" +import { Deferred, Effect } from "effect" +import { BackgroundJob } from "@/background/job" +import { testEffect } from "../lib/effect" + +const it = testEffect(BackgroundJob.defaultLayer) + +describe("background.job", () => { + it.instance("tracks started jobs through completion", () => + Effect.gen(function* () { + const jobs = yield* BackgroundJob.Service + const latch = yield* Deferred.make() + const job = yield* jobs.start({ + type: "test", + title: "test job", + run: Deferred.await(latch).pipe(Effect.as("done")), + }) + + expect(job.id.startsWith("job_")).toBe(true) + expect(job.status).toBe("running") + expect(job.title).toBe("test job") + + yield* Deferred.succeed(latch, undefined) + const done = yield* jobs.wait({ id: job.id }) + + expect(done.timedOut).toBe(false) + expect(done.info?.status).toBe("completed") + expect(done.info?.output).toBe("done") + expect((yield* jobs.list()).map((item) => item.id)).toEqual([job.id]) + }), + ) + + it.instance("returns a running snapshot when wait times out", () => + Effect.gen(function* () { + const jobs = yield* BackgroundJob.Service + const job = yield* jobs.start({ + type: "test", + run: Effect.never, + }) + + const result = yield* jobs.wait({ id: job.id, timeout: 1 }) + + expect(result.timedOut).toBe(true) + expect(result.info?.status).toBe("running") + }), + ) + + it.instance("deduplicates concurrent starts for a running id", () => + Effect.gen(function* () { + const jobs = yield* BackgroundJob.Service + const started = yield* Deferred.make() + const id = "job_test" + const [first, second] = yield* Effect.all( + [ + jobs.start({ + id, + type: "test", + run: Deferred.succeed(started, undefined).pipe(Effect.andThen(Effect.never)), + }), + jobs.start({ + id, + type: "test", + run: Effect.fail(new Error("duplicate started")), + }), + ], + { concurrency: "unbounded" }, + ) + + yield* Deferred.await(started) + + expect(first.id).toBe(id) + expect(second.id).toBe(id) + expect(first.status).toBe("running") + expect(second.status).toBe("running") + expect((yield* jobs.list()).map((item) => item.id)).toEqual([id]) + + yield* jobs.cancel(id) + }), + ) + + it.instance("records failed jobs", () => + Effect.gen(function* () { + const jobs = yield* BackgroundJob.Service + const job = yield* jobs.start({ + type: "test", + run: Effect.fail(new Error("boom")), + }) + + const result = yield* jobs.wait({ id: job.id }) + + expect(result.info?.status).toBe("error") + expect(result.info?.error).toBe("boom") + }), + ) + + it.instance("can cancel running jobs", () => + Effect.gen(function* () { + const jobs = yield* BackgroundJob.Service + const interrupted = yield* Deferred.make() + const job = yield* jobs.start({ + type: "test", + run: Effect.never.pipe(Effect.ensuring(Deferred.succeed(interrupted, undefined))), + }) + + const cancelled = yield* jobs.cancel(job.id) + + expect(cancelled?.status).toBe("cancelled") + yield* Deferred.await(interrupted).pipe(Effect.timeout("1 second")) + expect((yield* jobs.get(job.id))?.status).toBe("cancelled") + }), + ) + + it.instance("returns immutable snapshots", () => + Effect.gen(function* () { + const jobs = yield* BackgroundJob.Service + const job = yield* jobs.start({ + type: "test", + metadata: { value: "initial" }, + run: Effect.succeed("done"), + }) + + if (job.metadata) job.metadata.value = "changed" + + expect((yield* jobs.get(job.id))?.metadata?.value).toBe("initial") + }), + ) +}) From 28f38fc871091d3f0a9b360d3e256564603f6517 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 12 May 2026 09:20:15 -0400 Subject: [PATCH 13/70] Remove Zod from named errors (#26982) --- packages/core/src/util/error.ts | 53 ++++++++------- .../enterprise/src/routes/share/[shareID].tsx | 30 ++++++--- packages/opencode/src/cli/ui.ts | 4 +- packages/opencode/src/config/config.ts | 14 ++-- packages/opencode/src/config/error.ts | 30 +++++---- packages/opencode/src/config/markdown.ts | 13 ++-- packages/opencode/src/config/parse.ts | 11 ++-- packages/opencode/src/ide/index.ts | 12 ++-- packages/opencode/src/index.ts | 18 ++++-- packages/opencode/src/lsp/client.ts | 10 +-- packages/opencode/src/mcp/index.ts | 9 +-- packages/opencode/src/session/message-v2.ts | 4 +- packages/opencode/src/session/message.ts | 25 ++------ packages/opencode/src/session/retry.ts | 5 +- packages/opencode/src/skill/index.ts | 55 +++++++++------- packages/opencode/src/storage/db.ts | 11 ++-- packages/opencode/src/storage/storage.ts | 10 +-- .../opencode/src/util/named-schema-error.ts | 46 +------------ packages/opencode/src/worktree/index.ts | 64 ++++++------------- packages/opencode/test/util/error.test.ts | 18 ++++++ 20 files changed, 197 insertions(+), 245 deletions(-) diff --git a/packages/core/src/util/error.ts b/packages/core/src/util/error.ts index 9d3b7c661a..7338571f29 100644 --- a/packages/core/src/util/error.ts +++ b/packages/core/src/util/error.ts @@ -1,8 +1,8 @@ -import z from "zod" +import { Schema } from "effect" export abstract class NamedError extends Error { - abstract schema(): z.core.$ZodType - abstract toObject(): { name: string; data: any } + abstract schema(): Schema.Top + abstract toObject(): { name: string; data: unknown } static hasName(error: unknown, name: string): boolean { return ( @@ -10,30 +10,42 @@ export abstract class NamedError extends Error { ) } - static create(name: Name, data: Data) { - const schema = z - .object({ - name: z.literal(name), - data, - }) - .meta({ - ref: name, - }) + static create( + name: Name, + fields: Fields, + ): ReturnType>> + static create( + name: Name, + data: DataSchema, + ): ReturnType> + static create(name: Name, data: Schema.Top | Schema.Struct.Fields) { + return NamedError.createSchemaClass(name, Schema.isSchema(data) ? data : Schema.Struct(data)) + } + + private static createSchemaClass(name: Name, data: DataSchema) { + const schema = Schema.Struct({ + name: Schema.Literal(name), + data, + }).annotate({ identifier: name }) + type Data = Schema.Schema.Type + const result = class extends NamedError { public static readonly Schema = schema + public static readonly EffectSchema = schema + public static readonly tag = name - public override readonly name = name as Name + public override readonly name = name constructor( - public readonly data: z.input, + public readonly data: Data, options?: ErrorOptions, ) { super(name, options) this.name = name } - static isInstance(input: any): input is InstanceType { - return typeof input === "object" && "name" in input && input.name === name + static isInstance(input: unknown): input is InstanceType { + return NamedError.hasName(input, name) } schema() { @@ -51,10 +63,7 @@ export abstract class NamedError extends Error { return result } - public static readonly Unknown = NamedError.create( - "UnknownError", - z.object({ - message: z.string(), - }), - ) + public static readonly Unknown = NamedError.create("UnknownError", { + message: Schema.String, + }) } diff --git a/packages/enterprise/src/routes/share/[shareID].tsx b/packages/enterprise/src/routes/share/[shareID].tsx index b12afce27a..7cfb2bb4a7 100644 --- a/packages/enterprise/src/routes/share/[shareID].tsx +++ b/packages/enterprise/src/routes/share/[shareID].tsx @@ -15,7 +15,6 @@ import { Binary } from "@opencode-ai/core/util/binary" import { NamedError } from "@opencode-ai/core/util/error" import { DateTime } from "luxon" import { createStore } from "solid-js/store" -import z from "zod" import NotFound from "../[...404]" import { Tabs } from "@opencode-ai/ui/tabs" import { MessageNav } from "@opencode-ai/ui/message-nav" @@ -33,13 +32,28 @@ const ClientOnlyWorkerPoolProvider = clientOnly(() => })), ) -const SessionDataMissingError = NamedError.create( - "SessionDataMissingError", - z.object({ - sessionID: z.string(), - message: z.string().optional(), - }), -) +class SessionDataMissingError extends NamedError { + public override readonly name = "SessionDataMissingError" + + constructor( + public readonly data: { sessionID: string; message?: string }, + options?: ErrorOptions, + ) { + super("SessionDataMissingError", options) + } + + static isInstance(input: unknown): input is SessionDataMissingError { + return NamedError.hasName(input, "SessionDataMissingError") + } + + schema(): never { + throw new Error("SessionDataMissingError does not expose a schema") + } + + toObject() { + return { name: this.name, data: this.data } + } +} const getData = query(async (shareID) => { "use server" diff --git a/packages/opencode/src/cli/ui.ts b/packages/opencode/src/cli/ui.ts index 7b4cf7f345..69e04b925a 100644 --- a/packages/opencode/src/cli/ui.ts +++ b/packages/opencode/src/cli/ui.ts @@ -1,6 +1,6 @@ -import z from "zod" import { EOL } from "os" import { NamedError } from "@opencode-ai/core/util/error" +import { Schema } from "effect" import { logo as glyphs } from "./logo" const wordmark = [ @@ -10,7 +10,7 @@ const wordmark = [ `▀▀▀▀ █▀▀▀ ▀▀▀▀ ▀ ▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀`, ] -export const CancelledError = NamedError.create("UICancelledError", z.void()) +export const CancelledError = NamedError.create("UICancelledError", Schema.optional(Schema.Void)) export const Style = { TEXT_HIGHLIGHT: "\x1b[96m", diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index e44405f42e..d00c97f463 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -2,7 +2,6 @@ import * as Log from "@opencode-ai/core/util/log" import path from "path" import { pathToFileURL } from "url" import os from "os" -import z from "zod" import { mergeDeep } from "remeda" import { Global } from "@opencode-ai/core/global" import fsNode from "fs/promises" @@ -357,14 +356,11 @@ function writableGlobal(info: Info) { return next } -export const ConfigDirectoryTypoError = NamedError.create( - "ConfigDirectoryTypoError", - z.object({ - path: z.string(), - dir: z.string(), - suggestion: z.string(), - }), -) +export const ConfigDirectoryTypoError = NamedError.create("ConfigDirectoryTypoError", { + path: Schema.String, + dir: Schema.String, + suggestion: Schema.String, +}) export const layer = Layer.effect( Service, diff --git a/packages/opencode/src/config/error.ts b/packages/opencode/src/config/error.ts index c43598048a..17d74fc1c3 100644 --- a/packages/opencode/src/config/error.ts +++ b/packages/opencode/src/config/error.ts @@ -1,21 +1,23 @@ export * as ConfigError from "./error" -import z from "zod" import { NamedError } from "@opencode-ai/core/util/error" +import { Schema } from "effect" -export const JsonError = NamedError.create( - "ConfigJsonError", - z.object({ - path: z.string(), - message: z.string().optional(), +const Issue = Schema.StructWithRest( + Schema.Struct({ + message: Schema.String, + path: Schema.Array(Schema.String), }), + [Schema.Record(Schema.String, Schema.Unknown)], ) -export const InvalidError = NamedError.create( - "ConfigInvalidError", - z.object({ - path: z.string(), - issues: z.custom().optional(), - message: z.string().optional(), - }), -) +export const JsonError = NamedError.create("ConfigJsonError", { + path: Schema.String, + message: Schema.optional(Schema.String), +}) + +export const InvalidError = NamedError.create("ConfigInvalidError", { + path: Schema.String, + issues: Schema.optional(Schema.Array(Issue)), + message: Schema.optional(Schema.String), +}) diff --git a/packages/opencode/src/config/markdown.ts b/packages/opencode/src/config/markdown.ts index 390f7f8b06..820f4bf642 100644 --- a/packages/opencode/src/config/markdown.ts +++ b/packages/opencode/src/config/markdown.ts @@ -1,6 +1,6 @@ import { NamedError } from "@opencode-ai/core/util/error" import matter from "gray-matter" -import { z } from "zod" +import { Schema } from "effect" import { Filesystem } from "@/util/filesystem" export const FILE_REGEX = /(?>( keys: extra, path: [], message: `Unrecognized key${extra.length === 1 ? "" : "s"}: ${extra.join(", ")}`, - } as z.core.$ZodIssue, + }, ], }) } @@ -61,8 +60,12 @@ export function schema>( { path: source, issues: EffectSchema.isSchemaError(error) - ? (SchemaIssue.makeFormatterStandardSchemaV1()(error.issue).issues as z.core.$ZodIssue[]) - : ([{ code: "custom", message: String(error), path: [] }] as z.core.$ZodIssue[]), + ? SchemaIssue.makeFormatterStandardSchemaV1()(error.issue).issues.map((issue) => ({ + ...issue, + message: issue.message, + path: issue.path?.map(String) ?? [], + })) + : [{ message: String(error), path: [] }], }, { cause: error }, ) diff --git a/packages/opencode/src/ide/index.ts b/packages/opencode/src/ide/index.ts index 2df293f163..a31c5bd057 100644 --- a/packages/opencode/src/ide/index.ts +++ b/packages/opencode/src/ide/index.ts @@ -1,5 +1,4 @@ import { BusEvent } from "@/bus/bus-event" -import z from "zod" import { Schema } from "effect" import { NamedError } from "@opencode-ai/core/util/error" import * as Log from "@opencode-ai/core/util/log" @@ -24,14 +23,11 @@ export const Event = { ), } -export const AlreadyInstalledError = NamedError.create("AlreadyInstalledError", z.object({})) +export const AlreadyInstalledError = NamedError.create("AlreadyInstalledError", {}) -export const InstallFailedError = NamedError.create( - "InstallFailedError", - z.object({ - stderr: z.string(), - }), -) +export const InstallFailedError = NamedError.create("InstallFailedError", { + stderr: Schema.String, +}) export function ide() { if (process.env["TERM_PROGRAM"] === "vscode") { diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index 4c8e447041..d20f29dd4d 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -39,6 +39,7 @@ import { PluginCommand } from "./cli/cmd/plug" import { Heap } from "./cli/heap" import { drizzle } from "drizzle-orm/bun-sqlite" import { ensureProcessMetadata } from "@opencode-ai/core/util/opencode-process" +import { isRecord } from "@/util/record" const processMetadata = ensureProcessMetadata("main") @@ -203,13 +204,6 @@ try { } } catch (e) { let data: Record = {} - if (e instanceof NamedError) { - const obj = e.toObject() - Object.assign(data, { - ...obj.data, - }) - } - if (e instanceof Error) { Object.assign(data, { name: e.name, @@ -219,6 +213,16 @@ try { }) } + if (e instanceof NamedError) { + const obj = e.toObject() + if (isRecord(obj.data)) { + for (const [key, value] of Object.entries(obj.data)) { + if (key === "name" || key === "stack" || key === "cause") continue + data[key] = value + } + } + } + if (e instanceof ResolveMessage) { Object.assign(data, { name: e.name, diff --git a/packages/opencode/src/lsp/client.ts b/packages/opencode/src/lsp/client.ts index 809ea95091..ac9706fc36 100644 --- a/packages/opencode/src/lsp/client.ts +++ b/packages/opencode/src/lsp/client.ts @@ -7,7 +7,6 @@ import type { Diagnostic as VSCodeDiagnostic } from "vscode-languageserver-types import * as Log from "@opencode-ai/core/util/log" import { Process } from "@/util/process" import { LANGUAGE_EXTENSIONS } from "./language" -import z from "zod" import { Schema } from "effect" import type * as LSPServer from "./server" import { NamedError } from "@opencode-ai/core/util/error" @@ -32,12 +31,9 @@ export type Info = NonNullable>> export type Diagnostic = VSCodeDiagnostic -export const InitializeError = NamedError.create( - "LSPInitializeError", - z.object({ - serverID: z.string(), - }), -) +export const InitializeError = NamedError.create("LSPInitializeError", { + serverID: Schema.String, +}) export const Event = { Diagnostics: BusEvent.define( diff --git a/packages/opencode/src/mcp/index.ts b/packages/opencode/src/mcp/index.ts index db43412f73..992825dd63 100644 --- a/packages/opencode/src/mcp/index.ts +++ b/packages/opencode/src/mcp/index.ts @@ -68,12 +68,9 @@ export const BrowserOpenFailed = BusEvent.define( }), ) -export const Failed = NamedError.create( - "MCPFailed", - z.object({ - name: z.string(), - }), -) +export const Failed = NamedError.create("MCPFailed", { + name: Schema.String, +}) type MCPClient = Client diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index 4dae820382..f797e2dc3d 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -382,9 +382,7 @@ export type Part = const AssistantErrorSchema = Schema.Union([ AuthError.EffectSchema, - Schema.Struct({ name: Schema.Literal("UnknownError"), data: Schema.Struct({ message: Schema.String }) }).annotate({ - identifier: "UnknownError", - }), + NamedError.Unknown.EffectSchema, OutputLengthError.EffectSchema, AbortedError.EffectSchema, StructuredOutputError.EffectSchema, diff --git a/packages/opencode/src/session/message.ts b/packages/opencode/src/session/message.ts index 16c010003a..6a859ffaa4 100644 --- a/packages/opencode/src/session/message.ts +++ b/packages/opencode/src/session/message.ts @@ -3,6 +3,7 @@ import { SessionID } from "./schema" import { ModelID, ProviderID } from "../provider/schema" import { NonNegativeInt } from "@opencode-ai/core/schema" import { namedSchemaError } from "@/util/named-schema-error" +import { NamedError } from "@opencode-ai/core/util/error" export const OutputLengthError = namedSchemaError("MessageOutputLengthError", {}) export const AuthError = namedSchemaError("ProviderAuthError", { @@ -10,26 +11,6 @@ export const AuthError = namedSchemaError("ProviderAuthError", { message: Schema.String, }) -const AuthErrorEffect = Schema.Struct({ - name: Schema.Literal("ProviderAuthError"), - data: Schema.Struct({ - providerID: Schema.String, - message: Schema.String, - }), -}) - -const OutputLengthErrorEffect = Schema.Struct({ - name: Schema.Literal("MessageOutputLengthError"), - data: Schema.Struct({}), -}) - -const UnknownErrorEffect = Schema.Struct({ - name: Schema.Literal("UnknownError"), - data: Schema.Struct({ - message: Schema.String, - }), -}) - export const ToolCall = Schema.Struct({ state: Schema.Literal("call"), step: Schema.optional(NonNegativeInt), @@ -124,7 +105,9 @@ export const Info = Schema.Struct({ created: NonNegativeInt, completed: Schema.optional(NonNegativeInt), }), - error: Schema.optional(Schema.Union([AuthErrorEffect, UnknownErrorEffect, OutputLengthErrorEffect])), + error: Schema.optional( + Schema.Union([AuthError.EffectSchema, NamedError.Unknown.EffectSchema, OutputLengthError.EffectSchema]), + ), sessionID: SessionID, tool: Schema.Record( Schema.String, diff --git a/packages/opencode/src/session/retry.ts b/packages/opencode/src/session/retry.ts index 1f73dee31f..463bc27a95 100644 --- a/packages/opencode/src/session/retry.ts +++ b/packages/opencode/src/session/retry.ts @@ -2,6 +2,7 @@ import type { NamedError } from "@opencode-ai/core/util/error" import { Cause, Clock, Duration, Effect, Schedule } from "effect" import { MessageV2 } from "./message-v2" import { iife } from "@/util/iife" +import { isRecord } from "@/util/record" export type Err = ReturnType @@ -121,7 +122,7 @@ export function retryable(error: Err, provider: string) { } // Check for rate limit patterns in plain text error messages - const msg = error.data?.message + const msg = isRecord(error.data) ? error.data.message : undefined if (typeof msg === "string") { const lower = msg.toLowerCase() if ( @@ -133,7 +134,7 @@ export function retryable(error: Err, provider: string) { } } - const json = parseJSON(error.data?.message) + const json = parseJSON(msg) if (!json || typeof json !== "object") return undefined const code = typeof json.code === "string" ? json.code : "" diff --git a/packages/opencode/src/skill/index.ts b/packages/opencode/src/skill/index.ts index a0cc383d0f..59dfeb0804 100644 --- a/packages/opencode/src/skill/index.ts +++ b/packages/opencode/src/skill/index.ts @@ -1,6 +1,5 @@ import path from "path" import { pathToFileURL } from "url" -import z from "zod" import { Effect, Layer, Context, Schema } from "effect" import { NamedError } from "@opencode-ai/core/util/error" import type { Agent } from "@/agent/agent" @@ -16,6 +15,7 @@ import { Glob } from "@opencode-ai/core/util/glob" import * as Log from "@opencode-ai/core/util/log" import { Discovery } from "./discovery" import CUSTOMIZE_OPENCODE_SKILL_BODY from "./prompt/customize-opencode.md" with { type: "text" } +import { isRecord } from "@/util/record" const log = Log.create({ service: "skill" }) const CLAUDE_EXTERNAL_DIR = ".claude" @@ -41,23 +41,33 @@ export const Info = Schema.Struct({ }) export type Info = Schema.Schema.Type -export const InvalidError = NamedError.create( - "SkillInvalidError", - z.object({ - path: z.string(), - message: z.string().optional(), - issues: z.custom().optional(), +const Issue = Schema.StructWithRest( + Schema.Struct({ + message: Schema.String, + path: Schema.Array(Schema.String), }), + [Schema.Record(Schema.String, Schema.Unknown)], ) -export const NameMismatchError = NamedError.create( - "SkillNameMismatchError", - z.object({ - path: z.string(), - expected: z.string(), - actual: z.string(), - }), -) +function isSkillFrontmatter(data: unknown): data is { name: string; description?: string } { + return ( + isRecord(data) && + typeof data.name === "string" && + (data.description === undefined || typeof data.description === "string") + ) +} + +export const InvalidError = NamedError.create("SkillInvalidError", { + path: Schema.String, + message: Schema.optional(Schema.String), + issues: Schema.optional(Schema.Array(Issue)), +}) + +export const NameMismatchError = NamedError.create("SkillNameMismatchError", { + path: Schema.String, + expected: Schema.String, + actual: Schema.String, +}) type State = { skills: Record @@ -101,21 +111,20 @@ const add = Effect.fnUntraced(function* (state: State, match: string, bus: Bus.I if (!md) return - const parsed = z.object({ name: z.string(), description: z.string().optional() }).safeParse(md.data) - if (!parsed.success) return + if (!isSkillFrontmatter(md.data)) return - if (state.skills[parsed.data.name]) { + if (state.skills[md.data.name]) { log.warn("duplicate skill name", { - name: parsed.data.name, - existing: state.skills[parsed.data.name].location, + name: md.data.name, + existing: state.skills[md.data.name].location, duplicate: match, }) } state.dirs.add(path.dirname(match)) - state.skills[parsed.data.name] = { - name: parsed.data.name, - description: parsed.data.description, + state.skills[md.data.name] = { + name: md.data.name, + description: md.data.description, location: match, content: md.content, } diff --git a/packages/opencode/src/storage/db.ts b/packages/opencode/src/storage/db.ts index 06cb99f97f..86e14da560 100644 --- a/packages/opencode/src/storage/db.ts +++ b/packages/opencode/src/storage/db.ts @@ -7,7 +7,6 @@ import { lazy } from "../util/lazy" import { Global } from "@opencode-ai/core/global" import * as Log from "@opencode-ai/core/util/log" import { NamedError } from "@opencode-ai/core/util/error" -import z from "zod" import path from "path" import { readFileSync, readdirSync, existsSync } from "fs" import { Flag } from "@opencode-ai/core/flag/flag" @@ -15,15 +14,13 @@ import { InstallationChannel } from "@opencode-ai/core/installation/version" import { InstanceState } from "@/effect/instance-state" import { iife } from "@/util/iife" import { init } from "#db" +import { Schema } from "effect" declare const OPENCODE_MIGRATIONS: { sql: string; timestamp: number; name: string }[] | undefined -export const NotFoundError = NamedError.create( - "NotFoundError", - z.object({ - message: z.string(), - }), -) +export const NotFoundError = NamedError.create("NotFoundError", { + message: Schema.String, +}) const log = Log.create({ service: "db" }) diff --git a/packages/opencode/src/storage/storage.ts b/packages/opencode/src/storage/storage.ts index bc4d8b8f17..e1f5f681bb 100644 --- a/packages/opencode/src/storage/storage.ts +++ b/packages/opencode/src/storage/storage.ts @@ -2,7 +2,6 @@ import * as Log from "@opencode-ai/core/util/log" import path from "path" import { Global } from "@opencode-ai/core/global" import { NamedError } from "@opencode-ai/core/util/error" -import z from "zod" import { AppFileSystem } from "@opencode-ai/core/filesystem" import { Effect, Exit, Layer, Option, RcMap, Schema, Context, TxReentrantLock } from "effect" import { NonNegativeInt } from "@opencode-ai/core/schema" @@ -16,12 +15,9 @@ type Migration = ( git: Git.Interface, ) => Effect.Effect -export const NotFoundError = NamedError.create( - "NotFoundError", - z.object({ - message: z.string(), - }), -) +export const NotFoundError = NamedError.create("NotFoundError", { + message: Schema.String, +}) export type Error = AppFileSystem.Error | InstanceType diff --git a/packages/opencode/src/util/named-schema-error.ts b/packages/opencode/src/util/named-schema-error.ts index a5ff0828ea..cc02c3731a 100644 --- a/packages/opencode/src/util/named-schema-error.ts +++ b/packages/opencode/src/util/named-schema-error.ts @@ -1,51 +1,9 @@ import { Schema } from "effect" +import { NamedError } from "@opencode-ai/core/util/error" /** * Create a Schema-backed NamedError-shaped class. - * - * Drop-in replacement for `NamedError.create(tag, zodShape)` but backed by - * `Schema.Struct` under the hood. The wire shape emitted by the derived - * `.Schema` is still `{ name: tag, data: {...fields} }` so the generated - * OpenAPI/SDK output is byte-identical to the original NamedError schema. - * - * Preserves the existing surface: - * - static `Schema` (Effect schema of the wire shape) - * - static `isInstance(x)` - * - instance `toObject()` returning `{ name, data }` - * - `new X({ ...data }, { cause })` */ export function namedSchemaError(tag: Tag, fields: Fields) { - const dataSchema = Schema.Struct(fields) - // Wire shape matches the original NamedError output so the SDK stays stable. - const effectSchema = Schema.Struct({ - name: Schema.Literal(tag), - data: dataSchema, - }).annotate({ identifier: tag }) - - type Data = Schema.Schema.Type - - class NamedSchemaError extends Error { - static readonly Schema = effectSchema - static readonly EffectSchema = effectSchema - static readonly tag = tag - public static isInstance(input: unknown): input is NamedSchemaError { - return typeof input === "object" && input !== null && "name" in input && (input as { name: unknown }).name === tag - } - - public override readonly name: Tag = tag - public readonly data: Data - - constructor(data: Data, options?: ErrorOptions) { - super(tag, options) - this.data = data - } - - toObject(): { name: Tag; data: Data } { - return { name: tag, data: this.data } - } - } - - Object.defineProperty(NamedSchemaError, "name", { value: tag }) - - return NamedSchemaError + return NamedError.create(tag, fields) } diff --git a/packages/opencode/src/worktree/index.ts b/packages/opencode/src/worktree/index.ts index 439f36e0a9..7d02189261 100644 --- a/packages/opencode/src/worktree/index.ts +++ b/packages/opencode/src/worktree/index.ts @@ -1,4 +1,3 @@ -import z from "zod" import { NamedError } from "@opencode-ai/core/util/error" import { Global } from "@opencode-ai/core/global" import { InstanceLayer } from "@/project/instance-layer" @@ -65,54 +64,33 @@ export const ResetInput = Schema.Struct({ }).annotate({ identifier: "WorktreeResetInput" }) export type ResetInput = Schema.Schema.Type -export const NotGitError = NamedError.create( - "WorktreeNotGitError", - z.object({ - message: z.string(), - }), -) +export const NotGitError = NamedError.create("WorktreeNotGitError", { + message: Schema.String, +}) -export const NameGenerationFailedError = NamedError.create( - "WorktreeNameGenerationFailedError", - z.object({ - message: z.string(), - }), -) +export const NameGenerationFailedError = NamedError.create("WorktreeNameGenerationFailedError", { + message: Schema.String, +}) -export const CreateFailedError = NamedError.create( - "WorktreeCreateFailedError", - z.object({ - message: z.string(), - }), -) +export const CreateFailedError = NamedError.create("WorktreeCreateFailedError", { + message: Schema.String, +}) -export const StartCommandFailedError = NamedError.create( - "WorktreeStartCommandFailedError", - z.object({ - message: z.string(), - }), -) +export const StartCommandFailedError = NamedError.create("WorktreeStartCommandFailedError", { + message: Schema.String, +}) -export const RemoveFailedError = NamedError.create( - "WorktreeRemoveFailedError", - z.object({ - message: z.string(), - }), -) +export const RemoveFailedError = NamedError.create("WorktreeRemoveFailedError", { + message: Schema.String, +}) -export const ResetFailedError = NamedError.create( - "WorktreeResetFailedError", - z.object({ - message: z.string(), - }), -) +export const ResetFailedError = NamedError.create("WorktreeResetFailedError", { + message: Schema.String, +}) -export const ListFailedError = NamedError.create( - "WorktreeListFailedError", - z.object({ - message: z.string(), - }), -) +export const ListFailedError = NamedError.create("WorktreeListFailedError", { + message: Schema.String, +}) function slugify(input: string) { return input diff --git a/packages/opencode/test/util/error.test.ts b/packages/opencode/test/util/error.test.ts index e7a02d6151..bc966133ab 100644 --- a/packages/opencode/test/util/error.test.ts +++ b/packages/opencode/test/util/error.test.ts @@ -1,5 +1,9 @@ import { describe, expect, test } from "bun:test" +import { Schema } from "effect" +import { NamedError } from "@opencode-ai/core/util/error" import { errorData, errorFormat, errorMessage } from "../../src/util/error" +import { namedSchemaError } from "../../src/util/named-schema-error" +import { UI } from "../../src/cli/ui" describe("util.error", () => { test("formats native Error instances", () => { @@ -48,4 +52,18 @@ describe("util.error", () => { expect(data.message).toBe("ResolveMessage: Cannot resolve module") expect(String(data.formatted)).toContain("ResolveMessage") }) + + test("named schema errors are real NamedError instances", () => { + const ExampleError = namedSchemaError("ExampleError", { message: Schema.String }) + const error = new ExampleError({ message: "boom" }) + + expect(error).toBeInstanceOf(NamedError) + expect(error.toObject()).toEqual({ name: "ExampleError", data: { message: "boom" } }) + }) + + test("void named errors accept JSON without data", () => { + const serialized = JSON.parse(JSON.stringify(new UI.CancelledError(undefined).toObject())) + + expect(Schema.decodeUnknownOption(UI.CancelledError.Schema)(serialized)._tag).toBe("Some") + }) }) From 0de3b67cc04102c895a9138049083cc81b81be8b Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 12 May 2026 09:28:46 -0400 Subject: [PATCH 14/70] test(tool): migrate shell tests to Effect runner (#26968) --- packages/opencode/test/tool/shell.test.ts | 1643 ++++++++++----------- 1 file changed, 780 insertions(+), 863 deletions(-) diff --git a/packages/opencode/test/tool/shell.test.ts b/packages/opencode/test/tool/shell.test.ts index 287844141f..4b4cc8c885 100644 --- a/packages/opencode/test/tool/shell.test.ts +++ b/packages/opencode/test/tool/shell.test.ts @@ -1,14 +1,13 @@ -import { describe, expect, test } from "bun:test" -import { Effect, Layer, ManagedRuntime } from "effect" +import { describe, expect } from "bun:test" +import { Cause, Effect, Exit, Layer } from "effect" +import type * as Scope from "effect/Scope" import os from "os" import path from "path" import { Config } from "@/config/config" import { Shell } from "../../src/shell/shell" import { ShellTool } from "../../src/tool/shell" -import { Instance } from "../../src/project/instance" -import { WithInstance } from "../../src/project/with-instance" import { Filesystem } from "@/util/filesystem" -import { tmpdir } from "../fixture/fixture" +import { provideInstance, tmpdirScoped } from "../fixture/fixture" import type { Permission } from "../../src/permission" import { Agent } from "../../src/agent/agent" import { Truncate } from "@/tool/truncate" @@ -16,23 +15,48 @@ import { SessionID, MessageID } from "../../src/session/schema" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { AppFileSystem } from "@opencode-ai/core/filesystem" import { Plugin } from "../../src/plugin" +import { testEffect } from "../lib/effect" +import { Tool } from "@/tool/tool" -const runtime = ManagedRuntime.make( - Layer.mergeAll( - CrossSpawnSpawner.defaultLayer, - AppFileSystem.defaultLayer, - Plugin.defaultLayer, - Truncate.defaultLayer, - Config.defaultLayer, - Agent.defaultLayer, - ), +const shellLayer = Layer.mergeAll( + CrossSpawnSpawner.defaultLayer, + AppFileSystem.defaultLayer, + Plugin.defaultLayer, + Truncate.defaultLayer, + Config.defaultLayer, + Agent.defaultLayer, ) +const it = testEffect(shellLayer) +type ShellTestServices = (typeof shellLayer extends Layer.Layer ? ROut : never) | Scope.Scope -function initBash() { - return runtime.runPromise(ShellTool.pipe(Effect.flatMap((info) => info.init()))) -} +const initShell = Effect.fn("ShellToolTest.init")(function* () { + const info = yield* ShellTool + return yield* info.init() +}) -const initShell = initBash +const initBash = initShell + +const run = Effect.fn("ShellToolTest.run")(function* ( + args: Tool.InferParameters, + next: Tool.Context = ctx, +) { + const bash = yield* initShell() + return yield* bash.execute(args, next) +}) + +const runIn = (directory: string, self: Effect.Effect) => self.pipe(provideInstance(directory)) + +const fail = Effect.fn("ShellToolTest.fail")(function* ( + args: Tool.InferParameters, + next: Tool.Context = ctx, +) { + const exit = yield* run(args, next).pipe(Effect.exit) + if (Exit.isFailure(exit)) { + const err = Cause.squash(exit.cause) + return err instanceof Error ? err : new Error(String(err)) + } + throw new Error("expected command to fail") +}) const ctx = { sessionID: SessionID.make("ses_test"), @@ -96,27 +120,31 @@ const forms = (dir: string) => { return Array.from(new Set([full, slash, root, root.toLowerCase()])) } -const withShell = (item: { label: string; shell: string }, fn: () => Promise) => async () => { - const prev = process.env.SHELL - process.env.SHELL = item.shell - Shell.acceptable.reset() - Shell.preferred.reset() - try { - await fn() - } finally { - if (prev === undefined) delete process.env.SHELL - else process.env.SHELL = prev - Shell.acceptable.reset() - Shell.preferred.reset() - } -} +const withShell = (item: { label: string; shell: string }, self: Effect.Effect) => + Effect.acquireUseRelease( + Effect.sync(() => { + const prev = process.env.SHELL + process.env.SHELL = item.shell + Shell.acceptable.reset() + Shell.preferred.reset() + return prev + }), + () => self, + (prev) => + Effect.sync(() => { + if (prev === undefined) delete process.env.SHELL + else process.env.SHELL = prev + Shell.acceptable.reset() + Shell.preferred.reset() + }), + ) -const each = (name: string, fn: (item: { label: string; shell: string }) => Promise) => { +const each = ( + name: string, + fn: (item: { label: string; shell: string }) => Effect.Effect, +) => { for (const item of shells) { - test( - `${name} [${item.label}]`, - withShell(item, () => fn(item)), - ) + it.live(`${name} [${item.label}]`, () => withShell(item, fn(item))) } } @@ -140,277 +168,248 @@ const mustTruncate = (result: { } describe("tool.shell", () => { - each("basic", async () => { - await WithInstance.provide({ - directory: projectRoot, - fn: async () => { - const bash = await initShell() - const result = await Effect.runPromise( - bash.execute( - { - command: "echo test", - description: "Echo test message", - }, - ctx, - ), - ) + each("basic", () => + runIn( + projectRoot, + Effect.gen(function* () { + const result = yield* run({ + command: "echo test", + description: "Echo test message", + }) expect(result.metadata.exit).toBe(0) expect(result.metadata.output).toContain("test") - }, - }) - }) + }), + ), + ) - test("falls back from terminal-only configured shell", async () => { - await using tmp = await tmpdir({ - config: { shell: "fish" }, - }) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const bash = await initBash() - const fallback = Shell.name(Shell.acceptable("fish")) - expect(fallback).not.toBe("fish") - expect(bash.description).toContain(fallback) + it.live("falls back from terminal-only configured shell", () => + Effect.gen(function* () { + const tmp = yield* tmpdirScoped({ config: { shell: "fish" } }) + yield* runIn( + tmp, + Effect.gen(function* () { + const bash = yield* initBash() + const fallback = Shell.name(Shell.acceptable("fish")) + expect(fallback).not.toBe("fish") + expect(bash.description).toContain(fallback) - const result = await Effect.runPromise( - bash.execute( + const result = yield* bash.execute( { command: "echo fallback", description: "Echo fallback text", }, ctx, - ), - ) - expect(result.metadata.exit).toBe(0) - expect(result.output).toContain("fallback") - }, - }) - }) + ) + expect(result.metadata.exit).toBe(0) + expect(result.output).toContain("fallback") + }), + ) + }), + ) }) describe("tool.shell permissions", () => { - each("asks for bash permission with correct pattern", async () => { - await using tmp = await tmpdir() - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const bash = await initShell() - const requests: Array> = [] - await Effect.runPromise( - bash.execute( + each("asks for bash permission with correct pattern", () => + Effect.gen(function* () { + const tmp = yield* tmpdirScoped() + yield* runIn( + tmp, + Effect.gen(function* () { + const requests: Array> = [] + yield* run( { command: "echo hello", description: "Echo hello", }, capture(requests), - ), - ) - expect(requests.length).toBe(1) - expect(requests[0].permission).toBe("bash") - expect(requests[0].patterns).toContain("echo hello") - }, - }) - }) + ) + expect(requests.length).toBe(1) + expect(requests[0].permission).toBe("bash") + expect(requests[0].patterns).toContain("echo hello") + }), + ) + }), + ) - each("asks for bash permission with multiple commands", async () => { - await using tmp = await tmpdir() - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const bash = await initShell() - const requests: Array> = [] - await Effect.runPromise( - bash.execute( + each("asks for bash permission with multiple commands", () => + Effect.gen(function* () { + const tmp = yield* tmpdirScoped() + yield* runIn( + tmp, + Effect.gen(function* () { + const requests: Array> = [] + yield* run( { command: "echo foo && echo bar", description: "Echo twice", }, capture(requests), - ), - ) - expect(requests.length).toBe(1) - expect(requests[0].permission).toBe("bash") - expect(requests[0].patterns).toContain("echo foo") - expect(requests[0].patterns).toContain("echo bar") - }, - }) - }) + ) + expect(requests.length).toBe(1) + expect(requests[0].permission).toBe("bash") + expect(requests[0].patterns).toContain("echo foo") + expect(requests[0].patterns).toContain("echo bar") + }), + ) + }), + ) for (const item of ps) { - test( - `parses PowerShell conditionals for permission prompts [${item.label}]`, - withShell(item, async () => { - await WithInstance.provide({ - directory: projectRoot, - fn: async () => { - const bash = await initShell() + it.live(`parses PowerShell conditionals for permission prompts [${item.label}]`, () => + withShell( + item, + runIn( + projectRoot, + Effect.gen(function* () { const requests: Array> = [] - await Effect.runPromise( - bash.execute( - { - command: "Write-Host foo; if ($?) { Write-Host bar }", - description: "Check PowerShell conditional", - }, - capture(requests), - ), + yield* run( + { + command: "Write-Host foo; if ($?) { Write-Host bar }", + description: "Check PowerShell conditional", + }, + capture(requests), ) const bashReq = requests.find((r) => r.permission === "bash") expect(bashReq).toBeDefined() expect(bashReq!.patterns).toContain("Write-Host foo") expect(bashReq!.patterns).toContain("Write-Host bar") expect(bashReq!.always).toContain("Write-Host *") - }, - }) - }), + }), + ), + ), ) } for (const item of ps) { - test( - `uses PowerShell cmdlet prefixes for always-allow prompts [${item.label}]`, - withShell(item, async () => { - await using tmp = await tmpdir() - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const bash = await initShell() - const err = new Error("stop after permission") - const requests: Array> = [] - await expect( - Effect.runPromise( - bash.execute( + it.live(`uses PowerShell cmdlet prefixes for always-allow prompts [${item.label}]`, () => + withShell( + item, + Effect.gen(function* () { + const tmp = yield* tmpdirScoped() + yield* runIn( + tmp, + Effect.gen(function* () { + const err = new Error("stop after permission") + const requests: Array> = [] + expect( + yield* fail( { command: "Remove-Item -Recurse tmp", description: "Remove a temp directory", }, capture(requests, err), ), - ), - ).rejects.toThrow(err.message) - const bashReq = requests.find((r) => r.permission === "bash") - expect(bashReq).toBeDefined() - expect(bashReq!.always).toContain("Remove-Item *") - expect(bashReq!.always).not.toContain("Remove-Item -Recurse *") - }, - }) - }), + ).toMatchObject({ message: err.message }) + const bashReq = requests.find((r) => r.permission === "bash") + expect(bashReq).toBeDefined() + expect(bashReq!.always).toContain("Remove-Item *") + expect(bashReq!.always).not.toContain("Remove-Item -Recurse *") + }), + ) + }), + ), ) } - each("asks for external_directory permission for wildcard external paths", async () => { - await WithInstance.provide({ - directory: projectRoot, - fn: async () => { - const bash = await initShell() + each("asks for external_directory permission for wildcard external paths", () => + runIn( + projectRoot, + Effect.gen(function* () { const err = new Error("stop after permission") const requests: Array> = [] const file = process.platform === "win32" ? `${process.env.WINDIR!.replaceAll("\\", "/")}/*` : "/etc/*" const want = process.platform === "win32" ? glob(path.join(process.env.WINDIR!, "*")) : "/etc/*" - await expect( - Effect.runPromise( - bash.execute( - { - command: `cat ${file}`, - description: "Read wildcard path", - }, - capture(requests, err), - ), + expect( + yield* fail( + { + command: `cat ${file}`, + description: "Read wildcard path", + }, + capture(requests, err), ), - ).rejects.toThrow(err.message) + ).toMatchObject({ message: err.message }) const extDirReq = requests.find((r) => r.permission === "external_directory") expect(extDirReq).toBeDefined() expect(extDirReq!.patterns).toContain(want) - }, - }) - }) + }), + ), + ) if (process.platform === "win32") { if (bash) { - test( - "asks for nested bash command permissions [bash]", - withShell({ label: "bash", shell: bash }, async () => { - await using outerTmp = await tmpdir({ - init: async (dir) => { - await Bun.write(path.join(dir, "outside.txt"), "x") - }, - }) - await WithInstance.provide({ - directory: projectRoot, - fn: async () => { - const bash = await initShell() - const file = path.join(outerTmp.path, "outside.txt").replaceAll("\\", "/") - const requests: Array> = [] - await Effect.runPromise( - bash.execute( + it.live("asks for nested bash command permissions [bash]", () => + withShell( + { label: "bash", shell: bash }, + Effect.gen(function* () { + const outerTmp = yield* tmpdirScoped() + yield* Effect.promise(() => Bun.write(path.join(outerTmp, "outside.txt"), "x")) + yield* runIn( + projectRoot, + Effect.gen(function* () { + const file = path.join(outerTmp, "outside.txt").replaceAll("\\", "/") + const requests: Array> = [] + yield* run( { command: `echo $(cat "${file}")`, description: "Read nested bash file", }, capture(requests), - ), - ) - const extDirReq = requests.find((r) => r.permission === "external_directory") - const bashReq = requests.find((r) => r.permission === "bash") - expect(extDirReq).toBeDefined() - expect(extDirReq!.patterns).toContain(glob(path.join(outerTmp.path, "*"))) - expect(bashReq).toBeDefined() - expect(bashReq!.patterns).toContain(`cat "${file}"`) - }, - }) - }), + ) + const extDirReq = requests.find((r) => r.permission === "external_directory") + const bashReq = requests.find((r) => r.permission === "bash") + expect(extDirReq).toBeDefined() + expect(extDirReq!.patterns).toContain(glob(path.join(outerTmp, "*"))) + expect(bashReq).toBeDefined() + expect(bashReq!.patterns).toContain(`cat "${file}"`) + }), + ) + }), + ), ) } - } - if (process.platform === "win32") { for (const item of ps) { - test( - `asks for external_directory permission for PowerShell paths after switches [${item.label}]`, - withShell(item, async () => { - await WithInstance.provide({ - directory: projectRoot, - fn: async () => { - const bash = await initShell() + it.live(`asks for external_directory permission for PowerShell paths after switches [${item.label}]`, () => + withShell( + item, + runIn( + projectRoot, + Effect.gen(function* () { const err = new Error("stop after permission") const requests: Array> = [] - await expect( - Effect.runPromise( - bash.execute( - { - command: `Copy-Item -PassThru "${process.env.WINDIR!.replaceAll("\\", "/")}/win.ini" ./out`, - description: "Copy Windows ini", - }, - capture(requests, err), - ), + expect( + yield* fail( + { + command: `Copy-Item -PassThru "${process.env.WINDIR!.replaceAll("\\", "/")}/win.ini" ./out`, + description: "Copy Windows ini", + }, + capture(requests, err), ), - ).rejects.toThrow(err.message) + ).toMatchObject({ message: err.message }) const extDirReq = requests.find((r) => r.permission === "external_directory") expect(extDirReq).toBeDefined() expect(extDirReq!.patterns).toContain(glob(path.join(process.env.WINDIR!, "*"))) - }, - }) - }), + }), + ), + ), ) } for (const item of ps) { - test( - `asks for nested PowerShell command permissions [${item.label}]`, - withShell(item, async () => { - await WithInstance.provide({ - directory: projectRoot, - fn: async () => { - const bash = await initShell() + it.live(`asks for nested PowerShell command permissions [${item.label}]`, () => + withShell( + item, + runIn( + projectRoot, + Effect.gen(function* () { const requests: Array> = [] const file = `${process.env.WINDIR!.replaceAll("\\", "/")}/win.ini` - await Effect.runPromise( - bash.execute( - { - command: `Write-Output $(Get-Content ${file})`, - description: "Read nested PowerShell file", - }, - capture(requests), - ), + yield* run( + { + command: `Write-Output $(Get-Content ${file})`, + description: "Read nested PowerShell file", + }, + capture(requests), ) const extDirReq = requests.find((r) => r.permission === "external_directory") const bashReq = requests.find((r) => r.permission === "bash") @@ -418,283 +417,266 @@ describe("tool.shell permissions", () => { expect(extDirReq!.patterns).toContain(glob(path.join(process.env.WINDIR!, "*"))) expect(bashReq).toBeDefined() expect(bashReq!.patterns).toContain(`Get-Content ${file}`) - }, - }) - }), + }), + ), + ), ) } for (const item of ps) { - test( - `asks for external_directory permission for drive-relative PowerShell paths [${item.label}]`, - withShell(item, async () => { - await using tmp = await tmpdir() - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const bash = await initShell() - const err = new Error("stop after permission") - const requests: Array> = [] - await expect( - Effect.runPromise( - bash.execute( + it.live(`asks for external_directory permission for drive-relative PowerShell paths [${item.label}]`, () => + withShell( + item, + Effect.gen(function* () { + const tmp = yield* tmpdirScoped() + yield* runIn( + tmp, + Effect.gen(function* () { + const err = new Error("stop after permission") + const requests: Array> = [] + expect( + yield* fail( { command: 'Get-Content "C:../outside.txt"', description: "Read drive-relative file", }, capture(requests, err), ), - ), - ).rejects.toThrow(err.message) - expect(requests[0]?.permission).toBe("external_directory") - if (requests[0]?.permission !== "external_directory") return - expect(requests[0].patterns).toContain(glob(path.join(path.dirname(tmp.path), "*"))) - }, - }) - }), + ).toMatchObject({ message: err.message }) + expect(requests[0]?.permission).toBe("external_directory") + if (requests[0]?.permission !== "external_directory") return + expect(requests[0].patterns).toContain(glob(path.join(path.dirname(tmp), "*"))) + }), + ) + }), + ), ) } for (const item of ps) { - test( - `asks for external_directory permission for $HOME PowerShell paths [${item.label}]`, - withShell(item, async () => { - await WithInstance.provide({ - directory: projectRoot, - fn: async () => { - const bash = await initShell() + it.live(`asks for external_directory permission for $HOME PowerShell paths [${item.label}]`, () => + withShell( + item, + runIn( + projectRoot, + Effect.gen(function* () { const err = new Error("stop after permission") const requests: Array> = [] - await expect( - Effect.runPromise( - bash.execute( - { - command: 'Get-Content "$HOME/.ssh/config"', - description: "Read home config", - }, - capture(requests, err), - ), + expect( + yield* fail( + { + command: 'Get-Content "$HOME/.ssh/config"', + description: "Read home config", + }, + capture(requests, err), ), - ).rejects.toThrow(err.message) + ).toMatchObject({ message: err.message }) expect(requests[0]?.permission).toBe("external_directory") if (requests[0]?.permission !== "external_directory") return expect(requests[0].patterns).toContain(glob(path.join(os.homedir(), ".ssh", "*"))) - }, - }) - }), + }), + ), + ), ) } for (const item of ps) { - test( - `asks for external_directory permission for $PWD PowerShell paths [${item.label}]`, - withShell(item, async () => { - await using tmp = await tmpdir() - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const bash = await initBash() - const err = new Error("stop after permission") - const requests: Array> = [] - await expect( - Effect.runPromise( - bash.execute( + it.live(`asks for external_directory permission for $PWD PowerShell paths [${item.label}]`, () => + withShell( + item, + Effect.gen(function* () { + const tmp = yield* tmpdirScoped() + yield* runIn( + tmp, + Effect.gen(function* () { + const err = new Error("stop after permission") + const requests: Array> = [] + expect( + yield* fail( { command: 'Get-Content "$PWD/../outside.txt"', description: "Read pwd-relative file", }, capture(requests, err), ), - ), - ).rejects.toThrow(err.message) - expect(requests[0]?.permission).toBe("external_directory") - if (requests[0]?.permission !== "external_directory") return - expect(requests[0].patterns).toContain(glob(path.join(path.dirname(tmp.path), "*"))) - }, - }) - }), + ).toMatchObject({ message: err.message }) + expect(requests[0]?.permission).toBe("external_directory") + if (requests[0]?.permission !== "external_directory") return + expect(requests[0].patterns).toContain(glob(path.join(path.dirname(tmp), "*"))) + }), + ) + }), + ), ) } for (const item of ps) { - test( - `asks for external_directory permission for $PSHOME PowerShell paths [${item.label}]`, - withShell(item, async () => { - await WithInstance.provide({ - directory: projectRoot, - fn: async () => { - const bash = await initBash() + it.live(`asks for external_directory permission for $PSHOME PowerShell paths [${item.label}]`, () => + withShell( + item, + runIn( + projectRoot, + Effect.gen(function* () { const err = new Error("stop after permission") const requests: Array> = [] - await expect( - Effect.runPromise( - bash.execute( - { - command: 'Get-Content "$PSHOME/outside.txt"', - description: "Read pshome file", - }, - capture(requests, err), - ), + expect( + yield* fail( + { + command: 'Get-Content "$PSHOME/outside.txt"', + description: "Read pshome file", + }, + capture(requests, err), ), - ).rejects.toThrow(err.message) + ).toMatchObject({ message: err.message }) expect(requests[0]?.permission).toBe("external_directory") if (requests[0]?.permission !== "external_directory") return expect(requests[0].patterns).toContain(glob(path.join(path.dirname(item.shell), "*"))) - }, - }) - }), + }), + ), + ), ) } for (const item of ps) { - test( - `asks for external_directory permission for missing PowerShell env paths [${item.label}]`, - withShell(item, async () => { - const key = "OPENCODE_TEST_MISSING" - const prev = process.env[key] - delete process.env[key] - try { - await WithInstance.provide({ - directory: projectRoot, - fn: async () => { - const bash = await initShell() - const err = new Error("stop after permission") - const requests: Array> = [] - const root = path.parse(process.env.WINDIR!).root.replace(/[\\/]+$/, "") - await expect( - Effect.runPromise( - bash.execute( + it.live(`asks for external_directory permission for missing PowerShell env paths [${item.label}]`, () => + withShell( + item, + Effect.acquireUseRelease( + Effect.sync(() => { + const key = "OPENCODE_TEST_MISSING" + const prev = process.env[key] + delete process.env[key] + return { key, prev } + }), + ({ key }) => + runIn( + projectRoot, + Effect.gen(function* () { + const err = new Error("stop after permission") + const requests: Array> = [] + const root = path.parse(process.env.WINDIR!).root.replace(/[\\/]+$/, "") + expect( + yield* fail( { command: `Get-Content -Path "${root}$env:${key}\\Windows\\win.ini"`, description: "Read Windows ini with missing env", }, capture(requests, err), ), - ), - ).rejects.toThrow(err.message) - const extDirReq = requests.find((r) => r.permission === "external_directory") - expect(extDirReq).toBeDefined() - expect(extDirReq!.patterns).toContain(glob(path.join(process.env.WINDIR!, "*"))) - }, - }) - } finally { - if (prev === undefined) delete process.env[key] - else process.env[key] = prev - } - }), + ).toMatchObject({ message: err.message }) + const extDirReq = requests.find((r) => r.permission === "external_directory") + expect(extDirReq).toBeDefined() + expect(extDirReq!.patterns).toContain(glob(path.join(process.env.WINDIR!, "*"))) + }), + ), + ({ key, prev }) => + Effect.sync(() => { + if (prev === undefined) delete process.env[key] + else process.env[key] = prev + }), + ), + ), ) } for (const item of ps) { - test( - `asks for external_directory permission for PowerShell env paths [${item.label}]`, - withShell(item, async () => { - await WithInstance.provide({ - directory: projectRoot, - fn: async () => { - const bash = await initBash() + it.live(`asks for external_directory permission for PowerShell env paths [${item.label}]`, () => + withShell( + item, + runIn( + projectRoot, + Effect.gen(function* () { const requests: Array> = [] - await Effect.runPromise( - bash.execute( - { - command: "Get-Content $env:WINDIR/win.ini", - description: "Read Windows ini from env", - }, - capture(requests), - ), + yield* run( + { + command: "Get-Content $env:WINDIR/win.ini", + description: "Read Windows ini from env", + }, + capture(requests), ) const extDirReq = requests.find((r) => r.permission === "external_directory") expect(extDirReq).toBeDefined() expect(extDirReq!.patterns).toContain( Filesystem.normalizePathPattern(path.join(process.env.WINDIR!, "*")), ) - }, - }) - }), + }), + ), + ), ) } for (const item of ps) { - test( - `asks for external_directory permission for PowerShell FileSystem paths [${item.label}]`, - withShell(item, async () => { - await WithInstance.provide({ - directory: projectRoot, - fn: async () => { - const bash = await initBash() + it.live(`asks for external_directory permission for PowerShell FileSystem paths [${item.label}]`, () => + withShell( + item, + runIn( + projectRoot, + Effect.gen(function* () { const err = new Error("stop after permission") const requests: Array> = [] - await expect( - Effect.runPromise( - bash.execute( - { - command: `Get-Content -Path FileSystem::${process.env.WINDIR!.replaceAll("\\", "/")}/win.ini`, - description: "Read Windows ini from FileSystem provider", - }, - capture(requests, err), - ), - ), - ).rejects.toThrow(err.message) - expect(requests[0]?.permission).toBe("external_directory") - if (requests[0]?.permission !== "external_directory") return - expect(requests[0].patterns).toContain( - Filesystem.normalizePathPattern(path.join(process.env.WINDIR!, "*")), - ) - }, - }) - }), - ) - } - - for (const item of ps) { - test( - `asks for external_directory permission for braced PowerShell env paths [${item.label}]`, - withShell(item, async () => { - await WithInstance.provide({ - directory: projectRoot, - fn: async () => { - const bash = await initBash() - const err = new Error("stop after permission") - const requests: Array> = [] - await expect( - Effect.runPromise( - bash.execute( - { - command: "Get-Content ${env:WINDIR}/win.ini", - description: "Read Windows ini from braced env", - }, - capture(requests, err), - ), - ), - ).rejects.toThrow(err.message) - expect(requests[0]?.permission).toBe("external_directory") - if (requests[0]?.permission !== "external_directory") return - expect(requests[0].patterns).toContain( - Filesystem.normalizePathPattern(path.join(process.env.WINDIR!, "*")), - ) - }, - }) - }), - ) - } - - for (const item of ps) { - test( - `treats Set-Location like cd for permissions [${item.label}]`, - withShell(item, async () => { - await WithInstance.provide({ - directory: projectRoot, - fn: async () => { - const bash = await initBash() - const requests: Array> = [] - await Effect.runPromise( - bash.execute( + expect( + yield* fail( { - command: "Set-Location C:/Windows", - description: "Change location", + command: `Get-Content -Path FileSystem::${process.env.WINDIR!.replaceAll("\\", "/")}/win.ini`, + description: "Read Windows ini from FileSystem provider", }, - capture(requests), + capture(requests, err), ), + ).toMatchObject({ message: err.message }) + expect(requests[0]?.permission).toBe("external_directory") + if (requests[0]?.permission !== "external_directory") return + expect(requests[0].patterns).toContain( + Filesystem.normalizePathPattern(path.join(process.env.WINDIR!, "*")), + ) + }), + ), + ), + ) + } + + for (const item of ps) { + it.live(`asks for external_directory permission for braced PowerShell env paths [${item.label}]`, () => + withShell( + item, + runIn( + projectRoot, + Effect.gen(function* () { + const err = new Error("stop after permission") + const requests: Array> = [] + expect( + yield* fail( + { + command: "Get-Content ${env:WINDIR}/win.ini", + description: "Read Windows ini from braced env", + }, + capture(requests, err), + ), + ).toMatchObject({ message: err.message }) + expect(requests[0]?.permission).toBe("external_directory") + if (requests[0]?.permission !== "external_directory") return + expect(requests[0].patterns).toContain( + Filesystem.normalizePathPattern(path.join(process.env.WINDIR!, "*")), + ) + }), + ), + ), + ) + } + + for (const item of ps) { + it.live(`treats Set-Location like cd for permissions [${item.label}]`, () => + withShell( + item, + runIn( + projectRoot, + Effect.gen(function* () { + const requests: Array> = [] + yield* run( + { + command: "Set-Location C:/Windows", + description: "Change location", + }, + capture(requests), ) const extDirReq = requests.find((r) => r.permission === "external_directory") const bashReq = requests.find((r) => r.permission === "bash") @@ -703,104 +685,96 @@ describe("tool.shell permissions", () => { Filesystem.normalizePathPattern(path.join(process.env.WINDIR!, "*")), ) expect(bashReq).toBeUndefined() - }, - }) - }), + }), + ), + ), ) } for (const item of ps) { - test( - `does not add nested PowerShell expressions to permission prompts [${item.label}]`, - withShell(item, async () => { - await WithInstance.provide({ - directory: projectRoot, - fn: async () => { - const bash = await initShell() + it.live(`does not add nested PowerShell expressions to permission prompts [${item.label}]`, () => + withShell( + item, + runIn( + projectRoot, + Effect.gen(function* () { const requests: Array> = [] - await Effect.runPromise( - bash.execute( - { - command: "Write-Output ('a' * 3)", - description: "Write repeated text", - }, - capture(requests), - ), + yield* run( + { + command: "Write-Output ('a' * 3)", + description: "Write repeated text", + }, + capture(requests), ) const bashReq = requests.find((r) => r.permission === "bash") expect(bashReq).toBeDefined() expect(bashReq!.patterns).not.toContain("a * 3") expect(bashReq!.always).not.toContain("a *") - }, - }) - }), + }), + ), + ), ) } } if (process.platform === "win32" && cmdShell) { - test( - "asks for external_directory permission for cmd file commands [cmd]", - withShell(cmdShell, async () => { - await WithInstance.provide({ - directory: projectRoot, - fn: async () => { - const bash = await initShell() + it.live("asks for external_directory permission for cmd file commands [cmd]", () => + withShell( + cmdShell, + runIn( + projectRoot, + Effect.gen(function* () { const requests: Array> = [] - await Effect.runPromise( - bash.execute( - { - command: `TYPE "${path.join(process.env.WINDIR!, "win.ini")}"`, - description: "Read Windows ini with cmd", - }, - capture(requests), - ), + yield* run( + { + command: `TYPE "${path.join(process.env.WINDIR!, "win.ini")}"`, + description: "Read Windows ini with cmd", + }, + capture(requests), ) const extDirReq = requests.find((r) => r.permission === "external_directory") expect(extDirReq).toBeDefined() expect(extDirReq!.patterns).toContain(Filesystem.normalizePathPattern(path.join(process.env.WINDIR!, "*"))) - }, - }) - }), + }), + ), + ), ) } - each("asks for external_directory permission when cd to parent", async () => { - await using tmp = await tmpdir() - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const bash = await initBash() - const err = new Error("stop after permission") - const requests: Array> = [] - await expect( - Effect.runPromise( - bash.execute( + each("asks for external_directory permission when cd to parent", () => + Effect.gen(function* () { + const tmp = yield* tmpdirScoped() + yield* runIn( + tmp, + Effect.gen(function* () { + const err = new Error("stop after permission") + const requests: Array> = [] + expect( + yield* fail( { command: "cd ../", description: "Change to parent directory", }, capture(requests, err), ), - ), - ).rejects.toThrow(err.message) - const extDirReq = requests.find((r) => r.permission === "external_directory") - expect(extDirReq).toBeDefined() - }, - }) - }) + ).toMatchObject({ message: err.message }) + const extDirReq = requests.find((r) => r.permission === "external_directory") + expect(extDirReq).toBeDefined() + }), + ) + }), + ) - each("asks for external_directory permission when workdir is outside project", async () => { - await using tmp = await tmpdir() - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const bash = await initBash() - const err = new Error("stop after permission") - const requests: Array> = [] - await expect( - Effect.runPromise( - bash.execute( + each("asks for external_directory permission when workdir is outside project", () => + Effect.gen(function* () { + const tmp = yield* tmpdirScoped() + yield* runIn( + tmp, + Effect.gen(function* () { + const err = new Error("stop after permission") + const requests: Array> = [] + expect( + yield* fail( { command: "echo ok", workdir: os.tmpdir(), @@ -808,31 +782,30 @@ describe("tool.shell permissions", () => { }, capture(requests, err), ), - ), - ).rejects.toThrow(err.message) - const extDirReq = requests.find((r) => r.permission === "external_directory") - expect(extDirReq).toBeDefined() - expect(extDirReq!.patterns).toContain(glob(path.join(os.tmpdir(), "*"))) - }, - }) - }) + ).toMatchObject({ message: err.message }) + const extDirReq = requests.find((r) => r.permission === "external_directory") + expect(extDirReq).toBeDefined() + expect(extDirReq!.patterns).toContain(glob(path.join(os.tmpdir(), "*"))) + }), + ) + }), + ) if (process.platform === "win32") { - test("normalizes external_directory workdir variants on Windows", async () => { - const err = new Error("stop after permission") - await using outerTmp = await tmpdir() - await using tmp = await tmpdir() - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const bash = await initBash() - const want = Filesystem.normalizePathPattern(path.join(outerTmp.path, "*")) + it.live("normalizes external_directory workdir variants on Windows", () => + Effect.gen(function* () { + const err = new Error("stop after permission") + const outerTmp = yield* tmpdirScoped() + const tmp = yield* tmpdirScoped() + yield* runIn( + tmp, + Effect.gen(function* () { + const want = Filesystem.normalizePathPattern(path.join(outerTmp, "*")) - for (const dir of forms(outerTmp.path)) { - const requests: Array> = [] - await expect( - Effect.runPromise( - bash.execute( + for (const dir of forms(outerTmp)) { + const requests: Array> = [] + expect( + yield* fail( { command: "echo ok", workdir: dir, @@ -840,240 +813,224 @@ describe("tool.shell permissions", () => { }, capture(requests, err), ), - ), - ).rejects.toThrow(err.message) + ).toMatchObject({ message: err.message }) - const extDirReq = requests.find((r) => r.permission === "external_directory") - expect({ dir, patterns: extDirReq?.patterns, always: extDirReq?.always }).toEqual({ - dir, - patterns: [want], - always: [want], - }) - } - }, - }) - }) + const extDirReq = requests.find((r) => r.permission === "external_directory") + expect({ dir, patterns: extDirReq?.patterns, always: extDirReq?.always }).toEqual({ + dir, + patterns: [want], + always: [want], + }) + } + }), + ) + }), + ) if (bash) { - test( - "uses Git Bash /tmp semantics for external workdir", - withShell({ label: "bash", shell: bash }, async () => { - await WithInstance.provide({ - directory: projectRoot, - fn: async () => { - const bash = await initBash() + it.live("uses Git Bash /tmp semantics for external workdir", () => + withShell( + { label: "bash", shell: bash }, + runIn( + projectRoot, + Effect.gen(function* () { const err = new Error("stop after permission") const requests: Array> = [] const want = glob(path.join(os.tmpdir(), "*")) - await expect( - Effect.runPromise( - bash.execute( - { - command: "echo ok", - workdir: "/tmp", - description: "Echo from Git Bash tmp", - }, - capture(requests, err), - ), + expect( + yield* fail( + { + command: "echo ok", + workdir: "/tmp", + description: "Echo from Git Bash tmp", + }, + capture(requests, err), ), - ).rejects.toThrow(err.message) + ).toMatchObject({ message: err.message }) expect(requests[0]).toMatchObject({ permission: "external_directory", patterns: [want], always: [want], }) - }, - }) - }), + }), + ), + ), ) - test( - "uses Git Bash /tmp semantics for external file paths", - withShell({ label: "bash", shell: bash }, async () => { - await WithInstance.provide({ - directory: projectRoot, - fn: async () => { - const bash = await initBash() + it.live("uses Git Bash /tmp semantics for external file paths", () => + withShell( + { label: "bash", shell: bash }, + runIn( + projectRoot, + Effect.gen(function* () { const err = new Error("stop after permission") const requests: Array> = [] const want = glob(path.join(os.tmpdir(), "*")) - await expect( - Effect.runPromise( - bash.execute( - { - command: "cat /tmp/opencode-does-not-exist", - description: "Read Git Bash tmp file", - }, - capture(requests, err), - ), + expect( + yield* fail( + { + command: "cat /tmp/opencode-does-not-exist", + description: "Read Git Bash tmp file", + }, + capture(requests, err), ), - ).rejects.toThrow(err.message) + ).toMatchObject({ message: err.message }) expect(requests[0]).toMatchObject({ permission: "external_directory", patterns: [want], always: [want], }) - }, - }) - }), + }), + ), + ), ) } } - each("asks for external_directory permission when file arg is outside project", async () => { - await using outerTmp = await tmpdir({ - init: async (dir) => { - await Bun.write(path.join(dir, "outside.txt"), "x") - }, - }) - await using tmp = await tmpdir() - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const bash = await initBash() - const err = new Error("stop after permission") - const requests: Array> = [] - const filepath = path.join(outerTmp.path, "outside.txt") - await expect( - Effect.runPromise( - bash.execute( + each("asks for external_directory permission when file arg is outside project", () => + Effect.gen(function* () { + const outerTmp = yield* tmpdirScoped() + yield* Effect.promise(() => Bun.write(path.join(outerTmp, "outside.txt"), "x")) + const tmp = yield* tmpdirScoped() + yield* runIn( + tmp, + Effect.gen(function* () { + const err = new Error("stop after permission") + const requests: Array> = [] + const filepath = path.join(outerTmp, "outside.txt") + expect( + yield* fail( { command: `cat ${filepath}`, description: "Read external file", }, capture(requests, err), ), - ), - ).rejects.toThrow(err.message) - const extDirReq = requests.find((r) => r.permission === "external_directory") - const expected = glob(path.join(outerTmp.path, "*")) - expect(extDirReq).toBeDefined() - expect(extDirReq!.patterns).toContain(expected) - expect(extDirReq!.always).toContain(expected) - }, - }) - }) + ).toMatchObject({ message: err.message }) + const extDirReq = requests.find((r) => r.permission === "external_directory") + const expected = glob(path.join(outerTmp, "*")) + expect(extDirReq).toBeDefined() + expect(extDirReq!.patterns).toContain(expected) + expect(extDirReq!.always).toContain(expected) + }), + ) + }), + ) - each("does not ask for external_directory permission when rm inside project", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { - await Bun.write(path.join(dir, "tmpfile"), "x") - }, - }) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const bash = await initBash() - const requests: Array> = [] - await Effect.runPromise( - bash.execute( + each("does not ask for external_directory permission when rm inside project", () => + Effect.gen(function* () { + const tmp = yield* tmpdirScoped() + yield* Effect.promise(() => Bun.write(path.join(tmp, "tmpfile"), "x")) + yield* runIn( + tmp, + Effect.gen(function* () { + const requests: Array> = [] + yield* run( { - command: `rm -rf ${path.join(tmp.path, "nested")}`, + command: `rm -rf ${path.join(tmp, "nested")}`, description: "Remove nested dir", }, capture(requests), - ), - ) - const extDirReq = requests.find((r) => r.permission === "external_directory") - expect(extDirReq).toBeUndefined() - }, - }) - }) + ) + const extDirReq = requests.find((r) => r.permission === "external_directory") + expect(extDirReq).toBeUndefined() + }), + ) + }), + ) - each("includes always patterns for auto-approval", async () => { - await using tmp = await tmpdir() - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const bash = await initBash() - const requests: Array> = [] - await Effect.runPromise( - bash.execute( + each("includes always patterns for auto-approval", () => + Effect.gen(function* () { + const tmp = yield* tmpdirScoped() + yield* runIn( + tmp, + Effect.gen(function* () { + const requests: Array> = [] + yield* run( { command: "git log --oneline -5", description: "Git log", }, capture(requests), - ), - ) - expect(requests.length).toBe(1) - expect(requests[0].always.length).toBeGreaterThan(0) - expect(requests[0].always.some((item) => item.endsWith("*"))).toBe(true) - }, - }) - }) + ) + expect(requests.length).toBe(1) + expect(requests[0].always.length).toBeGreaterThan(0) + expect(requests[0].always.some((item) => item.endsWith("*"))).toBe(true) + }), + ) + }), + ) - each("does not ask for bash permission when command is cd only", async () => { - await using tmp = await tmpdir() - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const bash = await initShell() - const requests: Array> = [] - await Effect.runPromise( - bash.execute( + each("does not ask for bash permission when command is cd only", () => + Effect.gen(function* () { + const tmp = yield* tmpdirScoped() + yield* runIn( + tmp, + Effect.gen(function* () { + const requests: Array> = [] + yield* run( { command: "cd .", description: "Stay in current directory", }, capture(requests), - ), - ) - const bashReq = requests.find((r) => r.permission === "bash") - expect(bashReq).toBeUndefined() - }, - }) - }) + ) + const bashReq = requests.find((r) => r.permission === "bash") + expect(bashReq).toBeUndefined() + }), + ) + }), + ) - each("matches redirects in permission pattern", async () => { - await using tmp = await tmpdir() - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const bash = await initShell() - const err = new Error("stop after permission") - const requests: Array> = [] - await expect( - Effect.runPromise( - bash.execute( + each("matches redirects in permission pattern", () => + Effect.gen(function* () { + const tmp = yield* tmpdirScoped() + yield* runIn( + tmp, + Effect.gen(function* () { + const err = new Error("stop after permission") + const requests: Array> = [] + expect( + yield* fail( { command: "echo test > output.txt", description: "Redirect test output" }, capture(requests, err), ), - ), - ).rejects.toThrow(err.message) - const bashReq = requests.find((r) => r.permission === "bash") - expect(bashReq).toBeDefined() - expect(bashReq!.patterns).toContain("echo test > output.txt") - }, - }) - }) + ).toMatchObject({ message: err.message }) + const bashReq = requests.find((r) => r.permission === "bash") + expect(bashReq).toBeDefined() + expect(bashReq!.patterns).toContain("echo test > output.txt") + }), + ) + }), + ) - each("always pattern has space before wildcard to not include different commands", async () => { - await using tmp = await tmpdir() - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const bash = await initBash() - const requests: Array> = [] - await Effect.runPromise(bash.execute({ command: "ls -la", description: "List" }, capture(requests))) - const bashReq = requests.find((r) => r.permission === "bash") - expect(bashReq).toBeDefined() - expect(bashReq!.always[0]).toBe("ls *") - }, - }) - }) + each("always pattern has space before wildcard to not include different commands", () => + Effect.gen(function* () { + const tmp = yield* tmpdirScoped() + yield* runIn( + tmp, + Effect.gen(function* () { + const requests: Array> = [] + yield* run({ command: "ls -la", description: "List" }, capture(requests)) + const bashReq = requests.find((r) => r.permission === "bash") + expect(bashReq).toBeDefined() + expect(bashReq!.always[0]).toBe("ls *") + }), + ) + }), + ) }) describe("tool.shell abort", () => { - test("preserves output when aborted", async () => { - await WithInstance.provide({ - directory: projectRoot, - fn: async () => { - const bash = await initShell() - const controller = new AbortController() - const collected: string[] = [] - const res = await Effect.runPromise( - bash.execute( + it.live( + "preserves output when aborted", + () => + runIn( + projectRoot, + Effect.gen(function* () { + const controller = new AbortController() + const collected: string[] = [] + const res = yield* run( { command: `echo before && sleep 30`, description: "Long running command", @@ -1090,198 +1047,158 @@ describe("tool.shell abort", () => { } }), }, - ), - ) - expect(res.output).toContain("before") - expect(res.output).toContain("User aborted the command") - expect(collected.length).toBeGreaterThan(0) - }, - }) - }, 15_000) + ) + expect(res.output).toContain("before") + expect(res.output).toContain("User aborted the command") + expect(collected.length).toBeGreaterThan(0) + }), + ), + 15_000, + ) - test("terminates command on timeout", async () => { - await WithInstance.provide({ - directory: projectRoot, - fn: async () => { - const bash = await initShell() - const result = await Effect.runPromise( - bash.execute( - { - command: `echo started && sleep 60`, - description: "Timeout test", - timeout: 500, - }, - ctx, - ), - ) - expect(result.output).toContain("started") - expect(result.output).toContain("shell tool terminated command after exceeding timeout") - expect(result.output).toContain("retry with a larger timeout value in milliseconds") - }, - }) - }, 15_000) + it.live( + "terminates command on timeout", + () => + runIn( + projectRoot, + Effect.gen(function* () { + const result = yield* run({ + command: `echo started && sleep 60`, + description: "Timeout test", + timeout: 500, + }) + expect(result.output).toContain("started") + expect(result.output).toContain("shell tool terminated command after exceeding timeout") + expect(result.output).toContain("retry with a larger timeout value in milliseconds") + }), + ), + 15_000, + ) - test.skipIf(process.platform === "win32")("captures stderr in output", async () => { - await WithInstance.provide({ - directory: projectRoot, - fn: async () => { - const bash = await initShell() - const result = await Effect.runPromise( - bash.execute( - { - command: `echo stdout_msg && echo stderr_msg >&2`, - description: "Stderr test", - }, - ctx, - ), - ) - expect(result.output).toContain("stdout_msg") - expect(result.output).toContain("stderr_msg") - expect(result.metadata.exit).toBe(0) - }, - }) - }) + if (process.platform !== "win32") { + it.live("captures stderr in output", () => + runIn( + projectRoot, + Effect.gen(function* () { + const result = yield* run({ + command: `echo stdout_msg && echo stderr_msg >&2`, + description: "Stderr test", + }) + expect(result.output).toContain("stdout_msg") + expect(result.output).toContain("stderr_msg") + expect(result.metadata.exit).toBe(0) + }), + ), + ) + } - test("returns non-zero exit code", async () => { - await WithInstance.provide({ - directory: projectRoot, - fn: async () => { - const bash = await initShell() - const result = await Effect.runPromise( - bash.execute( - { - command: `exit 42`, - description: "Non-zero exit", - }, - ctx, - ), - ) + it.live("returns non-zero exit code", () => + runIn( + projectRoot, + Effect.gen(function* () { + const result = yield* run({ + command: `exit 42`, + description: "Non-zero exit", + }) expect(result.metadata.exit).toBe(42) - }, - }) - }) + }), + ), + ) - test("streams metadata updates progressively", async () => { - await WithInstance.provide({ - directory: projectRoot, - fn: async () => { - const bash = await initBash() + it.live("streams metadata updates progressively", () => + runIn( + projectRoot, + Effect.gen(function* () { const updates: string[] = [] - const result = await Effect.runPromise( - bash.execute( - { - command: `echo first && sleep 0.1 && echo second`, - description: "Streaming test", - }, - { - ...ctx, - metadata: (input) => - Effect.sync(() => { - const output = (input.metadata as { output?: string })?.output - if (output) updates.push(output) - }), - }, - ), + const result = yield* run( + { + command: `echo first && sleep 0.1 && echo second`, + description: "Streaming test", + }, + { + ...ctx, + metadata: (input) => + Effect.sync(() => { + const output = (input.metadata as { output?: string })?.output + if (output) updates.push(output) + }), + }, ) expect(result.output).toContain("first") expect(result.output).toContain("second") expect(updates.length).toBeGreaterThan(1) - }, - }) - }) + }), + ), + ) }) describe("tool.shell truncation", () => { - test("truncates output exceeding line limit", async () => { - await WithInstance.provide({ - directory: projectRoot, - fn: async () => { - const bash = await initShell() + it.live("truncates output exceeding line limit", () => + runIn( + projectRoot, + Effect.gen(function* () { const lineCount = Truncate.MAX_LINES + 500 - const result = await Effect.runPromise( - bash.execute( - { - command: fill("lines", lineCount), - description: "Generate lines exceeding limit", - }, - ctx, - ), - ) + const result = yield* run({ + command: fill("lines", lineCount), + description: "Generate lines exceeding limit", + }) mustTruncate(result) expect(result.output).toMatch(/\.\.\.output truncated\.\.\./) expect(result.output).toMatch(/Full output saved to:\s+\S+/) - }, - }) - }) + }), + ), + ) - test("truncates output exceeding byte limit", async () => { - await WithInstance.provide({ - directory: projectRoot, - fn: async () => { - const bash = await initShell() + it.live("truncates output exceeding byte limit", () => + runIn( + projectRoot, + Effect.gen(function* () { const byteCount = Truncate.MAX_BYTES + 10000 - const result = await Effect.runPromise( - bash.execute( - { - command: fill("bytes", byteCount), - description: "Generate bytes exceeding limit", - }, - ctx, - ), - ) + const result = yield* run({ + command: fill("bytes", byteCount), + description: "Generate bytes exceeding limit", + }) mustTruncate(result) expect(result.output).toMatch(/\.\.\.output truncated\.\.\./) expect(result.output).toMatch(/Full output saved to:\s+\S+/) - }, - }) - }) + }), + ), + ) - test("does not truncate small output", async () => { - await WithInstance.provide({ - directory: projectRoot, - fn: async () => { - const bash = await initShell() - const result = await Effect.runPromise( - bash.execute( - { - command: "echo hello", - description: "Echo hello", - }, - ctx, - ), - ) + it.live("does not truncate small output", () => + runIn( + projectRoot, + Effect.gen(function* () { + const result = yield* run({ + command: fill("lines", 1), + description: "Generate one line", + }) expect((result.metadata as { truncated?: boolean }).truncated).toBe(false) - expect(result.output).toContain("hello") - }, - }) - }) + expect(result.output).toContain("1") + }), + ), + ) - test("full output is saved to file when truncated", async () => { - await WithInstance.provide({ - directory: projectRoot, - fn: async () => { - const bash = await initShell() + it.live("full output is saved to file when truncated", () => + runIn( + projectRoot, + Effect.gen(function* () { const lineCount = Truncate.MAX_LINES + 100 - const result = await Effect.runPromise( - bash.execute( - { - command: fill("lines", lineCount), - description: "Generate lines for file check", - }, - ctx, - ), - ) + const result = yield* run({ + command: fill("lines", lineCount), + description: "Generate lines for file check", + }) mustTruncate(result) const filepath = (result.metadata as { outputPath?: string }).outputPath expect(filepath).toBeTruthy() - const saved = await Filesystem.readText(filepath!) + const saved = yield* Effect.promise(() => Filesystem.readText(filepath!)) const lines = saved.trim().split(/\r?\n/) expect(lines.length).toBe(lineCount) expect(lines[0]).toBe("1") expect(lines[lineCount - 1]).toBe(String(lineCount)) - }, - }) - }) + }), + ), + ) }) From 0fd0facc442b1c9dd10264437d3439207c18653c Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Tue, 12 May 2026 13:31:03 +0000 Subject: [PATCH 15/70] chore: generate --- packages/opencode/test/tool/shell.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/opencode/test/tool/shell.test.ts b/packages/opencode/test/tool/shell.test.ts index 4b4cc8c885..6ce0e5c081 100644 --- a/packages/opencode/test/tool/shell.test.ts +++ b/packages/opencode/test/tool/shell.test.ts @@ -27,7 +27,9 @@ const shellLayer = Layer.mergeAll( Agent.defaultLayer, ) const it = testEffect(shellLayer) -type ShellTestServices = (typeof shellLayer extends Layer.Layer ? ROut : never) | Scope.Scope +type ShellTestServices = + | (typeof shellLayer extends Layer.Layer ? ROut : never) + | Scope.Scope const initShell = Effect.fn("ShellToolTest.init")(function* () { const info = yield* ShellTool From 04aafe2bfcd42ce6fb9b8f118225d92a93e858d1 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 12 May 2026 10:19:49 -0400 Subject: [PATCH 16/70] test(provider): migrate more config-backed cases (#27067) --- .../opencode/test/provider/provider.test.ts | 90 ++++++++----------- 1 file changed, 39 insertions(+), 51 deletions(-) diff --git a/packages/opencode/test/provider/provider.test.ts b/packages/opencode/test/provider/provider.test.ts index ea65c90c4f..2270418beb 100644 --- a/packages/opencode/test/provider/provider.test.ts +++ b/packages/opencode/test/provider/provider.test.ts @@ -582,64 +582,52 @@ it.instance( }, ) -test("model options are merged from existing model", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { - await Bun.write( - path.join(dir, "opencode.json"), - JSON.stringify({ - $schema: "https://opencode.ai/config.json", - provider: { - anthropic: { - models: { - "claude-sonnet-4-20250514": { - options: { - customOption: "custom-value", - }, - }, +it.instance( + "model options are merged from existing model", + Effect.gen(function* () { + const providers = yield* Provider.Service.use((provider) => provider.list()) + const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"] + expect(model.options.customOption).toBe("custom-value") + }), + { + config: { + provider: { + anthropic: { + options: { + apiKey: "test-api-key", + }, + models: { + "claude-sonnet-4-20250514": { + options: { + customOption: "custom-value", }, }, }, - }), - ) + }, + }, }, - }) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - set("ANTHROPIC_API_KEY", "test-api-key") - const providers = await list() - const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"] - expect(model.options.customOption).toBe("custom-value") - }, - }) -}) + }, +) -test("provider removed when all models filtered out", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { - await Bun.write( - path.join(dir, "opencode.json"), - JSON.stringify({ - $schema: "https://opencode.ai/config.json", - provider: { - anthropic: { - whitelist: ["nonexistent-model"], - }, +it.instance( + "provider removed when all models filtered out", + Effect.gen(function* () { + const providers = yield* Provider.Service.use((provider) => provider.list()) + expect(providers[ProviderID.anthropic]).toBeUndefined() + }), + { + config: { + provider: { + anthropic: { + options: { + apiKey: "test-api-key", }, - }), - ) + whitelist: ["nonexistent-model"], + }, + }, }, - }) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - set("ANTHROPIC_API_KEY", "test-api-key") - const providers = await list() - expect(providers[ProviderID.anthropic]).toBeUndefined() - }, - }) -}) + }, +) test("closest finds model by partial match", async () => { await using tmp = await tmpdir({ From 257fcafc8302d7a6f31d8d5d0e733bf9a5ff9181 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 12 May 2026 11:52:31 -0400 Subject: [PATCH 17/70] test(tool): migrate edit concurrency test (#26983) --- packages/opencode/test/tool/edit.test.ts | 110 +++++++++-------------- 1 file changed, 43 insertions(+), 67 deletions(-) diff --git a/packages/opencode/test/tool/edit.test.ts b/packages/opencode/test/tool/edit.test.ts index 572fcd9aa4..6a16828267 100644 --- a/packages/opencode/test/tool/edit.test.ts +++ b/packages/opencode/test/tool/edit.test.ts @@ -1,10 +1,9 @@ -import { afterAll, afterEach, describe, test, expect } from "bun:test" +import { afterEach, describe, expect } from "bun:test" import path from "path" import fs from "fs/promises" -import { Cause, Deferred, Effect, Exit, Layer, ManagedRuntime } from "effect" +import { Cause, Deferred, Effect, Exit, Fiber, Layer } from "effect" import { EditTool } from "../../src/tool/edit" -import { WithInstance } from "../../src/project/with-instance" -import { disposeAllInstances, TestInstance, tmpdir } from "../fixture/fixture" +import { disposeAllInstances, TestInstance } from "../fixture/fixture" import { LSP } from "@/lsp/lsp" import { AppFileSystem } from "@opencode-ai/core/filesystem" import { Format } from "../../src/format" @@ -42,20 +41,6 @@ const layer = Layer.mergeAll( const it = testEffect(layer) -const runtime = ManagedRuntime.make(layer) - -afterAll(async () => { - await runtime.dispose() -}) - -const resolve = () => - runtime.runPromise( - Effect.gen(function* () { - const info = yield* EditTool - return yield* info.init() - }), - ) - const init = Effect.fn("EditToolTest.init")(function* () { const info = yield* EditTool return yield* info.init() @@ -500,58 +485,49 @@ describe("tool.edit", () => { }) describe("concurrent editing", () => { - test("preserves concurrent edits to different sections of the same file", async () => { - await using tmp = await tmpdir() - const filepath = path.join(tmp.path, "file.txt") - await fs.writeFile(filepath, "top = 0\nmiddle = keep\nbottom = 0\n", "utf-8") + it.instance("preserves concurrent edits to different sections of the same file", () => + Effect.gen(function* () { + const test = yield* TestInstance + const filepath = path.join(test.directory, "file.txt") + yield* put(filepath, "top = 0\nmiddle = keep\nbottom = 0\n") - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const edit = await resolve() - let asks = 0 - const firstAsk = Promise.withResolvers() - const delayedCtx = { - ...ctx, - ask: () => - Effect.gen(function* () { - asks++ - if (asks !== 1) return - firstAsk.resolve() - yield* Effect.promise(() => Bun.sleep(50)) - }), - } + const firstAsk = yield* Deferred.make() + let asks = 0 + const delayedCtx = { + ...ctx, + ask: () => + Effect.gen(function* () { + asks++ + if (asks !== 1) return + yield* Deferred.succeed(firstAsk, undefined) + yield* Effect.promise(() => Bun.sleep(50)) + }), + } - const promise1 = Effect.runPromise( - edit.execute( - { - filePath: filepath, - oldString: "top = 0", - newString: "top = 1", - }, - delayedCtx, - ), - ) + const first = yield* run( + { + filePath: filepath, + oldString: "top = 0", + newString: "top = 1", + }, + delayedCtx, + ).pipe(Effect.forkScoped) - await firstAsk.promise + yield* Deferred.await(firstAsk) + yield* Effect.all([ + Fiber.join(first), + run( + { + filePath: filepath, + oldString: "bottom = 0", + newString: "bottom = 2", + }, + delayedCtx, + ), + ]) - const promise2 = Effect.runPromise( - edit.execute( - { - filePath: filepath, - oldString: "bottom = 0", - newString: "bottom = 2", - }, - delayedCtx, - ), - ) - - const results = await Promise.allSettled([promise1, promise2]) - expect(results[0]?.status).toBe("fulfilled") - expect(results[1]?.status).toBe("fulfilled") - expect(await fs.readFile(filepath, "utf-8")).toBe("top = 1\nmiddle = keep\nbottom = 2\n") - }, - }) - }) + expect(yield* load(filepath)).toBe("top = 1\nmiddle = keep\nbottom = 2\n") + }), + ) }) }) From c7d8b0d56562209d69ef6a20424a4681827766bd Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 12 May 2026 12:04:28 -0400 Subject: [PATCH 18/70] Delete named schema error wrapper (#27066) --- packages/opencode/src/provider/auth.ts | 10 ++++----- packages/opencode/src/provider/provider.ts | 6 +++--- .../opencode/src/session/message-error.ts | 14 +++++++++++++ packages/opencode/src/session/message-v2.ts | 21 +++++++------------ packages/opencode/src/session/message.ts | 15 ++++--------- .../opencode/src/util/named-schema-error.ts | 9 -------- packages/opencode/test/util/error.test.ts | 9 ++++---- 7 files changed, 38 insertions(+), 46 deletions(-) create mode 100644 packages/opencode/src/session/message-error.ts delete mode 100644 packages/opencode/src/util/named-schema-error.ts diff --git a/packages/opencode/src/provider/auth.ts b/packages/opencode/src/provider/auth.ts index 42b94ffcc5..ba2a8c7446 100644 --- a/packages/opencode/src/provider/auth.ts +++ b/packages/opencode/src/provider/auth.ts @@ -1,7 +1,7 @@ import type { AuthOAuthResult, Hooks } from "@opencode-ai/plugin" import { Auth } from "@/auth" import { InstanceState } from "@/effect/instance-state" -import { namedSchemaError } from "@/util/named-schema-error" +import { NamedError } from "@opencode-ai/core/util/error" import { optionalOmitUndefined } from "@opencode-ai/core/schema" import { Plugin } from "../plugin" import { ProviderID } from "./schema" @@ -64,13 +64,13 @@ export const CallbackInput = Schema.Struct({ }) export type CallbackInput = Schema.Schema.Type -export const OauthMissing = namedSchemaError("ProviderAuthOauthMissing", { providerID: ProviderID }) +export const OauthMissing = NamedError.create("ProviderAuthOauthMissing", { providerID: ProviderID }) -export const OauthCodeMissing = namedSchemaError("ProviderAuthOauthCodeMissing", { providerID: ProviderID }) +export const OauthCodeMissing = NamedError.create("ProviderAuthOauthCodeMissing", { providerID: ProviderID }) -export const OauthCallbackFailed = namedSchemaError("ProviderAuthOauthCallbackFailed", {}) +export const OauthCallbackFailed = NamedError.create("ProviderAuthOauthCallbackFailed", {}) -export const ValidationFailed = namedSchemaError("ProviderAuthValidationFailed", { +export const ValidationFailed = NamedError.create("ProviderAuthValidationFailed", { field: Schema.String, message: Schema.String, }) diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 236f14de75..f381e848d8 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -13,7 +13,7 @@ import { Auth } from "../auth" import { Env } from "../env" import { InstallationVersion } from "@opencode-ai/core/installation/version" import { Flag } from "@opencode-ai/core/flag/flag" -import { namedSchemaError } from "@/util/named-schema-error" +import { NamedError } from "@opencode-ai/core/util/error" import { iife } from "@/util/iife" import { Global } from "@opencode-ai/core/global" import path from "path" @@ -1749,13 +1749,13 @@ export function parseModel(model: string) { } } -export const ModelNotFoundError = namedSchemaError("ProviderModelNotFoundError", { +export const ModelNotFoundError = NamedError.create("ProviderModelNotFoundError", { providerID: ProviderID, modelID: ModelID, suggestions: Schema.optional(Schema.Array(Schema.String)), }) -export const InitError = namedSchemaError("ProviderInitError", { +export const InitError = NamedError.create("ProviderInitError", { providerID: ProviderID, }) diff --git a/packages/opencode/src/session/message-error.ts b/packages/opencode/src/session/message-error.ts new file mode 100644 index 0000000000..bf40d45be0 --- /dev/null +++ b/packages/opencode/src/session/message-error.ts @@ -0,0 +1,14 @@ +import { Schema } from "effect" +import { NamedError } from "@opencode-ai/core/util/error" + +export const OutputLengthError = NamedError.create("MessageOutputLengthError", {}) + +export const AuthError = NamedError.create("ProviderAuthError", { + providerID: Schema.String, + message: Schema.String, +}) + +export const Shared = [AuthError.EffectSchema, NamedError.Unknown.EffectSchema, OutputLengthError.EffectSchema] as const +export const SharedSchema = Schema.Union(Shared) + +export * as MessageError from "./message-error" diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index f797e2dc3d..626261d0f6 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -23,8 +23,10 @@ import type { Provider } from "@/provider/provider" import { ModelID, ProviderID } from "@/provider/schema" import { Effect, Schema, Types } from "effect" import { NonNegativeInt } from "@opencode-ai/core/schema" -import { namedSchemaError } from "@/util/named-schema-error" import * as EffectLogger from "@opencode-ai/core/effect/logger" +import { MessageError } from "./message-error" +import { AuthError, OutputLengthError } from "./message-error" +export { AuthError, OutputLengthError } from "./message-error" /** Error shape thrown by Bun's fetch() when gzip/br decompression fails mid-stream */ interface FetchDecompressionError extends Error { @@ -36,17 +38,12 @@ interface FetchDecompressionError extends Error { export const SYNTHETIC_ATTACHMENT_PROMPT = "Attached media from tool result:" export { isMedia } -export const OutputLengthError = namedSchemaError("MessageOutputLengthError", {}) -export const AbortedError = namedSchemaError("MessageAbortedError", { message: Schema.String }) -export const StructuredOutputError = namedSchemaError("StructuredOutputError", { +export const AbortedError = NamedError.create("MessageAbortedError", { message: Schema.String }) +export const StructuredOutputError = NamedError.create("StructuredOutputError", { message: Schema.String, retries: NonNegativeInt, }) -export const AuthError = namedSchemaError("ProviderAuthError", { - providerID: Schema.String, - message: Schema.String, -}) -export const APIError = namedSchemaError("APIError", { +export const APIError = NamedError.create("APIError", { message: Schema.String, statusCode: Schema.optional(NonNegativeInt), isRetryable: Schema.Boolean, @@ -55,7 +52,7 @@ export const APIError = namedSchemaError("APIError", { metadata: Schema.optional(Schema.Record(Schema.String, Schema.String)), }) export type APIError = Schema.Schema.Type -export const ContextOverflowError = namedSchemaError("ContextOverflowError", { +export const ContextOverflowError = NamedError.create("ContextOverflowError", { message: Schema.String, responseBody: Schema.optional(Schema.String), }) @@ -381,9 +378,7 @@ export type Part = | CompactionPart const AssistantErrorSchema = Schema.Union([ - AuthError.EffectSchema, - NamedError.Unknown.EffectSchema, - OutputLengthError.EffectSchema, + ...MessageError.Shared, AbortedError.EffectSchema, StructuredOutputError.EffectSchema, ContextOverflowError.EffectSchema, diff --git a/packages/opencode/src/session/message.ts b/packages/opencode/src/session/message.ts index 6a859ffaa4..39c842f94b 100644 --- a/packages/opencode/src/session/message.ts +++ b/packages/opencode/src/session/message.ts @@ -2,14 +2,9 @@ import { Schema } from "effect" import { SessionID } from "./schema" import { ModelID, ProviderID } from "../provider/schema" import { NonNegativeInt } from "@opencode-ai/core/schema" -import { namedSchemaError } from "@/util/named-schema-error" -import { NamedError } from "@opencode-ai/core/util/error" - -export const OutputLengthError = namedSchemaError("MessageOutputLengthError", {}) -export const AuthError = namedSchemaError("ProviderAuthError", { - providerID: Schema.String, - message: Schema.String, -}) +import { MessageError } from "./message-error" +import { AuthError, OutputLengthError } from "./message-error" +export { AuthError, OutputLengthError } from "./message-error" export const ToolCall = Schema.Struct({ state: Schema.Literal("call"), @@ -105,9 +100,7 @@ export const Info = Schema.Struct({ created: NonNegativeInt, completed: Schema.optional(NonNegativeInt), }), - error: Schema.optional( - Schema.Union([AuthError.EffectSchema, NamedError.Unknown.EffectSchema, OutputLengthError.EffectSchema]), - ), + error: Schema.optional(MessageError.SharedSchema), sessionID: SessionID, tool: Schema.Record( Schema.String, diff --git a/packages/opencode/src/util/named-schema-error.ts b/packages/opencode/src/util/named-schema-error.ts deleted file mode 100644 index cc02c3731a..0000000000 --- a/packages/opencode/src/util/named-schema-error.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { Schema } from "effect" -import { NamedError } from "@opencode-ai/core/util/error" - -/** - * Create a Schema-backed NamedError-shaped class. - */ -export function namedSchemaError(tag: Tag, fields: Fields) { - return NamedError.create(tag, fields) -} diff --git a/packages/opencode/test/util/error.test.ts b/packages/opencode/test/util/error.test.ts index bc966133ab..8d077b1f26 100644 --- a/packages/opencode/test/util/error.test.ts +++ b/packages/opencode/test/util/error.test.ts @@ -2,8 +2,8 @@ import { describe, expect, test } from "bun:test" import { Schema } from "effect" import { NamedError } from "@opencode-ai/core/util/error" import { errorData, errorFormat, errorMessage } from "../../src/util/error" -import { namedSchemaError } from "../../src/util/named-schema-error" import { UI } from "../../src/cli/ui" +import { MessageError } from "../../src/session/message-error" describe("util.error", () => { test("formats native Error instances", () => { @@ -53,12 +53,11 @@ describe("util.error", () => { expect(String(data.formatted)).toContain("ResolveMessage") }) - test("named schema errors are real NamedError instances", () => { - const ExampleError = namedSchemaError("ExampleError", { message: Schema.String }) - const error = new ExampleError({ message: "boom" }) + test("schema-backed named errors are real NamedError instances", () => { + const error = new MessageError.AuthError({ providerID: "anthropic", message: "boom" }) expect(error).toBeInstanceOf(NamedError) - expect(error.toObject()).toEqual({ name: "ExampleError", data: { message: "boom" } }) + expect(error.toObject()).toEqual({ name: "ProviderAuthError", data: { providerID: "anthropic", message: "boom" } }) }) test("void named errors accept JSON without data", () => { From 23f8b3eb3e55a0780e4d7bccc4d99825c2c3acc1 Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Tue, 12 May 2026 11:31:18 -0500 Subject: [PATCH 19/70] fix: annotate Effect log metadata (#27093) --- packages/opencode/src/data-migration.ts | 2 +- packages/opencode/src/project/bootstrap.ts | 2 +- packages/opencode/src/project/instance-store.ts | 4 +++- packages/opencode/src/provider/models.ts | 2 +- packages/opencode/src/reference/reference.ts | 4 +++- .../src/server/routes/instance/httpapi/handlers/session.ts | 4 +++- 6 files changed, 12 insertions(+), 6 deletions(-) diff --git a/packages/opencode/src/data-migration.ts b/packages/opencode/src/data-migration.ts index 53e3196b7a..331e0f7500 100644 --- a/packages/opencode/src/data-migration.ts +++ b/packages/opencode/src/data-migration.ts @@ -145,7 +145,7 @@ export const layer = Layer.effect( ) } }).pipe( - Effect.tapCause((cause) => Effect.logError("failed to run data migrations", { cause })), + Effect.tapCause((cause) => Effect.logError("failed to run data migrations").pipe(Effect.annotateLogs("cause", cause))), Effect.ignore, Effect.forkScoped, ) diff --git a/packages/opencode/src/project/bootstrap.ts b/packages/opencode/src/project/bootstrap.ts index 6103a9efb4..a7e67d45e9 100644 --- a/packages/opencode/src/project/bootstrap.ts +++ b/packages/opencode/src/project/bootstrap.ts @@ -37,7 +37,7 @@ export const layer = Layer.effect( const run = Effect.gen(function* () { const ctx = yield* InstanceState.context - yield* Effect.logInfo("bootstrapping", { directory: ctx.directory }) + yield* Effect.logInfo("bootstrapping").pipe(Effect.annotateLogs("directory", ctx.directory)) // everything depends on config so eager load it for nice traces yield* config.get() // Plugin can mutate config so it has to be initialized before anything else. diff --git a/packages/opencode/src/project/instance-store.ts b/packages/opencode/src/project/instance-store.ts index 9707305f93..faa56668a7 100644 --- a/packages/opencode/src/project/instance-store.ts +++ b/packages/opencode/src/project/instance-store.ts @@ -156,7 +156,9 @@ export const layer: Layer.Layer Effect.logError("Failed to fetch models.dev", { cause })), + Effect.tapCause((cause) => Effect.logError("Failed to fetch models.dev").pipe(Effect.annotateLogs("cause", cause))), Effect.ignore, ) }) diff --git a/packages/opencode/src/reference/reference.ts b/packages/opencode/src/reference/reference.ts index 09e0a825d8..748c3b2386 100644 --- a/packages/opencode/src/reference/reference.ts +++ b/packages/opencode/src/reference/reference.ts @@ -169,7 +169,9 @@ export const layer = Layer.effect( ).pipe( Effect.asVoid, Effect.catchCause((cause) => - Effect.logWarning("failed to materialize reference repository", { name: reference.name, cause }), + Effect.logWarning("failed to materialize reference repository").pipe( + Effect.annotateLogs({ name: reference.name, cause }), + ), ), ), ) diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts index 99645f3da3..236eee13f5 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts @@ -299,7 +299,9 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session", yield* promptSvc.prompt({ ...ctx.payload, sessionID: ctx.params.sessionID }).pipe( Effect.catchCause((cause) => Effect.gen(function* () { - yield* Effect.logError("prompt_async failed", { sessionID: ctx.params.sessionID, cause }) + yield* Effect.logError("prompt_async failed").pipe( + Effect.annotateLogs({ sessionID: ctx.params.sessionID, cause }), + ) yield* bus.publish(Session.Event.Error, { sessionID: ctx.params.sessionID, error: new NamedError.Unknown({ message: Cause.pretty(cause) }).toObject(), From 30e3fa1de966e828db43a8f00f265beaad92a455 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Tue, 12 May 2026 16:33:06 +0000 Subject: [PATCH 20/70] chore: generate --- packages/opencode/src/data-migration.ts | 4 +++- packages/opencode/src/provider/models.ts | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/data-migration.ts b/packages/opencode/src/data-migration.ts index 331e0f7500..bec4c9c8a9 100644 --- a/packages/opencode/src/data-migration.ts +++ b/packages/opencode/src/data-migration.ts @@ -145,7 +145,9 @@ export const layer = Layer.effect( ) } }).pipe( - Effect.tapCause((cause) => Effect.logError("failed to run data migrations").pipe(Effect.annotateLogs("cause", cause))), + Effect.tapCause((cause) => + Effect.logError("failed to run data migrations").pipe(Effect.annotateLogs("cause", cause)), + ), Effect.ignore, Effect.forkScoped, ) diff --git a/packages/opencode/src/provider/models.ts b/packages/opencode/src/provider/models.ts index d3d04b552e..e9d2bac1af 100644 --- a/packages/opencode/src/provider/models.ts +++ b/packages/opencode/src/provider/models.ts @@ -177,7 +177,9 @@ export const layer: Layer.Layer Effect.logError("Failed to fetch models.dev").pipe(Effect.annotateLogs("cause", cause))), + Effect.tapCause((cause) => + Effect.logError("Failed to fetch models.dev").pipe(Effect.annotateLogs("cause", cause)), + ), Effect.ignore, ) }) From d658e1e3506f55093ecb894e53fa9ac3cb7cd98a Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 12 May 2026 12:38:27 -0400 Subject: [PATCH 21/70] Remove local MCP Zod schema (#27095) --- packages/opencode/src/mcp/index.ts | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/packages/opencode/src/mcp/index.ts b/packages/opencode/src/mcp/index.ts index 992825dd63..832811b281 100644 --- a/packages/opencode/src/mcp/index.ts +++ b/packages/opencode/src/mcp/index.ts @@ -6,6 +6,7 @@ import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js" import { UnauthorizedError } from "@modelcontextprotocol/sdk/client/auth.js" import { CallToolResultSchema, + ListToolsResultSchema, ToolSchema, type Tool as MCPToolDef, ToolListChangedNotificationSchema, @@ -14,7 +15,6 @@ import { Config } from "@/config/config" import { ConfigMCP } from "../config/mcp" import * as Log from "@opencode-ai/core/util/log" import { NamedError } from "@opencode-ai/core/util/error" -import z from "zod/v4" import { Installation } from "../installation" import { InstallationVersion } from "@opencode-ai/core/installation/version" import { withTimeout } from "@/util/timeout" @@ -35,13 +35,8 @@ import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" const log = Log.create({ service: "mcp" }) const DEFAULT_TIMEOUT = 30_000 -const TolerantToolSchema = ToolSchema.extend({ - outputSchema: z.unknown().optional(), -}) - -const TolerantListToolsResultSchema = z.looseObject({ - tools: z.array(TolerantToolSchema), - nextCursor: z.string().optional(), +const TolerantListToolsResultSchema = ListToolsResultSchema.extend({ + tools: ToolSchema.omit({ outputSchema: true }).array(), }) export const Resource = Schema.Struct({ @@ -137,7 +132,10 @@ function listTools(key: string, client: MCPClient, timeout: number) { log.warn("failed to validate MCP tool output schemas, retrying without output schema validation", { key, error }) return Effect.tryPromise({ - try: () => client.request({ method: "tools/list" }, TolerantListToolsResultSchema, { timeout }), + try: () => + client.request({ method: "tools/list" }, TolerantListToolsResultSchema, { + timeout, + }), catch: (err) => (err instanceof Error ? err : new Error(String(err))), }).pipe( Effect.map((result) => From 3dc2c1d81c3ac6a0e508ddf3cf918e76888faf08 Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Tue, 12 May 2026 22:10:28 +0530 Subject: [PATCH 22/70] fix(session): preserve usage update timestamps (#27094) --- packages/opencode/src/data-migration.ts | 1 + packages/opencode/src/session/projectors.ts | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/data-migration.ts b/packages/opencode/src/data-migration.ts index bec4c9c8a9..b6956032a4 100644 --- a/packages/opencode/src/data-migration.ts +++ b/packages/opencode/src/data-migration.ts @@ -94,6 +94,7 @@ export const layer = Layer.effect( tokens_reasoning: value.tokens.reasoning, tokens_cache_read: value.tokens.cache.read, tokens_cache_write: value.tokens.cache.write, + time_updated: sql`${SessionTable.time_updated}`, }) .where(eq(SessionTable.id, sessionID)) .run() diff --git a/packages/opencode/src/session/projectors.ts b/packages/opencode/src/session/projectors.ts index 8b5cc2bdcc..3dd848c5bc 100644 --- a/packages/opencode/src/session/projectors.ts +++ b/packages/opencode/src/session/projectors.ts @@ -38,6 +38,7 @@ function applyUsage(db: TxOrDb, sessionID: Session.Info["id"], value: Usage, sig tokens_reasoning: sql`${SessionTable.tokens_reasoning} + ${value.tokens.reasoning * sign}`, tokens_cache_read: sql`${SessionTable.tokens_cache_read} + ${value.tokens.cache.read * sign}`, tokens_cache_write: sql`${SessionTable.tokens_cache_write} + ${value.tokens.cache.write * sign}`, + time_updated: sql`${SessionTable.time_updated}`, }) .where(eq(SessionTable.id, sessionID)) .run() @@ -110,7 +111,7 @@ export default [ const info = data.info const row = db .update(SessionTable) - .set(toPartialRow(info as Session.Patch)) + .set({ time_updated: sql`${SessionTable.time_updated}`, ...toPartialRow(info as Session.Patch) }) .where(eq(SessionTable.id, data.sessionID)) .returning() .get() From ec4fdaf8e9275ac0f89d0c1734f4806cb6c7d37a Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 12 May 2026 12:42:19 -0400 Subject: [PATCH 23/70] test(tool): migrate tool define tests to Effect runner (#27097) --- .../opencode/test/tool/tool-define.test.ts | 115 +++++++++--------- 1 file changed, 60 insertions(+), 55 deletions(-) diff --git a/packages/opencode/test/tool/tool-define.test.ts b/packages/opencode/test/tool/tool-define.test.ts index a291b9f7f9..ca351cca48 100644 --- a/packages/opencode/test/tool/tool-define.test.ts +++ b/packages/opencode/test/tool/tool-define.test.ts @@ -1,11 +1,12 @@ -import { describe, test, expect } from "bun:test" -import { Effect, Layer, ManagedRuntime, Schema } from "effect" +import { describe, expect } from "bun:test" +import { Effect, Layer, Schema } from "effect" import { Agent } from "../../src/agent/agent" import { MessageID, SessionID } from "../../src/session/schema" import { Tool } from "@/tool/tool" import { Truncate } from "@/tool/truncate" +import { testEffect } from "../lib/effect" -const runtime = ManagedRuntime.make(Layer.mergeAll(Truncate.defaultLayer, Agent.defaultLayer)) +const it = testEffect(Layer.mergeAll(Truncate.defaultLayer, Agent.defaultLayer)) const params = Schema.Struct({ input: Schema.String }) @@ -21,49 +22,53 @@ function makeTool(id: string, executeFn?: () => void) { } describe("Tool.define", () => { - test("object-defined tool does not mutate the original init object", async () => { - const original = makeTool("test") - const originalExecute = original.execute + it.effect("object-defined tool does not mutate the original init object", () => + Effect.gen(function* () { + const original = makeTool("test") + const originalExecute = original.execute - const info = await runtime.runPromise(Tool.define("test-tool", Effect.succeed(original))) + const info = yield* Tool.define("test-tool", Effect.succeed(original)) - await Effect.runPromise(info.init()) - await Effect.runPromise(info.init()) - await Effect.runPromise(info.init()) + yield* info.init() + yield* info.init() + yield* info.init() - expect(original.execute).toBe(originalExecute) - }) + expect(original.execute).toBe(originalExecute) + }), + ) - test("effect-defined tool returns fresh objects and is unaffected", async () => { - const info = await runtime.runPromise( - Tool.define( + it.effect("effect-defined tool returns fresh objects and is unaffected", () => + Effect.gen(function* () { + const info = yield* Tool.define( "test-fn-tool", Effect.succeed(() => Effect.succeed(makeTool("test"))), - ), - ) + ) - const first = await Effect.runPromise(info.init()) - const second = await Effect.runPromise(info.init()) + const first = yield* info.init() + const second = yield* info.init() - expect(first).not.toBe(second) - }) + expect(first).not.toBe(second) + }), + ) - test("object-defined tool returns distinct objects per init() call", async () => { - const info = await runtime.runPromise(Tool.define("test-copy", Effect.succeed(makeTool("test")))) + it.effect("object-defined tool returns distinct objects per init() call", () => + Effect.gen(function* () { + const info = yield* Tool.define("test-copy", Effect.succeed(makeTool("test"))) - const first = await Effect.runPromise(info.init()) - const second = await Effect.runPromise(info.init()) + const first = yield* info.init() + const second = yield* info.init() - expect(first).not.toBe(second) - }) + expect(first).not.toBe(second) + }), + ) - test("execute receives decoded parameters", async () => { - const parameters = Schema.Struct({ - count: Schema.NumberFromString.pipe(Schema.optional, Schema.withDecodingDefaultType(Effect.succeed(5))), - }) - const calls: Array> = [] - const info = await runtime.runPromise( - Tool.define( + it.effect("execute receives decoded parameters", () => + Effect.gen(function* () { + const parameters = Schema.Struct({ + count: Schema.NumberFromString.pipe(Schema.optional, Schema.withDecodingDefaultType(Effect.succeed(5))), + }) + const calls: Array> = [] + const info = yield* Tool.define( "test-decoded", Effect.succeed({ description: "test tool", @@ -73,27 +78,27 @@ describe("Tool.define", () => { return Effect.succeed({ title: "test", output: "ok", metadata: { truncated: false } }) }, }), - ), - ) - const ctx: Tool.Context = { - sessionID: SessionID.descending(), - messageID: MessageID.ascending(), - agent: "build", - abort: new AbortController().signal, - messages: [], - metadata() { - return Effect.void - }, - ask() { - return Effect.void - }, - } - const tool = await Effect.runPromise(info.init()) - const execute = tool.execute as unknown as (args: unknown, ctx: Tool.Context) => ReturnType + ) + const ctx: Tool.Context = { + sessionID: SessionID.descending(), + messageID: MessageID.ascending(), + agent: "build", + abort: new AbortController().signal, + messages: [], + metadata() { + return Effect.void + }, + ask() { + return Effect.void + }, + } + const tool = yield* info.init() + const execute = tool.execute as unknown as (args: unknown, ctx: Tool.Context) => ReturnType - await Effect.runPromise(execute({}, ctx)) - await Effect.runPromise(execute({ count: "7" }, ctx)) + yield* execute({}, ctx) + yield* execute({ count: "7" }, ctx) - expect(calls).toEqual([{ count: 5 }, { count: 7 }]) - }) + expect(calls).toEqual([{ count: 5 }, { count: 7 }]) + }), + ) }) From 8115004c73559fc3ad0f9eda4066f8d47aa2e8cf Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 12 May 2026 12:42:49 -0400 Subject: [PATCH 24/70] test(file): migrate path traversal tests to Effect runner (#27098) --- .../opencode/test/file/path-traversal.test.ts | 295 ++++++++---------- 1 file changed, 133 insertions(+), 162 deletions(-) diff --git a/packages/opencode/test/file/path-traversal.test.ts b/packages/opencode/test/file/path-traversal.test.ts index 5b59929ea5..28bd34978b 100644 --- a/packages/opencode/test/file/path-traversal.test.ts +++ b/packages/opencode/test/file/path-traversal.test.ts @@ -1,42 +1,55 @@ -import { test, expect, describe } from "bun:test" -import { Effect } from "effect" +import { expect, describe } from "bun:test" +import { Cause, Effect, Exit } from "effect" import path from "path" import fs from "fs/promises" import { Filesystem } from "@/util/filesystem" import { File } from "../../src/file" -import { Instance } from "../../src/project/instance" -import { WithInstance } from "../../src/project/with-instance" +import { InstanceState } from "../../src/effect/instance-state" import { containsPath } from "../../src/project/instance-context" -import { provideInstance, tmpdir } from "../fixture/fixture" +import { TestInstance } from "../fixture/fixture" +import { testEffect } from "../lib/effect" -const run = (eff: Effect.Effect) => - Effect.runPromise(provideInstance(Instance.directory)(eff.pipe(Effect.provide(File.defaultLayer)))) -const read = (file: string) => run(File.Service.use((svc) => svc.read(file))) -const list = (dir?: string) => run(File.Service.use((svc) => svc.list(dir))) +const it = testEffect(File.defaultLayer) +const read = (file: string) => File.Service.use((svc) => svc.read(file)) +const list = (dir?: string) => File.Service.use((svc) => svc.list(dir)) +const expectAccessDenied = (effect: Effect.Effect) => + Effect.gen(function* () { + const exit = yield* effect.pipe(Effect.exit) + if (Exit.isSuccess(exit)) throw new Error("expected access denied") + expect(Cause.squash(exit.cause)).toHaveProperty("message", "Access denied: path escapes project directory") + }) describe("Filesystem.contains", () => { - test("allows paths within project", () => { - expect(Filesystem.contains("/project", "/project/src")).toBe(true) - expect(Filesystem.contains("/project", "/project/src/file.ts")).toBe(true) - expect(Filesystem.contains("/project", "/project")).toBe(true) - }) + it.effect("allows paths within project", () => + Effect.sync(() => { + expect(Filesystem.contains("/project", "/project/src")).toBe(true) + expect(Filesystem.contains("/project", "/project/src/file.ts")).toBe(true) + expect(Filesystem.contains("/project", "/project")).toBe(true) + }), + ) - test("blocks ../ traversal", () => { - expect(Filesystem.contains("/project", "/project/../etc")).toBe(false) - expect(Filesystem.contains("/project", "/project/src/../../etc")).toBe(false) - expect(Filesystem.contains("/project", "/etc/passwd")).toBe(false) - }) + it.effect("blocks ../ traversal", () => + Effect.sync(() => { + expect(Filesystem.contains("/project", "/project/../etc")).toBe(false) + expect(Filesystem.contains("/project", "/project/src/../../etc")).toBe(false) + expect(Filesystem.contains("/project", "/etc/passwd")).toBe(false) + }), + ) - test("blocks absolute paths outside project", () => { - expect(Filesystem.contains("/project", "/etc/passwd")).toBe(false) - expect(Filesystem.contains("/project", "/tmp/file")).toBe(false) - expect(Filesystem.contains("/home/user/project", "/home/user/other")).toBe(false) - }) + it.effect("blocks absolute paths outside project", () => + Effect.sync(() => { + expect(Filesystem.contains("/project", "/etc/passwd")).toBe(false) + expect(Filesystem.contains("/project", "/tmp/file")).toBe(false) + expect(Filesystem.contains("/home/user/project", "/home/user/other")).toBe(false) + }), + ) - test("handles prefix collision edge cases", () => { - expect(Filesystem.contains("/project", "/project-other/file")).toBe(false) - expect(Filesystem.contains("/project", "/projectfile")).toBe(false) - }) + it.effect("handles prefix collision edge cases", () => + Effect.sync(() => { + expect(Filesystem.contains("/project", "/project-other/file")).toBe(false) + expect(Filesystem.contains("/project", "/projectfile")).toBe(false) + }), + ) }) /* @@ -49,158 +62,116 @@ describe("Filesystem.contains", () => { * This is a SEPARATE code path from ReadTool, which has its own checks. */ describe("File.read path traversal protection", () => { - test("rejects ../ traversal attempting to read /etc/passwd", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { - await Bun.write(path.join(dir, "allowed.txt"), "allowed content") - }, - }) + it.instance("rejects ../ traversal attempting to read /etc/passwd", () => + Effect.gen(function* () { + const test = yield* TestInstance + yield* Effect.promise(() => Bun.write(path.join(test.directory, "allowed.txt"), "allowed content")) + yield* expectAccessDenied(read("../../../etc/passwd")) + }), + ) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - await expect(read("../../../etc/passwd")).rejects.toThrow("Access denied: path escapes project directory") - }, - }) - }) + it.instance("rejects deeply nested traversal", () => + Effect.gen(function* () { + yield* expectAccessDenied(read("src/nested/../../../../../../../etc/passwd")) + }), + ) - test("rejects deeply nested traversal", async () => { - await using tmp = await tmpdir() + it.instance("allows valid paths within project", () => + Effect.gen(function* () { + const test = yield* TestInstance + yield* Effect.promise(() => Bun.write(path.join(test.directory, "valid.txt"), "valid content")) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - await expect(read("src/nested/../../../../../../../etc/passwd")).rejects.toThrow( - "Access denied: path escapes project directory", - ) - }, - }) - }) - - test("allows valid paths within project", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { - await Bun.write(path.join(dir, "valid.txt"), "valid content") - }, - }) - - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const result = await read("valid.txt") - expect(result.content).toBe("valid content") - }, - }) - }) + const result = yield* read("valid.txt") + expect(result.content).toBe("valid content") + }), + ) }) describe("File.list path traversal protection", () => { - test("rejects ../ traversal attempting to list /etc", async () => { - await using tmp = await tmpdir() + it.instance("rejects ../ traversal attempting to list /etc", () => + Effect.gen(function* () { + yield* expectAccessDenied(list("../../../etc")) + }), + ) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - await expect(list("../../../etc")).rejects.toThrow("Access denied: path escapes project directory") - }, - }) - }) + it.instance("allows valid subdirectory listing", () => + Effect.gen(function* () { + const test = yield* TestInstance + yield* Effect.promise(() => Bun.write(path.join(test.directory, "subdir", "file.txt"), "content")) - test("allows valid subdirectory listing", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { - await Bun.write(path.join(dir, "subdir", "file.txt"), "content") - }, - }) - - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const result = await list("subdir") - expect(Array.isArray(result)).toBe(true) - }, - }) - }) + const result = yield* list("subdir") + expect(Array.isArray(result)).toBe(true) + }), + ) }) describe("containsPath", () => { - test("returns true for path inside directory", async () => { - await using tmp = await tmpdir({ git: true }) + it.instance("returns true for path inside directory", () => + Effect.gen(function* () { + const test = yield* TestInstance + const ctx = yield* InstanceState.context + expect(containsPath(path.join(test.directory, "foo.txt"), ctx)).toBe(true) + expect(containsPath(path.join(test.directory, "src", "file.ts"), ctx)).toBe(true) + }), + { git: true }, + ) - await WithInstance.provide({ - directory: tmp.path, - fn: () => { - expect(containsPath(path.join(tmp.path, "foo.txt"), Instance.current)).toBe(true) - expect(containsPath(path.join(tmp.path, "src", "file.ts"), Instance.current)).toBe(true) - }, - }) - }) + it.instance( + "returns true for path inside worktree but outside directory (monorepo subdirectory scenario)", + () => + Effect.gen(function* () { + const test = yield* TestInstance + const subdir = path.join(test.directory, "packages", "lib") + yield* Effect.promise(() => fs.mkdir(subdir, { recursive: true })) + const ctx = { ...(yield* InstanceState.context), directory: subdir } - test("returns true for path inside worktree but outside directory (monorepo subdirectory scenario)", async () => { - await using tmp = await tmpdir({ git: true }) - const subdir = path.join(tmp.path, "packages", "lib") - await fs.mkdir(subdir, { recursive: true }) - - await WithInstance.provide({ - directory: subdir, - fn: () => { // .opencode at worktree root, but we're running from packages/lib - expect(containsPath(path.join(tmp.path, ".opencode", "state"), Instance.current)).toBe(true) + expect(containsPath(path.join(test.directory, ".opencode", "state"), ctx)).toBe(true) // sibling package should also be accessible - expect(containsPath(path.join(tmp.path, "packages", "other", "file.ts"), Instance.current)).toBe(true) + expect(containsPath(path.join(test.directory, "packages", "other", "file.ts"), ctx)).toBe(true) // worktree root itself - expect(containsPath(tmp.path, Instance.current)).toBe(true) - }, - }) - }) + expect(containsPath(test.directory, ctx)).toBe(true) + }), + { git: true }, + ) - test("returns false for path outside both directory and worktree", async () => { - await using tmp = await tmpdir({ git: true }) + it.instance("returns false for path outside both directory and worktree", () => + Effect.gen(function* () { + const ctx = yield* InstanceState.context + expect(containsPath("/etc/passwd", ctx)).toBe(false) + expect(containsPath("/tmp/other-project", ctx)).toBe(false) + }), + { git: true }, + ) - await WithInstance.provide({ - directory: tmp.path, - fn: () => { - expect(containsPath("/etc/passwd", Instance.current)).toBe(false) - expect(containsPath("/tmp/other-project", Instance.current)).toBe(false) - }, - }) - }) + it.instance("returns false for path with .. escaping worktree", () => + Effect.gen(function* () { + const test = yield* TestInstance + const ctx = yield* InstanceState.context + expect(containsPath(path.join(test.directory, "..", "escape.txt"), ctx)).toBe(false) + }), + { git: true }, + ) - test("returns false for path with .. escaping worktree", async () => { - await using tmp = await tmpdir({ git: true }) + it.instance("handles directory === worktree (running from repo root)", () => + Effect.gen(function* () { + const test = yield* TestInstance + const ctx = yield* InstanceState.context + expect(ctx.directory).toBe(ctx.worktree) + expect(containsPath(path.join(test.directory, "file.txt"), ctx)).toBe(true) + expect(containsPath("/etc/passwd", ctx)).toBe(false) + }), + { git: true }, + ) - await WithInstance.provide({ - directory: tmp.path, - fn: () => { - expect(containsPath(path.join(tmp.path, "..", "escape.txt"), Instance.current)).toBe(false) - }, - }) - }) - - test("handles directory === worktree (running from repo root)", async () => { - await using tmp = await tmpdir({ git: true }) - - await WithInstance.provide({ - directory: tmp.path, - fn: () => { - expect(Instance.directory).toBe(Instance.worktree) - expect(containsPath(path.join(tmp.path, "file.txt"), Instance.current)).toBe(true) - expect(containsPath("/etc/passwd", Instance.current)).toBe(false) - }, - }) - }) - - test("non-git project does not allow arbitrary paths via worktree='/'", async () => { - await using tmp = await tmpdir() // no git: true - - await WithInstance.provide({ - directory: tmp.path, - fn: () => { - // worktree is "/" for non-git projects, but containsPath should NOT allow all paths - expect(containsPath(path.join(tmp.path, "file.txt"), Instance.current)).toBe(true) - expect(containsPath("/etc/passwd", Instance.current)).toBe(false) - expect(containsPath("/tmp/other", Instance.current)).toBe(false) - }, - }) - }) + it.instance("non-git project does not allow arbitrary paths via worktree='/'", () => + Effect.gen(function* () { + const test = yield* TestInstance + const ctx = yield* InstanceState.context + // worktree is "/" for non-git projects, but containsPath should NOT allow all paths + expect(containsPath(path.join(test.directory, "file.txt"), ctx)).toBe(true) + expect(containsPath("/etc/passwd", ctx)).toBe(false) + expect(containsPath("/tmp/other", ctx)).toBe(false) + }), + ) }) From a16789dfdd47e208054f0d5fdb1d4a3be53b9ada Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 12 May 2026 12:43:33 -0400 Subject: [PATCH 25/70] test(tool): migrate apply patch tests to Effect runner (#27100) --- .../opencode/test/tool/apply_patch.test.ts | 871 ++++++++---------- 1 file changed, 392 insertions(+), 479 deletions(-) diff --git a/packages/opencode/test/tool/apply_patch.test.ts b/packages/opencode/test/tool/apply_patch.test.ts index 3fc034e4e5..190254866d 100644 --- a/packages/opencode/test/tool/apply_patch.test.ts +++ b/packages/opencode/test/tool/apply_patch.test.ts @@ -1,20 +1,19 @@ -import { describe, expect, test } from "bun:test" +import { describe, expect } from "bun:test" import path from "path" import * as fs from "fs/promises" -import { Effect, ManagedRuntime, Layer } from "effect" +import { Cause, Effect, Exit, Layer } from "effect" import { ApplyPatchTool } from "../../src/tool/apply_patch" -import { Instance } from "../../src/project/instance" -import { WithInstance } from "../../src/project/with-instance" import { LSP } from "@/lsp/lsp" import { AppFileSystem } from "@opencode-ai/core/filesystem" import { Format } from "../../src/format" import { Agent } from "../../src/agent/agent" import { Bus } from "../../src/bus" import { Truncate } from "@/tool/truncate" -import { tmpdir } from "../fixture/fixture" +import { TestInstance } from "../fixture/fixture" import { SessionID, MessageID } from "../../src/session/schema" +import { testEffect } from "../lib/effect" -const runtime = ManagedRuntime.make( +const it = testEffect( Layer.mergeAll( LSP.defaultLayer, AppFileSystem.defaultLayer, @@ -58,11 +57,11 @@ type ToolCtx = typeof baseCtx & { ask: (input: AskInput) => Effect.Effect } -const execute = async (params: { patchText: string }, ctx: ToolCtx) => { - const info = await runtime.runPromise(ApplyPatchTool) - const tool = await runtime.runPromise(info.init()) - return Effect.runPromise(tool.execute(params, ctx)) -} +const execute = Effect.fn("ApplyPatchToolTest.execute")(function* (params: { patchText: string }, ctx: ToolCtx) { + const info = yield* ApplyPatchTool + const tool = yield* info.init() + return yield* tool.execute(params, ctx) +}) const makeCtx = () => { const calls: AskInput[] = [] @@ -77,39 +76,56 @@ const makeCtx = () => { return { ctx, calls } } +const readText = (filepath: string) => Effect.promise(() => fs.readFile(filepath, "utf-8")) +const writeText = (filepath: string, content: string) => Effect.promise(() => fs.writeFile(filepath, content, "utf-8")) +const makeDir = (dir: string) => Effect.promise(() => fs.mkdir(dir, { recursive: true })) + +const expectFailure = (effect: Effect.Effect, message?: string) => + Effect.gen(function* () { + const exit = yield* Effect.exit(effect) + expect(Exit.isFailure(exit)).toBe(true) + if (Exit.isFailure(exit) && message) expect(Cause.pretty(exit.cause)).toContain(message) + }) + +const expectReadFailure = (filepath: string) => expectFailure(readText(filepath)) + describe("tool.apply_patch freeform", () => { - test("requires patchText", async () => { - const { ctx } = makeCtx() - await expect(execute({ patchText: "" }, ctx)).rejects.toThrow("patchText is required") - }) + it.live("requires patchText", () => + Effect.gen(function* () { + const { ctx } = makeCtx() + yield* expectFailure(execute({ patchText: "" }, ctx), "patchText is required") + }), + ) - test("rejects invalid patch format", async () => { - const { ctx } = makeCtx() - await expect(execute({ patchText: "invalid patch" }, ctx)).rejects.toThrow("apply_patch verification failed") - }) + it.live("rejects invalid patch format", () => + Effect.gen(function* () { + const { ctx } = makeCtx() + yield* expectFailure(execute({ patchText: "invalid patch" }, ctx), "apply_patch verification failed") + }), + ) - test("rejects empty patch", async () => { - const { ctx } = makeCtx() - const emptyPatch = "*** Begin Patch\n*** End Patch" - await expect(execute({ patchText: emptyPatch }, ctx)).rejects.toThrow("patch rejected: empty patch") - }) + it.live("rejects empty patch", () => + Effect.gen(function* () { + const { ctx } = makeCtx() + yield* expectFailure(execute({ patchText: "*** Begin Patch\n*** End Patch" }, ctx), "patch rejected: empty patch") + }), + ) - test("applies add/update/delete in one patch", async () => { - await using fixture = await tmpdir({ git: true }) - const { ctx, calls } = makeCtx() - - await WithInstance.provide({ - directory: fixture.path, - fn: async () => { - const modifyPath = path.join(fixture.path, "modify.txt") - const deletePath = path.join(fixture.path, "delete.txt") - await fs.writeFile(modifyPath, "line1\nline2\n", "utf-8") - await fs.writeFile(deletePath, "obsolete\n", "utf-8") + it.instance( + "applies add/update/delete in one patch", + () => + Effect.gen(function* () { + const test = yield* TestInstance + const { ctx, calls } = makeCtx() + const modifyPath = path.join(test.directory, "modify.txt") + const deletePath = path.join(test.directory, "delete.txt") + yield* writeText(modifyPath, "line1\nline2\n") + yield* writeText(deletePath, "obsolete\n") const patchText = "*** Begin Patch\n*** Add File: nested/new.txt\n+created\n*** Delete File: delete.txt\n*** Update File: modify.txt\n@@\n-line2\n+changed\n*** End Patch" - const result = await execute({ patchText }, ctx) + const result = yield* execute({ patchText }, ctx) expect(result.title).toContain("Success. Updated the following files") expect(result.output).toContain("Success. Updated the following files") @@ -129,38 +145,34 @@ describe("tool.apply_patch freeform", () => { expect(permissionCall.metadata.files.map((f) => f.type).sort()).toEqual(["add", "delete", "update"]) const addFile = permissionCall.metadata.files.find((f) => f.type === "add") - expect(addFile).toBeDefined() - expect(addFile!.relativePath).toBe("nested/new.txt") - expect(addFile!.patch).toContain("+created") + expect(addFile?.relativePath).toBe("nested/new.txt") + expect(addFile?.patch).toContain("+created") const updateFile = permissionCall.metadata.files.find((f) => f.type === "update") - expect(updateFile).toBeDefined() - expect(updateFile!.patch).toContain("-line2") - expect(updateFile!.patch).toContain("+changed") + expect(updateFile?.patch).toContain("-line2") + expect(updateFile?.patch).toContain("+changed") - const added = await fs.readFile(path.join(fixture.path, "nested", "new.txt"), "utf-8") - expect(added).toBe("created\n") - expect(await fs.readFile(modifyPath, "utf-8")).toBe("line1\nchanged\n") - await expect(fs.readFile(deletePath, "utf-8")).rejects.toThrow() - }, - }) - }) + expect(yield* readText(path.join(test.directory, "nested", "new.txt"))).toBe("created\n") + expect(yield* readText(modifyPath)).toBe("line1\nchanged\n") + yield* expectReadFailure(deletePath) + }), + { git: true }, + ) - test("permission metadata includes move file info", async () => { - await using fixture = await tmpdir({ git: true }) - const { ctx, calls } = makeCtx() - - await WithInstance.provide({ - directory: fixture.path, - fn: async () => { - const original = path.join(fixture.path, "old", "name.txt") - await fs.mkdir(path.dirname(original), { recursive: true }) - await fs.writeFile(original, "old content\n", "utf-8") + it.instance( + "permission metadata includes move file info", + () => + Effect.gen(function* () { + const test = yield* TestInstance + const { ctx, calls } = makeCtx() + const original = path.join(test.directory, "old", "name.txt") + yield* makeDir(path.dirname(original)) + yield* writeText(original, "old content\n") const patchText = "*** Begin Patch\n*** Update File: old/name.txt\n*** Move to: renamed/dir/name.txt\n@@\n-old content\n+new content\n*** End Patch" - await execute({ patchText }, ctx) + yield* execute({ patchText }, ctx) expect(calls.length).toBe(1) const permissionCall = calls[0] @@ -169,447 +181,348 @@ describe("tool.apply_patch freeform", () => { const moveFile = permissionCall.metadata.files[0] expect(moveFile.type).toBe("move") expect(moveFile.relativePath).toBe("renamed/dir/name.txt") - expect(moveFile.movePath).toBe(path.join(fixture.path, "renamed/dir/name.txt")) + expect(moveFile.movePath).toBe(path.join(test.directory, "renamed/dir/name.txt")) expect(moveFile.patch).toContain("-old content") expect(moveFile.patch).toContain("+new content") - }, - }) - }) - - test("applies multiple hunks to one file", async () => { - await using fixture = await tmpdir() - const { ctx } = makeCtx() - - await WithInstance.provide({ - directory: fixture.path, - fn: async () => { - const target = path.join(fixture.path, "multi.txt") - await fs.writeFile(target, "line1\nline2\nline3\nline4\n", "utf-8") - - const patchText = - "*** Begin Patch\n*** Update File: multi.txt\n@@\n-line2\n+changed2\n@@\n-line4\n+changed4\n*** End Patch" - - await execute({ patchText }, ctx) - - expect(await fs.readFile(target, "utf-8")).toBe("line1\nchanged2\nline3\nchanged4\n") - }, - }) - }) - - test("does not invent a first-line diff for BOM files", async () => { - await using fixture = await tmpdir() - const { ctx, calls } = makeCtx() - - await WithInstance.provide({ - directory: fixture.path, - fn: async () => { - const bom = String.fromCharCode(0xfeff) - const target = path.join(fixture.path, "example.cs") - await fs.writeFile(target, `${bom}using System;\n\nclass Test {}\n`, "utf-8") - - const patchText = - "*** Begin Patch\n*** Update File: example.cs\n@@\n class Test {}\n+class Next {}\n*** End Patch" - - await execute({ patchText }, ctx) - - expect(calls.length).toBe(1) - const shown = calls[0].metadata.files[0]?.patch ?? "" - expect(shown).not.toContain(bom) - expect(shown).not.toContain("-using System;") - expect(shown).not.toContain("+using System;") - - const content = await fs.readFile(target, "utf-8") - expect(content.charCodeAt(0)).toBe(0xfeff) - expect(content.slice(1)).toBe("using System;\n\nclass Test {}\nclass Next {}\n") - }, - }) - }) - - test("inserts lines with insert-only hunk", async () => { - await using fixture = await tmpdir() - const { ctx } = makeCtx() - - await WithInstance.provide({ - directory: fixture.path, - fn: async () => { - const target = path.join(fixture.path, "insert_only.txt") - await fs.writeFile(target, "alpha\nomega\n", "utf-8") - - const patchText = "*** Begin Patch\n*** Update File: insert_only.txt\n@@\n alpha\n+beta\n omega\n*** End Patch" - - await execute({ patchText }, ctx) - - expect(await fs.readFile(target, "utf-8")).toBe("alpha\nbeta\nomega\n") - }, - }) - }) - - test("appends trailing newline on update", async () => { - await using fixture = await tmpdir() - const { ctx } = makeCtx() - - await WithInstance.provide({ - directory: fixture.path, - fn: async () => { - const target = path.join(fixture.path, "no_newline.txt") - await fs.writeFile(target, "no newline at end", "utf-8") - - const patchText = - "*** Begin Patch\n*** Update File: no_newline.txt\n@@\n-no newline at end\n+first line\n+second line\n*** End Patch" - - await execute({ patchText }, ctx) - - const contents = await fs.readFile(target, "utf-8") - expect(contents.endsWith("\n")).toBe(true) - expect(contents).toBe("first line\nsecond line\n") - }, - }) - }) - - test("moves file to a new directory", async () => { - await using fixture = await tmpdir() - const { ctx } = makeCtx() - - await WithInstance.provide({ - directory: fixture.path, - fn: async () => { - const original = path.join(fixture.path, "old", "name.txt") - await fs.mkdir(path.dirname(original), { recursive: true }) - await fs.writeFile(original, "old content\n", "utf-8") - - const patchText = - "*** Begin Patch\n*** Update File: old/name.txt\n*** Move to: renamed/dir/name.txt\n@@\n-old content\n+new content\n*** End Patch" - - await execute({ patchText }, ctx) - - const moved = path.join(fixture.path, "renamed", "dir", "name.txt") - await expect(fs.readFile(original, "utf-8")).rejects.toThrow() - expect(await fs.readFile(moved, "utf-8")).toBe("new content\n") - }, - }) - }) - - test("moves file overwriting existing destination", async () => { - await using fixture = await tmpdir() - const { ctx } = makeCtx() - - await WithInstance.provide({ - directory: fixture.path, - fn: async () => { - const original = path.join(fixture.path, "old", "name.txt") - const destination = path.join(fixture.path, "renamed", "dir", "name.txt") - await fs.mkdir(path.dirname(original), { recursive: true }) - await fs.mkdir(path.dirname(destination), { recursive: true }) - await fs.writeFile(original, "from\n", "utf-8") - await fs.writeFile(destination, "existing\n", "utf-8") - - const patchText = - "*** Begin Patch\n*** Update File: old/name.txt\n*** Move to: renamed/dir/name.txt\n@@\n-from\n+new\n*** End Patch" - - await execute({ patchText }, ctx) - - await expect(fs.readFile(original, "utf-8")).rejects.toThrow() - expect(await fs.readFile(destination, "utf-8")).toBe("new\n") - }, - }) - }) - - test("adds file overwriting existing file", async () => { - await using fixture = await tmpdir() - const { ctx } = makeCtx() - - await WithInstance.provide({ - directory: fixture.path, - fn: async () => { - const target = path.join(fixture.path, "duplicate.txt") - await fs.writeFile(target, "old content\n", "utf-8") - - const patchText = "*** Begin Patch\n*** Add File: duplicate.txt\n+new content\n*** End Patch" - - await execute({ patchText }, ctx) - expect(await fs.readFile(target, "utf-8")).toBe("new content\n") - }, - }) - }) - - test("rejects update when target file is missing", async () => { - await using fixture = await tmpdir() - const { ctx } = makeCtx() - - await WithInstance.provide({ - directory: fixture.path, - fn: async () => { - const patchText = "*** Begin Patch\n*** Update File: missing.txt\n@@\n-nope\n+better\n*** End Patch" - - await expect(execute({ patchText }, ctx)).rejects.toThrow( - "apply_patch verification failed: Failed to read file to update", - ) - }, - }) - }) - - test("rejects delete when file is missing", async () => { - await using fixture = await tmpdir() - const { ctx } = makeCtx() - - await WithInstance.provide({ - directory: fixture.path, - fn: async () => { - const patchText = "*** Begin Patch\n*** Delete File: missing.txt\n*** End Patch" - - await expect(execute({ patchText }, ctx)).rejects.toThrow() - }, - }) - }) - - test("rejects delete when target is a directory", async () => { - await using fixture = await tmpdir() - const { ctx } = makeCtx() - - await WithInstance.provide({ - directory: fixture.path, - fn: async () => { - const dirPath = path.join(fixture.path, "dir") - await fs.mkdir(dirPath) - - const patchText = "*** Begin Patch\n*** Delete File: dir\n*** End Patch" - - await expect(execute({ patchText }, ctx)).rejects.toThrow() - }, - }) - }) - - test("rejects invalid hunk header", async () => { - await using fixture = await tmpdir() - const { ctx } = makeCtx() - - await WithInstance.provide({ - directory: fixture.path, - fn: async () => { - const patchText = "*** Begin Patch\n*** Frobnicate File: foo\n*** End Patch" - - await expect(execute({ patchText }, ctx)).rejects.toThrow("apply_patch verification failed") - }, - }) - }) - - test("rejects update with missing context", async () => { - await using fixture = await tmpdir() - const { ctx } = makeCtx() - - await WithInstance.provide({ - directory: fixture.path, - fn: async () => { - const target = path.join(fixture.path, "modify.txt") - await fs.writeFile(target, "line1\nline2\n", "utf-8") - - const patchText = "*** Begin Patch\n*** Update File: modify.txt\n@@\n-missing\n+changed\n*** End Patch" - - await expect(execute({ patchText }, ctx)).rejects.toThrow("apply_patch verification failed") - expect(await fs.readFile(target, "utf-8")).toBe("line1\nline2\n") - }, - }) - }) - - test("verification failure leaves no side effects", async () => { - await using fixture = await tmpdir() - const { ctx } = makeCtx() - - await WithInstance.provide({ - directory: fixture.path, - fn: async () => { - const patchText = - "*** Begin Patch\n*** Add File: created.txt\n+hello\n*** Update File: missing.txt\n@@\n-old\n+new\n*** End Patch" - - await expect(execute({ patchText }, ctx)).rejects.toThrow() - - const createdPath = path.join(fixture.path, "created.txt") - await expect(fs.readFile(createdPath, "utf-8")).rejects.toThrow() - }, - }) - }) - - test("supports end of file anchor", async () => { - await using fixture = await tmpdir() - const { ctx } = makeCtx() - - await WithInstance.provide({ - directory: fixture.path, - fn: async () => { - const target = path.join(fixture.path, "tail.txt") - await fs.writeFile(target, "alpha\nlast\n", "utf-8") - - const patchText = "*** Begin Patch\n*** Update File: tail.txt\n@@\n-last\n+end\n*** End of File\n*** End Patch" - - await execute({ patchText }, ctx) - expect(await fs.readFile(target, "utf-8")).toBe("alpha\nend\n") - }, - }) - }) - - test("rejects missing second chunk context", async () => { - await using fixture = await tmpdir() - const { ctx } = makeCtx() - - await WithInstance.provide({ - directory: fixture.path, - fn: async () => { - const target = path.join(fixture.path, "two_chunks.txt") - await fs.writeFile(target, "a\nb\nc\nd\n", "utf-8") - - const patchText = "*** Begin Patch\n*** Update File: two_chunks.txt\n@@\n-b\n+B\n\n-d\n+D\n*** End Patch" - - await expect(execute({ patchText }, ctx)).rejects.toThrow() - expect(await fs.readFile(target, "utf-8")).toBe("a\nb\nc\nd\n") - }, - }) - }) - - test("disambiguates change context with @@ header", async () => { - await using fixture = await tmpdir() - const { ctx } = makeCtx() - - await WithInstance.provide({ - directory: fixture.path, - fn: async () => { - const target = path.join(fixture.path, "multi_ctx.txt") - await fs.writeFile(target, "fn a\nx=10\ny=2\nfn b\nx=10\ny=20\n", "utf-8") - - const patchText = "*** Begin Patch\n*** Update File: multi_ctx.txt\n@@ fn b\n-x=10\n+x=11\n*** End Patch" - - await execute({ patchText }, ctx) - expect(await fs.readFile(target, "utf-8")).toBe("fn a\nx=10\ny=2\nfn b\nx=11\ny=20\n") - }, - }) - }) - - test("EOF anchor matches from end of file first", async () => { - await using fixture = await tmpdir() - const { ctx } = makeCtx() - - await WithInstance.provide({ - directory: fixture.path, - fn: async () => { - const target = path.join(fixture.path, "eof_anchor.txt") - // File has duplicate "marker" lines - one in middle, one at end - await fs.writeFile(target, "start\nmarker\nmiddle\nmarker\nend\n", "utf-8") - - // With EOF anchor, should match the LAST "marker" line, not the first - const patchText = - "*** Begin Patch\n*** Update File: eof_anchor.txt\n@@\n-marker\n-end\n+marker-changed\n+end\n*** End of File\n*** End Patch" - - await execute({ patchText }, ctx) - // First marker unchanged, second marker changed - expect(await fs.readFile(target, "utf-8")).toBe("start\nmarker\nmiddle\nmarker-changed\nend\n") - }, - }) - }) - - test("parses heredoc-wrapped patch", async () => { - await using fixture = await tmpdir() - const { ctx } = makeCtx() - - await WithInstance.provide({ - directory: fixture.path, - fn: async () => { - const patchText = `cat <<'EOF' + }), + { git: true }, + ) + + it.instance("applies multiple hunks to one file", () => + Effect.gen(function* () { + const test = yield* TestInstance + const { ctx } = makeCtx() + const target = path.join(test.directory, "multi.txt") + yield* writeText(target, "line1\nline2\nline3\nline4\n") + + const patchText = + "*** Begin Patch\n*** Update File: multi.txt\n@@\n-line2\n+changed2\n@@\n-line4\n+changed4\n*** End Patch" + + yield* execute({ patchText }, ctx) + + expect(yield* readText(target)).toBe("line1\nchanged2\nline3\nchanged4\n") + }), + ) + + it.instance("does not invent a first-line diff for BOM files", () => + Effect.gen(function* () { + const test = yield* TestInstance + const { ctx, calls } = makeCtx() + const bom = String.fromCharCode(0xfeff) + const target = path.join(test.directory, "example.cs") + yield* writeText(target, `${bom}using System;\n\nclass Test {}\n`) + + const patchText = "*** Begin Patch\n*** Update File: example.cs\n@@\n class Test {}\n+class Next {}\n*** End Patch" + + yield* execute({ patchText }, ctx) + + expect(calls.length).toBe(1) + const shown = calls[0].metadata.files[0]?.patch ?? "" + expect(shown).not.toContain(bom) + expect(shown).not.toContain("-using System;") + expect(shown).not.toContain("+using System;") + + const content = yield* readText(target) + expect(content.charCodeAt(0)).toBe(0xfeff) + expect(content.slice(1)).toBe("using System;\n\nclass Test {}\nclass Next {}\n") + }), + ) + + it.instance("inserts lines with insert-only hunk", () => + Effect.gen(function* () { + const test = yield* TestInstance + const { ctx } = makeCtx() + const target = path.join(test.directory, "insert_only.txt") + yield* writeText(target, "alpha\nomega\n") + + const patchText = "*** Begin Patch\n*** Update File: insert_only.txt\n@@\n alpha\n+beta\n omega\n*** End Patch" + + yield* execute({ patchText }, ctx) + + expect(yield* readText(target)).toBe("alpha\nbeta\nomega\n") + }), + ) + + it.instance("appends trailing newline on update", () => + Effect.gen(function* () { + const test = yield* TestInstance + const { ctx } = makeCtx() + const target = path.join(test.directory, "no_newline.txt") + yield* writeText(target, "no newline at end") + + const patchText = + "*** Begin Patch\n*** Update File: no_newline.txt\n@@\n-no newline at end\n+first line\n+second line\n*** End Patch" + + yield* execute({ patchText }, ctx) + + const contents = yield* readText(target) + expect(contents.endsWith("\n")).toBe(true) + expect(contents).toBe("first line\nsecond line\n") + }), + ) + + it.instance("moves file to a new directory", () => + Effect.gen(function* () { + const test = yield* TestInstance + const { ctx } = makeCtx() + const original = path.join(test.directory, "old", "name.txt") + yield* makeDir(path.dirname(original)) + yield* writeText(original, "old content\n") + + const patchText = + "*** Begin Patch\n*** Update File: old/name.txt\n*** Move to: renamed/dir/name.txt\n@@\n-old content\n+new content\n*** End Patch" + + yield* execute({ patchText }, ctx) + + const moved = path.join(test.directory, "renamed", "dir", "name.txt") + yield* expectReadFailure(original) + expect(yield* readText(moved)).toBe("new content\n") + }), + ) + + it.instance("moves file overwriting existing destination", () => + Effect.gen(function* () { + const test = yield* TestInstance + const { ctx } = makeCtx() + const original = path.join(test.directory, "old", "name.txt") + const destination = path.join(test.directory, "renamed", "dir", "name.txt") + yield* makeDir(path.dirname(original)) + yield* makeDir(path.dirname(destination)) + yield* writeText(original, "from\n") + yield* writeText(destination, "existing\n") + + const patchText = + "*** Begin Patch\n*** Update File: old/name.txt\n*** Move to: renamed/dir/name.txt\n@@\n-from\n+new\n*** End Patch" + + yield* execute({ patchText }, ctx) + + yield* expectReadFailure(original) + expect(yield* readText(destination)).toBe("new\n") + }), + ) + + it.instance("adds file overwriting existing file", () => + Effect.gen(function* () { + const test = yield* TestInstance + const { ctx } = makeCtx() + const target = path.join(test.directory, "duplicate.txt") + yield* writeText(target, "old content\n") + + const patchText = "*** Begin Patch\n*** Add File: duplicate.txt\n+new content\n*** End Patch" + + yield* execute({ patchText }, ctx) + expect(yield* readText(target)).toBe("new content\n") + }), + ) + + it.instance("rejects update when target file is missing", () => + Effect.gen(function* () { + const { ctx } = makeCtx() + const patchText = "*** Begin Patch\n*** Update File: missing.txt\n@@\n-nope\n+better\n*** End Patch" + + yield* expectFailure(execute({ patchText }, ctx), "apply_patch verification failed: Failed to read file to update") + }), + ) + + it.instance("rejects delete when file is missing", () => + Effect.gen(function* () { + const { ctx } = makeCtx() + const patchText = "*** Begin Patch\n*** Delete File: missing.txt\n*** End Patch" + + yield* expectFailure(execute({ patchText }, ctx)) + }), + ) + + it.instance("rejects delete when target is a directory", () => + Effect.gen(function* () { + const test = yield* TestInstance + const { ctx } = makeCtx() + const dirPath = path.join(test.directory, "dir") + yield* makeDir(dirPath) + + const patchText = "*** Begin Patch\n*** Delete File: dir\n*** End Patch" + + yield* expectFailure(execute({ patchText }, ctx)) + }), + ) + + it.instance("rejects invalid hunk header", () => + Effect.gen(function* () { + const { ctx } = makeCtx() + const patchText = "*** Begin Patch\n*** Frobnicate File: foo\n*** End Patch" + + yield* expectFailure(execute({ patchText }, ctx), "apply_patch verification failed") + }), + ) + + it.instance("rejects update with missing context", () => + Effect.gen(function* () { + const test = yield* TestInstance + const { ctx } = makeCtx() + const target = path.join(test.directory, "modify.txt") + yield* writeText(target, "line1\nline2\n") + + const patchText = "*** Begin Patch\n*** Update File: modify.txt\n@@\n-missing\n+changed\n*** End Patch" + + yield* expectFailure(execute({ patchText }, ctx), "apply_patch verification failed") + expect(yield* readText(target)).toBe("line1\nline2\n") + }), + ) + + it.instance("verification failure leaves no side effects", () => + Effect.gen(function* () { + const test = yield* TestInstance + const { ctx } = makeCtx() + const patchText = + "*** Begin Patch\n*** Add File: created.txt\n+hello\n*** Update File: missing.txt\n@@\n-old\n+new\n*** End Patch" + + yield* expectFailure(execute({ patchText }, ctx)) + yield* expectReadFailure(path.join(test.directory, "created.txt")) + }), + ) + + it.instance("supports end of file anchor", () => + Effect.gen(function* () { + const test = yield* TestInstance + const { ctx } = makeCtx() + const target = path.join(test.directory, "tail.txt") + yield* writeText(target, "alpha\nlast\n") + + const patchText = "*** Begin Patch\n*** Update File: tail.txt\n@@\n-last\n+end\n*** End of File\n*** End Patch" + + yield* execute({ patchText }, ctx) + expect(yield* readText(target)).toBe("alpha\nend\n") + }), + ) + + it.instance("rejects missing second chunk context", () => + Effect.gen(function* () { + const test = yield* TestInstance + const { ctx } = makeCtx() + const target = path.join(test.directory, "two_chunks.txt") + yield* writeText(target, "a\nb\nc\nd\n") + + const patchText = "*** Begin Patch\n*** Update File: two_chunks.txt\n@@\n-b\n+B\n\n-d\n+D\n*** End Patch" + + yield* expectFailure(execute({ patchText }, ctx)) + expect(yield* readText(target)).toBe("a\nb\nc\nd\n") + }), + ) + + it.instance("disambiguates change context with @@ header", () => + Effect.gen(function* () { + const test = yield* TestInstance + const { ctx } = makeCtx() + const target = path.join(test.directory, "multi_ctx.txt") + yield* writeText(target, "fn a\nx=10\ny=2\nfn b\nx=10\ny=20\n") + + const patchText = "*** Begin Patch\n*** Update File: multi_ctx.txt\n@@ fn b\n-x=10\n+x=11\n*** End Patch" + + yield* execute({ patchText }, ctx) + expect(yield* readText(target)).toBe("fn a\nx=10\ny=2\nfn b\nx=11\ny=20\n") + }), + ) + + it.instance("EOF anchor matches from end of file first", () => + Effect.gen(function* () { + const test = yield* TestInstance + const { ctx } = makeCtx() + const target = path.join(test.directory, "eof_anchor.txt") + // File has duplicate "marker" lines - one in middle, one at end + yield* writeText(target, "start\nmarker\nmiddle\nmarker\nend\n") + + // With EOF anchor, should match the LAST "marker" line, not the first + const patchText = + "*** Begin Patch\n*** Update File: eof_anchor.txt\n@@\n-marker\n-end\n+marker-changed\n+end\n*** End of File\n*** End Patch" + + yield* execute({ patchText }, ctx) + // First marker unchanged, second marker changed + expect(yield* readText(target)).toBe("start\nmarker\nmiddle\nmarker-changed\nend\n") + }), + ) + + it.instance("parses heredoc-wrapped patch", () => + Effect.gen(function* () { + const test = yield* TestInstance + const { ctx } = makeCtx() + const patchText = `cat <<'EOF' *** Begin Patch *** Add File: heredoc_test.txt +heredoc content *** End Patch EOF` - await execute({ patchText }, ctx) - const content = await fs.readFile(path.join(fixture.path, "heredoc_test.txt"), "utf-8") - expect(content).toBe("heredoc content\n") - }, - }) - }) + yield* execute({ patchText }, ctx) + expect(yield* readText(path.join(test.directory, "heredoc_test.txt"))).toBe("heredoc content\n") + }), + ) - test("parses heredoc-wrapped patch without cat", async () => { - await using fixture = await tmpdir() - const { ctx } = makeCtx() - - await WithInstance.provide({ - directory: fixture.path, - fn: async () => { - const patchText = `< + Effect.gen(function* () { + const test = yield* TestInstance + const { ctx } = makeCtx() + const patchText = `< { - await using fixture = await tmpdir() - const { ctx } = makeCtx() + it.instance("matches with trailing whitespace differences", () => + Effect.gen(function* () { + const test = yield* TestInstance + const { ctx } = makeCtx() + const target = path.join(test.directory, "trailing_ws.txt") + // File has trailing spaces on some lines + yield* writeText(target, "line1 \nline2\nline3 \n") - await WithInstance.provide({ - directory: fixture.path, - fn: async () => { - const target = path.join(fixture.path, "trailing_ws.txt") - // File has trailing spaces on some lines - await fs.writeFile(target, "line1 \nline2\nline3 \n", "utf-8") + // Patch doesn't have trailing spaces - should still match via rstrip pass + const patchText = "*** Begin Patch\n*** Update File: trailing_ws.txt\n@@\n-line2\n+changed\n*** End Patch" - // Patch doesn't have trailing spaces - should still match via rstrip pass - const patchText = "*** Begin Patch\n*** Update File: trailing_ws.txt\n@@\n-line2\n+changed\n*** End Patch" + yield* execute({ patchText }, ctx) + expect(yield* readText(target)).toBe("line1 \nchanged\nline3 \n") + }), + ) - await execute({ patchText }, ctx) - expect(await fs.readFile(target, "utf-8")).toBe("line1 \nchanged\nline3 \n") - }, - }) - }) + it.instance("matches with leading whitespace differences", () => + Effect.gen(function* () { + const test = yield* TestInstance + const { ctx } = makeCtx() + const target = path.join(test.directory, "leading_ws.txt") + // File has leading spaces + yield* writeText(target, " line1\nline2\n line3\n") - test("matches with leading whitespace differences", async () => { - await using fixture = await tmpdir() - const { ctx } = makeCtx() + // Patch without leading spaces - should match via trim pass + const patchText = "*** Begin Patch\n*** Update File: leading_ws.txt\n@@\n-line2\n+changed\n*** End Patch" - await WithInstance.provide({ - directory: fixture.path, - fn: async () => { - const target = path.join(fixture.path, "leading_ws.txt") - // File has leading spaces - await fs.writeFile(target, " line1\nline2\n line3\n", "utf-8") + yield* execute({ patchText }, ctx) + expect(yield* readText(target)).toBe(" line1\nchanged\n line3\n") + }), + ) - // Patch without leading spaces - should match via trim pass - const patchText = "*** Begin Patch\n*** Update File: leading_ws.txt\n@@\n-line2\n+changed\n*** End Patch" + it.instance("matches with Unicode punctuation differences", () => + Effect.gen(function* () { + const test = yield* TestInstance + const { ctx } = makeCtx() + const target = path.join(test.directory, "unicode.txt") + // File has fancy Unicode quotes (U+201C, U+201D) and em-dash (U+2014) + const leftQuote = "\u201C" + const rightQuote = "\u201D" + const emDash = "\u2014" + yield* writeText(target, `He said ${leftQuote}hello${rightQuote}\nsome${emDash}dash\nend\n`) - await execute({ patchText }, ctx) - expect(await fs.readFile(target, "utf-8")).toBe(" line1\nchanged\n line3\n") - }, - }) - }) + // Patch uses ASCII equivalents - should match via normalized pass + // The replacement uses ASCII quotes from the patch (not preserving Unicode) + const patchText = '*** Begin Patch\n*** Update File: unicode.txt\n@@\n-He said "hello"\n+He said "hi"\n*** End Patch' - test("matches with Unicode punctuation differences", async () => { - await using fixture = await tmpdir() - const { ctx } = makeCtx() - - await WithInstance.provide({ - directory: fixture.path, - fn: async () => { - const target = path.join(fixture.path, "unicode.txt") - // File has fancy Unicode quotes (U+201C, U+201D) and em-dash (U+2014) - const leftQuote = "\u201C" - const rightQuote = "\u201D" - const emDash = "\u2014" - await fs.writeFile(target, `He said ${leftQuote}hello${rightQuote}\nsome${emDash}dash\nend\n`, "utf-8") - - // Patch uses ASCII equivalents - should match via normalized pass - // The replacement uses ASCII quotes from the patch (not preserving Unicode) - const patchText = - '*** Begin Patch\n*** Update File: unicode.txt\n@@\n-He said "hello"\n+He said "hi"\n*** End Patch' - - await execute({ patchText }, ctx) - // Result has ASCII quotes because that's what the patch specifies - expect(await fs.readFile(target, "utf-8")).toBe(`He said "hi"\nsome${emDash}dash\nend\n`) - }, - }) - }) + yield* execute({ patchText }, ctx) + // Result has ASCII quotes because that's what the patch specifies + expect(yield* readText(target)).toBe(`He said "hi"\nsome${emDash}dash\nend\n`) + }), + ) }) From a7b50416745122c3f7134a32813a937e1daab78a Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 12 May 2026 12:44:04 -0400 Subject: [PATCH 26/70] test(file): migrate fsmonitor tests to Effect runner (#27099) --- packages/opencode/test/file/fsmonitor.test.ts | 103 +++++++++--------- 1 file changed, 52 insertions(+), 51 deletions(-) diff --git a/packages/opencode/test/file/fsmonitor.test.ts b/packages/opencode/test/file/fsmonitor.test.ts index f345cd0850..3e025825b9 100644 --- a/packages/opencode/test/file/fsmonitor.test.ts +++ b/packages/opencode/test/file/fsmonitor.test.ts @@ -3,67 +3,68 @@ import { describe, expect, test } from "bun:test" import { Effect } from "effect" import fs from "fs/promises" import path from "path" -import { File } from "../../src/file" -import { Instance } from "../../src/project/instance" -import { WithInstance } from "../../src/project/with-instance" -import { provideInstance, tmpdir } from "../fixture/fixture" -const run = (eff: Effect.Effect) => - Effect.runPromise(provideInstance(Instance.directory)(eff.pipe(Effect.provide(File.defaultLayer)))) -const status = () => run(File.Service.use((svc) => svc.status())) -const read = (file: string) => run(File.Service.use((svc) => svc.read(file))) - -const wintest = process.platform === "win32" ? test : test.skip +const it = process.platform === "win32" ? (await import("../lib/effect")).testEffect((await import("../../src/file")).File.defaultLayer) : undefined describe("file fsmonitor", () => { - wintest("status does not start fsmonitor for readonly git checks", async () => { - await using tmp = await tmpdir({ git: true }) - const target = path.join(tmp.path, "tracked.txt") + if (!it) { + test.skip("status does not start fsmonitor for readonly git checks", () => {}) + test.skip("read does not start fsmonitor for git diffs", () => {}) + return + } - await fs.writeFile(target, "base\n") - await $`git add tracked.txt`.cwd(tmp.path).quiet() - await $`git commit -m init`.cwd(tmp.path).quiet() - await $`git config core.fsmonitor true`.cwd(tmp.path).quiet() - await $`git fsmonitor--daemon stop`.cwd(tmp.path).quiet().nothrow() - await fs.writeFile(target, "next\n") - await fs.writeFile(path.join(tmp.path, "new.txt"), "new\n") + it.instance( + "status does not start fsmonitor for readonly git checks", + () => + Effect.gen(function* () { + const { File } = yield* Effect.promise(() => import("../../src/file")) + const { TestInstance } = yield* Effect.promise(() => import("../fixture/fixture")) + const directory = (yield* TestInstance).directory + const target = path.join(directory, "tracked.txt") - const before = await $`git fsmonitor--daemon status`.cwd(tmp.path).quiet().nothrow() - expect(before.exitCode).not.toBe(0) + yield* Effect.promise(() => fs.writeFile(target, "base\n")) + yield* Effect.promise(() => $`git add tracked.txt`.cwd(directory).quiet()) + yield* Effect.promise(() => $`git commit -m init`.cwd(directory).quiet()) + yield* Effect.promise(() => $`git config core.fsmonitor true`.cwd(directory).quiet()) + yield* Effect.promise(() => $`git fsmonitor--daemon stop`.cwd(directory).quiet().nothrow()) + yield* Effect.promise(() => fs.writeFile(target, "next\n")) + yield* Effect.promise(() => fs.writeFile(path.join(directory, "new.txt"), "new\n")) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - await status() - }, - }) + const before = yield* Effect.promise(() => $`git fsmonitor--daemon status`.cwd(directory).quiet().nothrow()) + expect(before.exitCode).not.toBe(0) - const after = await $`git fsmonitor--daemon status`.cwd(tmp.path).quiet().nothrow() - expect(after.exitCode).not.toBe(0) - }) + yield* File.Service.use((svc) => svc.status()) - wintest("read does not start fsmonitor for git diffs", async () => { - await using tmp = await tmpdir({ git: true }) - const target = path.join(tmp.path, "tracked.txt") + const after = yield* Effect.promise(() => $`git fsmonitor--daemon status`.cwd(directory).quiet().nothrow()) + expect(after.exitCode).not.toBe(0) + }), + { git: true }, + ) - await fs.writeFile(target, "base\n") - await $`git add tracked.txt`.cwd(tmp.path).quiet() - await $`git commit -m init`.cwd(tmp.path).quiet() - await $`git config core.fsmonitor true`.cwd(tmp.path).quiet() - await $`git fsmonitor--daemon stop`.cwd(tmp.path).quiet().nothrow() - await fs.writeFile(target, "next\n") + it.instance( + "read does not start fsmonitor for git diffs", + () => + Effect.gen(function* () { + const { File } = yield* Effect.promise(() => import("../../src/file")) + const { TestInstance } = yield* Effect.promise(() => import("../fixture/fixture")) + const directory = (yield* TestInstance).directory + const target = path.join(directory, "tracked.txt") - const before = await $`git fsmonitor--daemon status`.cwd(tmp.path).quiet().nothrow() - expect(before.exitCode).not.toBe(0) + yield* Effect.promise(() => fs.writeFile(target, "base\n")) + yield* Effect.promise(() => $`git add tracked.txt`.cwd(directory).quiet()) + yield* Effect.promise(() => $`git commit -m init`.cwd(directory).quiet()) + yield* Effect.promise(() => $`git config core.fsmonitor true`.cwd(directory).quiet()) + yield* Effect.promise(() => $`git fsmonitor--daemon stop`.cwd(directory).quiet().nothrow()) + yield* Effect.promise(() => fs.writeFile(target, "next\n")) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - await read("tracked.txt") - }, - }) + const before = yield* Effect.promise(() => $`git fsmonitor--daemon status`.cwd(directory).quiet().nothrow()) + expect(before.exitCode).not.toBe(0) - const after = await $`git fsmonitor--daemon status`.cwd(tmp.path).quiet().nothrow() - expect(after.exitCode).not.toBe(0) - }) + yield* File.Service.use((svc) => svc.read("tracked.txt")) + + const after = yield* Effect.promise(() => $`git fsmonitor--daemon status`.cwd(directory).quiet().nothrow()) + expect(after.exitCode).not.toBe(0) + }), + { git: true }, + ) }) From e8125e9b428cd68260074e0de087cb1ae3ec3cf1 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 12 May 2026 12:45:05 -0400 Subject: [PATCH 27/70] test(server): migrate session list tests to Effect runner (#27101) --- .../opencode/test/server/session-list.test.ts | 332 +++++++++--------- 1 file changed, 157 insertions(+), 175 deletions(-) diff --git a/packages/opencode/test/server/session-list.test.ts b/packages/opencode/test/server/session-list.test.ts index 20478dde84..7a4eb61a41 100644 --- a/packages/opencode/test/server/session-list.test.ts +++ b/packages/opencode/test/server/session-list.test.ts @@ -1,33 +1,25 @@ -import { afterEach, describe, expect, test } from "bun:test" +import { afterEach, describe, expect } from "bun:test" import { Effect } from "effect" -import { Instance } from "../../src/project/instance" -import { WithInstance } from "../../src/project/with-instance" import { Session as SessionNs } from "@/session/session" import * as Log from "@opencode-ai/core/util/log" -import { disposeAllInstances, tmpdir } from "../fixture/fixture" +import { disposeAllInstances, provideInstance, TestInstance } from "../fixture/fixture" import { Flag } from "@opencode-ai/core/flag/flag" import { mkdir } from "fs/promises" import path from "path" import { Database } from "@/storage/db" import { SessionTable } from "@/session/session.sql" import { eq } from "drizzle-orm" +import { testEffect } from "../lib/effect" void Log.init({ print: false }) const originalWorkspaces = Flag.OPENCODE_EXPERIMENTAL_WORKSPACES +const it = testEffect(SessionNs.defaultLayer) -function run(fx: Effect.Effect) { - return Effect.runPromise(fx.pipe(Effect.provide(SessionNs.defaultLayer))) -} - -const svc = { - ...SessionNs, - create(input?: SessionNs.CreateInput) { - return run(SessionNs.Service.use((svc) => svc.create(input))) - }, - list(input?: SessionNs.ListInput) { - return run(SessionNs.Service.use((svc) => svc.list(input))) - }, -} +const withSession = (input?: Parameters[0]) => + Effect.acquireRelease( + SessionNs.Service.use((session) => session.create(input)), + (created) => SessionNs.Service.use((session) => session.remove(created.id).pipe(Effect.ignore)), + ) afterEach(async () => { Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = originalWorkspaces @@ -35,205 +27,195 @@ afterEach(async () => { }) describe("session.list", () => { - test("does not filter by directory when directory is omitted", async () => { - Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = false - await using tmp = await tmpdir({ git: true }) - await mkdir(path.join(tmp.path, "packages", "opencode"), { recursive: true }) - await mkdir(path.join(tmp.path, "packages", "app"), { recursive: true }) + it.instance( + "does not filter by directory when directory is omitted", + () => + Effect.gen(function* () { + Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = false + const test = yield* TestInstance + yield* Effect.promise(() => mkdir(path.join(test.directory, "packages", "opencode"), { recursive: true })) + yield* Effect.promise(() => mkdir(path.join(test.directory, "packages", "app"), { recursive: true })) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const root = await svc.create({ title: "root" }) + const root = yield* withSession({ title: "root" }) + const parent = yield* withSession({ title: "parent" }).pipe(provideInstance(path.join(test.directory, "packages"))) + const current = yield* withSession({ title: "current" }).pipe( + provideInstance(path.join(test.directory, "packages", "opencode")), + ) + const sibling = yield* withSession({ title: "sibling" }).pipe( + provideInstance(path.join(test.directory, "packages", "app")), + ) - const parent = await WithInstance.provide({ - directory: path.join(tmp.path, "packages"), - fn: async () => svc.create({ title: "parent" }), - }) - const current = await WithInstance.provide({ - directory: path.join(tmp.path, "packages", "opencode"), - fn: async () => svc.create({ title: "current" }), - }) - const sibling = await WithInstance.provide({ - directory: path.join(tmp.path, "packages", "app"), - fn: async () => svc.create({ title: "sibling" }), - }) - - const ids = (await svc.list()).map((s) => s.id) + const ids = (yield* SessionNs.Service.use((session) => session.list())).map((session) => session.id) expect(ids).toContain(root.id) expect(ids).toContain(parent.id) expect(ids).toContain(current.id) expect(ids).toContain(sibling.id) - }, - }) - }) + }), + { git: true }, + ) - test("filters by directory when directory is provided", async () => { - Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = false - await using tmp = await tmpdir({ git: true }) - await mkdir(path.join(tmp.path, "packages", "opencode"), { recursive: true }) - await mkdir(path.join(tmp.path, "packages", "app"), { recursive: true }) + it.instance( + "filters by directory when directory is provided", + () => + Effect.gen(function* () { + Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = false + const test = yield* TestInstance + yield* Effect.promise(() => mkdir(path.join(test.directory, "packages", "opencode"), { recursive: true })) + yield* Effect.promise(() => mkdir(path.join(test.directory, "packages", "app"), { recursive: true })) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const root = await svc.create({ title: "root" }) + const root = yield* withSession({ title: "root" }) + const parent = yield* withSession({ title: "parent" }).pipe(provideInstance(path.join(test.directory, "packages"))) + const current = yield* withSession({ title: "current" }).pipe( + provideInstance(path.join(test.directory, "packages", "opencode")), + ) + const sibling = yield* withSession({ title: "sibling" }).pipe( + provideInstance(path.join(test.directory, "packages", "app")), + ) - const parent = await WithInstance.provide({ - directory: path.join(tmp.path, "packages"), - fn: async () => svc.create({ title: "parent" }), - }) - const current = await WithInstance.provide({ - directory: path.join(tmp.path, "packages", "opencode"), - fn: async () => svc.create({ title: "current" }), - }) - const sibling = await WithInstance.provide({ - directory: path.join(tmp.path, "packages", "app"), - fn: async () => svc.create({ title: "sibling" }), - }) - - const ids = (await svc.list({ directory: path.join(tmp.path, "packages", "opencode") })).map((s) => s.id) + const ids = ( + yield* SessionNs.Service.use((session) => + session.list({ directory: path.join(test.directory, "packages", "opencode") }), + ) + ).map((session) => session.id) expect(ids).not.toContain(root.id) expect(ids).not.toContain(parent.id) expect(ids).toContain(current.id) expect(ids).not.toContain(sibling.id) - }, - }) - }) + }), + { git: true }, + ) - test("filters by path and ignores directory when path is provided", async () => { - Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = false - await using tmp = await tmpdir({ git: true }) - await mkdir(path.join(tmp.path, "packages", "opencode", "src", "deep"), { recursive: true }) - await mkdir(path.join(tmp.path, "packages", "app"), { recursive: true }) + it.instance( + "filters by path and ignores directory when path is provided", + () => + Effect.gen(function* () { + Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = false + const test = yield* TestInstance + yield* Effect.promise(() => + mkdir(path.join(test.directory, "packages", "opencode", "src", "deep"), { recursive: true }), + ) + yield* Effect.promise(() => mkdir(path.join(test.directory, "packages", "app"), { recursive: true })) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const parent = await WithInstance.provide({ - directory: path.join(tmp.path, "packages", "opencode"), - fn: async () => svc.create({ title: "parent" }), - }) - const current = await WithInstance.provide({ - directory: path.join(tmp.path, "packages", "opencode", "src"), - fn: async () => svc.create({ title: "current" }), - }) - const deeper = await WithInstance.provide({ - directory: path.join(tmp.path, "packages", "opencode", "src", "deep"), - fn: async () => svc.create({ title: "deeper" }), - }) - const sibling = await WithInstance.provide({ - directory: path.join(tmp.path, "packages", "app"), - fn: async () => svc.create({ title: "sibling" }), - }) + const parent = yield* withSession({ title: "parent" }).pipe( + provideInstance(path.join(test.directory, "packages", "opencode")), + ) + const current = yield* withSession({ title: "current" }).pipe( + provideInstance(path.join(test.directory, "packages", "opencode", "src")), + ) + const deeper = yield* withSession({ title: "deeper" }).pipe( + provideInstance(path.join(test.directory, "packages", "opencode", "src", "deep")), + ) + const sibling = yield* withSession({ title: "sibling" }).pipe( + provideInstance(path.join(test.directory, "packages", "app")), + ) const pathIDs = ( - await svc.list({ - directory: path.join(tmp.path, "packages", "app"), - path: "packages/opencode/src", - }) - ).map((s) => s.id) + yield* SessionNs.Service.use((session) => + session.list({ + directory: path.join(test.directory, "packages", "app"), + path: "packages/opencode/src", + }), + ) + ).map((session) => session.id) expect(pathIDs).not.toContain(parent.id) expect(pathIDs).toContain(current.id) expect(pathIDs).toContain(deeper.id) expect(pathIDs).not.toContain(sibling.id) - }, - }) - }) + }), + { git: true }, + ) - test("falls back to directory when filtering legacy sessions without path", async () => { - Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = false - await using tmp = await tmpdir({ git: true }) - await mkdir(path.join(tmp.path, "packages", "opencode", "src"), { recursive: true }) - await mkdir(path.join(tmp.path, "packages", "app"), { recursive: true }) + it.instance( + "falls back to directory when filtering legacy sessions without path", + () => + Effect.gen(function* () { + Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = false + const test = yield* TestInstance + yield* Effect.promise(() => mkdir(path.join(test.directory, "packages", "opencode", "src"), { recursive: true })) + yield* Effect.promise(() => mkdir(path.join(test.directory, "packages", "app"), { recursive: true })) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const current = await WithInstance.provide({ - directory: path.join(tmp.path, "packages", "opencode", "src"), - fn: async () => svc.create({ title: "legacy-current" }), - }) - const sibling = await WithInstance.provide({ - directory: path.join(tmp.path, "packages", "app"), - fn: async () => svc.create({ title: "legacy-sibling" }), - }) + const current = yield* withSession({ title: "legacy-current" }).pipe( + provideInstance(path.join(test.directory, "packages", "opencode", "src")), + ) + const sibling = yield* withSession({ title: "legacy-sibling" }).pipe( + provideInstance(path.join(test.directory, "packages", "app")), + ) - Database.use((db) => db.update(SessionTable).set({ path: null }).where(eq(SessionTable.id, current.id)).run()) - Database.use((db) => db.update(SessionTable).set({ path: null }).where(eq(SessionTable.id, sibling.id)).run()) + yield* Effect.sync(() => + Database.use((db) => db.update(SessionTable).set({ path: null }).where(eq(SessionTable.id, current.id)).run()), + ) + yield* Effect.sync(() => + Database.use((db) => db.update(SessionTable).set({ path: null }).where(eq(SessionTable.id, sibling.id)).run()), + ) const pathIDs = ( - await svc.list({ - directory: path.join(tmp.path, "packages", "opencode", "src"), - path: "packages/opencode/src", - }) - ).map((s) => s.id) + yield* SessionNs.Service.use((session) => + session.list({ + directory: path.join(test.directory, "packages", "opencode", "src"), + path: "packages/opencode/src", + }), + ) + ).map((session) => session.id) expect(pathIDs).toContain(current.id) expect(pathIDs).not.toContain(sibling.id) - }, - }) - }) + }), + { git: true }, + ) - test("filters root sessions", async () => { - await using tmp = await tmpdir({ git: true }) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const root = await svc.create({ title: "root-session" }) - const child = await svc.create({ title: "child-session", parentID: root.id }) + it.instance( + "filters root sessions", + () => + Effect.gen(function* () { + const root = yield* withSession({ title: "root-session" }) + const child = yield* withSession({ title: "child-session", parentID: root.id }) - const sessions = await svc.list({ roots: true }) - const ids = sessions.map((s) => s.id) + const sessions = yield* SessionNs.Service.use((session) => session.list({ roots: true })) + const ids = sessions.map((session) => session.id) expect(ids).toContain(root.id) expect(ids).not.toContain(child.id) - }, - }) - }) + }), + { git: true }, + ) - test("filters by start time", async () => { - await using tmp = await tmpdir({ git: true }) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - await svc.create({ title: "new-session" }) - const futureStart = Date.now() + 86400000 - - const sessions = await svc.list({ start: futureStart }) + it.instance( + "filters by start time", + () => + Effect.gen(function* () { + yield* withSession({ title: "new-session" }) + const sessions = yield* SessionNs.Service.use((session) => session.list({ start: Date.now() + 86400000 })) expect(sessions.length).toBe(0) - }, - }) - }) + }), + { git: true }, + ) - test("filters by search term", async () => { - await using tmp = await tmpdir({ git: true }) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - await svc.create({ title: "unique-search-term-abc" }) - await svc.create({ title: "other-session-xyz" }) + it.instance( + "filters by search term", + () => + Effect.gen(function* () { + yield* withSession({ title: "unique-search-term-abc" }) + yield* withSession({ title: "other-session-xyz" }) - const sessions = await svc.list({ search: "unique-search" }) - const titles = sessions.map((s) => s.title) + const sessions = yield* SessionNs.Service.use((session) => session.list({ search: "unique-search" })) + const titles = sessions.map((session) => session.title) expect(titles).toContain("unique-search-term-abc") expect(titles).not.toContain("other-session-xyz") - }, - }) - }) + }), + { git: true }, + ) - test("respects limit parameter", async () => { - await using tmp = await tmpdir({ git: true }) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - await svc.create({ title: "session-1" }) - await svc.create({ title: "session-2" }) - await svc.create({ title: "session-3" }) + it.instance( + "respects limit parameter", + () => + Effect.gen(function* () { + yield* withSession({ title: "session-1" }) + yield* withSession({ title: "session-2" }) + yield* withSession({ title: "session-3" }) - const sessions = await svc.list({ limit: 2 }) + const sessions = yield* SessionNs.Service.use((session) => session.list({ limit: 2 })) expect(sessions.length).toBe(2) - }, - }) - }) + }), + { git: true }, + ) }) From 0ce614a280aab5ceb26a449577ba88c9ec9941cb Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Tue, 12 May 2026 16:46:35 +0000 Subject: [PATCH 28/70] chore: generate --- packages/opencode/test/file/fsmonitor.test.ts | 5 +- .../opencode/test/file/path-traversal.test.ts | 62 +++++++++++-------- .../opencode/test/server/session-list.test.ts | 56 +++++++++-------- .../opencode/test/tool/apply_patch.test.ts | 11 +++- 4 files changed, 77 insertions(+), 57 deletions(-) diff --git a/packages/opencode/test/file/fsmonitor.test.ts b/packages/opencode/test/file/fsmonitor.test.ts index 3e025825b9..b8d3bd6055 100644 --- a/packages/opencode/test/file/fsmonitor.test.ts +++ b/packages/opencode/test/file/fsmonitor.test.ts @@ -4,7 +4,10 @@ import { Effect } from "effect" import fs from "fs/promises" import path from "path" -const it = process.platform === "win32" ? (await import("../lib/effect")).testEffect((await import("../../src/file")).File.defaultLayer) : undefined +const it = + process.platform === "win32" + ? (await import("../lib/effect")).testEffect((await import("../../src/file")).File.defaultLayer) + : undefined describe("file fsmonitor", () => { if (!it) { diff --git a/packages/opencode/test/file/path-traversal.test.ts b/packages/opencode/test/file/path-traversal.test.ts index 28bd34978b..336f214d1a 100644 --- a/packages/opencode/test/file/path-traversal.test.ts +++ b/packages/opencode/test/file/path-traversal.test.ts @@ -106,13 +106,15 @@ describe("File.list path traversal protection", () => { }) describe("containsPath", () => { - it.instance("returns true for path inside directory", () => - Effect.gen(function* () { - const test = yield* TestInstance - const ctx = yield* InstanceState.context - expect(containsPath(path.join(test.directory, "foo.txt"), ctx)).toBe(true) - expect(containsPath(path.join(test.directory, "src", "file.ts"), ctx)).toBe(true) - }), + it.instance( + "returns true for path inside directory", + () => + Effect.gen(function* () { + const test = yield* TestInstance + const ctx = yield* InstanceState.context + expect(containsPath(path.join(test.directory, "foo.txt"), ctx)).toBe(true) + expect(containsPath(path.join(test.directory, "src", "file.ts"), ctx)).toBe(true) + }), { git: true }, ) @@ -135,32 +137,38 @@ describe("containsPath", () => { { git: true }, ) - it.instance("returns false for path outside both directory and worktree", () => - Effect.gen(function* () { - const ctx = yield* InstanceState.context - expect(containsPath("/etc/passwd", ctx)).toBe(false) - expect(containsPath("/tmp/other-project", ctx)).toBe(false) - }), + it.instance( + "returns false for path outside both directory and worktree", + () => + Effect.gen(function* () { + const ctx = yield* InstanceState.context + expect(containsPath("/etc/passwd", ctx)).toBe(false) + expect(containsPath("/tmp/other-project", ctx)).toBe(false) + }), { git: true }, ) - it.instance("returns false for path with .. escaping worktree", () => - Effect.gen(function* () { - const test = yield* TestInstance - const ctx = yield* InstanceState.context - expect(containsPath(path.join(test.directory, "..", "escape.txt"), ctx)).toBe(false) - }), + it.instance( + "returns false for path with .. escaping worktree", + () => + Effect.gen(function* () { + const test = yield* TestInstance + const ctx = yield* InstanceState.context + expect(containsPath(path.join(test.directory, "..", "escape.txt"), ctx)).toBe(false) + }), { git: true }, ) - it.instance("handles directory === worktree (running from repo root)", () => - Effect.gen(function* () { - const test = yield* TestInstance - const ctx = yield* InstanceState.context - expect(ctx.directory).toBe(ctx.worktree) - expect(containsPath(path.join(test.directory, "file.txt"), ctx)).toBe(true) - expect(containsPath("/etc/passwd", ctx)).toBe(false) - }), + it.instance( + "handles directory === worktree (running from repo root)", + () => + Effect.gen(function* () { + const test = yield* TestInstance + const ctx = yield* InstanceState.context + expect(ctx.directory).toBe(ctx.worktree) + expect(containsPath(path.join(test.directory, "file.txt"), ctx)).toBe(true) + expect(containsPath("/etc/passwd", ctx)).toBe(false) + }), { git: true }, ) diff --git a/packages/opencode/test/server/session-list.test.ts b/packages/opencode/test/server/session-list.test.ts index 7a4eb61a41..e5dc725463 100644 --- a/packages/opencode/test/server/session-list.test.ts +++ b/packages/opencode/test/server/session-list.test.ts @@ -37,7 +37,9 @@ describe("session.list", () => { yield* Effect.promise(() => mkdir(path.join(test.directory, "packages", "app"), { recursive: true })) const root = yield* withSession({ title: "root" }) - const parent = yield* withSession({ title: "parent" }).pipe(provideInstance(path.join(test.directory, "packages"))) + const parent = yield* withSession({ title: "parent" }).pipe( + provideInstance(path.join(test.directory, "packages")), + ) const current = yield* withSession({ title: "current" }).pipe( provideInstance(path.join(test.directory, "packages", "opencode")), ) @@ -64,7 +66,9 @@ describe("session.list", () => { yield* Effect.promise(() => mkdir(path.join(test.directory, "packages", "app"), { recursive: true })) const root = yield* withSession({ title: "root" }) - const parent = yield* withSession({ title: "parent" }).pipe(provideInstance(path.join(test.directory, "packages"))) + const parent = yield* withSession({ title: "parent" }).pipe( + provideInstance(path.join(test.directory, "packages")), + ) const current = yield* withSession({ title: "current" }).pipe( provideInstance(path.join(test.directory, "packages", "opencode")), ) @@ -72,11 +76,9 @@ describe("session.list", () => { provideInstance(path.join(test.directory, "packages", "app")), ) - const ids = ( - yield* SessionNs.Service.use((session) => - session.list({ directory: path.join(test.directory, "packages", "opencode") }), - ) - ).map((session) => session.id) + const ids = (yield* SessionNs.Service.use((session) => + session.list({ directory: path.join(test.directory, "packages", "opencode") }), + )).map((session) => session.id) expect(ids).not.toContain(root.id) expect(ids).not.toContain(parent.id) expect(ids).toContain(current.id) @@ -109,14 +111,12 @@ describe("session.list", () => { provideInstance(path.join(test.directory, "packages", "app")), ) - const pathIDs = ( - yield* SessionNs.Service.use((session) => - session.list({ - directory: path.join(test.directory, "packages", "app"), - path: "packages/opencode/src", - }), - ) - ).map((session) => session.id) + const pathIDs = (yield* SessionNs.Service.use((session) => + session.list({ + directory: path.join(test.directory, "packages", "app"), + path: "packages/opencode/src", + }), + )).map((session) => session.id) expect(pathIDs).not.toContain(parent.id) expect(pathIDs).toContain(current.id) expect(pathIDs).toContain(deeper.id) @@ -131,7 +131,9 @@ describe("session.list", () => { Effect.gen(function* () { Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = false const test = yield* TestInstance - yield* Effect.promise(() => mkdir(path.join(test.directory, "packages", "opencode", "src"), { recursive: true })) + yield* Effect.promise(() => + mkdir(path.join(test.directory, "packages", "opencode", "src"), { recursive: true }), + ) yield* Effect.promise(() => mkdir(path.join(test.directory, "packages", "app"), { recursive: true })) const current = yield* withSession({ title: "legacy-current" }).pipe( @@ -142,20 +144,22 @@ describe("session.list", () => { ) yield* Effect.sync(() => - Database.use((db) => db.update(SessionTable).set({ path: null }).where(eq(SessionTable.id, current.id)).run()), + Database.use((db) => + db.update(SessionTable).set({ path: null }).where(eq(SessionTable.id, current.id)).run(), + ), ) yield* Effect.sync(() => - Database.use((db) => db.update(SessionTable).set({ path: null }).where(eq(SessionTable.id, sibling.id)).run()), + Database.use((db) => + db.update(SessionTable).set({ path: null }).where(eq(SessionTable.id, sibling.id)).run(), + ), ) - const pathIDs = ( - yield* SessionNs.Service.use((session) => - session.list({ - directory: path.join(test.directory, "packages", "opencode", "src"), - path: "packages/opencode/src", - }), - ) - ).map((session) => session.id) + const pathIDs = (yield* SessionNs.Service.use((session) => + session.list({ + directory: path.join(test.directory, "packages", "opencode", "src"), + path: "packages/opencode/src", + }), + )).map((session) => session.id) expect(pathIDs).toContain(current.id) expect(pathIDs).not.toContain(sibling.id) }), diff --git a/packages/opencode/test/tool/apply_patch.test.ts b/packages/opencode/test/tool/apply_patch.test.ts index 190254866d..be5754f3b4 100644 --- a/packages/opencode/test/tool/apply_patch.test.ts +++ b/packages/opencode/test/tool/apply_patch.test.ts @@ -212,7 +212,8 @@ describe("tool.apply_patch freeform", () => { const target = path.join(test.directory, "example.cs") yield* writeText(target, `${bom}using System;\n\nclass Test {}\n`) - const patchText = "*** Begin Patch\n*** Update File: example.cs\n@@\n class Test {}\n+class Next {}\n*** End Patch" + const patchText = + "*** Begin Patch\n*** Update File: example.cs\n@@\n class Test {}\n+class Next {}\n*** End Patch" yield* execute({ patchText }, ctx) @@ -320,7 +321,10 @@ describe("tool.apply_patch freeform", () => { const { ctx } = makeCtx() const patchText = "*** Begin Patch\n*** Update File: missing.txt\n@@\n-nope\n+better\n*** End Patch" - yield* expectFailure(execute({ patchText }, ctx), "apply_patch verification failed: Failed to read file to update") + yield* expectFailure( + execute({ patchText }, ctx), + "apply_patch verification failed: Failed to read file to update", + ) }), ) @@ -518,7 +522,8 @@ EOF` // Patch uses ASCII equivalents - should match via normalized pass // The replacement uses ASCII quotes from the patch (not preserving Unicode) - const patchText = '*** Begin Patch\n*** Update File: unicode.txt\n@@\n-He said "hello"\n+He said "hi"\n*** End Patch' + const patchText = + '*** Begin Patch\n*** Update File: unicode.txt\n@@\n-He said "hello"\n+He said "hi"\n*** End Patch' yield* execute({ patchText }, ctx) // Result has ASCII quotes because that's what the patch specifies From 5a4596c879a69aa20343060e5b5d5efeedfbbc0e Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Tue, 12 May 2026 12:50:32 -0400 Subject: [PATCH 29/70] core: Wait 3 days before installing new package versions to reduce supply chain risk --- bunfig.toml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/bunfig.toml b/bunfig.toml index 36a21d9332..363579bbf0 100644 --- a/bunfig.toml +++ b/bunfig.toml @@ -1,6 +1,7 @@ [install] exact = true +# Only install newly resolved package versions published at least 3 days ago. +minimumReleaseAge = 259200 [test] root = "./do-not-run-tests-from-root" - From 53a3f95088941aa0d3979af90b28b071c40fd866 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 12 May 2026 12:58:50 -0400 Subject: [PATCH 30/70] Make core fn Zod import type-only (#27103) --- packages/core/src/util/fn.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/src/util/fn.ts b/packages/core/src/util/fn.ts index 9efe4622fc..828baf3bd7 100644 --- a/packages/core/src/util/fn.ts +++ b/packages/core/src/util/fn.ts @@ -1,4 +1,4 @@ -import { z } from "zod" +import type { z } from "zod" export function fn(schema: T, cb: (input: z.infer) => Result) { const result = (input: z.infer) => { From 2b9af91568d6c7e65f644e52be295a955bf9a7ca Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 12 May 2026 13:08:57 -0400 Subject: [PATCH 31/70] Remove Zod from core log (#27102) --- packages/core/src/util/log.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/core/src/util/log.ts b/packages/core/src/util/log.ts index e1962aed4c..83060b29c6 100644 --- a/packages/core/src/util/log.ts +++ b/packages/core/src/util/log.ts @@ -4,11 +4,14 @@ import path from "path" import fs from "fs/promises" import { createWriteStream } from "fs" import * as Global from "../global" -import z from "zod" +import { Schema } from "effect" import { Glob } from "./glob" -export const Level = z.enum(["DEBUG", "INFO", "WARN", "ERROR"]).meta({ ref: "LogLevel", description: "Log level" }) -export type Level = z.infer +export const Level = Schema.Literals(["DEBUG", "INFO", "WARN", "ERROR"]).annotate({ + identifier: "LogLevel", + description: "Log level", +}) +export type Level = Schema.Schema.Type const levelPriority: Record = { DEBUG: 0, From bc4fdb837023a1b293ebf69f9729febd0ca13353 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 12 May 2026 13:09:23 -0400 Subject: [PATCH 32/70] Remove unused app ID schema (#27105) --- packages/app/src/utils/id.ts | 6 ------ .../.storybook/mocks/app/context/global-sync.ts | 13 +++++++++++++ 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/packages/app/src/utils/id.ts b/packages/app/src/utils/id.ts index fa27cf4c5f..dba7a8d951 100644 --- a/packages/app/src/utils/id.ts +++ b/packages/app/src/utils/id.ts @@ -1,5 +1,3 @@ -import z from "zod" - const prefixes = { session: "ses", message: "msg", @@ -15,10 +13,6 @@ let counter = 0 type Prefix = keyof typeof prefixes export namespace Identifier { - export function schema(prefix: Prefix) { - return z.string().startsWith(prefixes[prefix]) - } - export function ascending(prefix: Prefix, given?: string) { return generateID(prefix, false, given) } diff --git a/packages/storybook/.storybook/mocks/app/context/global-sync.ts b/packages/storybook/.storybook/mocks/app/context/global-sync.ts index 2eb134d37c..92622c04ac 100644 --- a/packages/storybook/.storybook/mocks/app/context/global-sync.ts +++ b/packages/storybook/.storybook/mocks/app/context/global-sync.ts @@ -40,3 +40,16 @@ export function useGlobalSync() { }, } } + +export function useQueryOptions() { + return { + agents: (directory: string) => ({ + queryKey: [directory, "agents"], + queryFn: async () => [], + }), + providers: (directory: string | null) => ({ + queryKey: [directory, "providers"], + queryFn: async () => provider, + }), + } +} From 6b950b666a4cf4d9a238cd4727bd0d39ffeed063 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 12 May 2026 13:51:08 -0400 Subject: [PATCH 33/70] Remove Zod from core dependencies (#27107) --- bun.lock | 1 - packages/core/package.json | 3 +-- packages/core/src/util/fn.ts | 11 ----------- packages/enterprise/src/core/share.ts | 5 ++++- 4 files changed, 5 insertions(+), 15 deletions(-) delete mode 100644 packages/core/src/util/fn.ts diff --git a/bun.lock b/bun.lock index 4268e5fb7d..9ab93237d2 100644 --- a/bun.lock +++ b/bun.lock @@ -214,7 +214,6 @@ "npm-package-arg": "13.0.2", "semver": "^7.6.3", "xdg-basedir": "5.1.0", - "zod": "catalog:", }, "devDependencies": { "@tsconfig/bun": "catalog:", diff --git a/packages/core/package.json b/packages/core/package.json index e2ffa31d8d..6bcef68dc5 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -41,8 +41,7 @@ "minimatch": "10.2.5", "npm-package-arg": "13.0.2", "semver": "^7.6.3", - "xdg-basedir": "5.1.0", - "zod": "catalog:" + "xdg-basedir": "5.1.0" }, "overrides": { "drizzle-orm": "catalog:" diff --git a/packages/core/src/util/fn.ts b/packages/core/src/util/fn.ts deleted file mode 100644 index 828baf3bd7..0000000000 --- a/packages/core/src/util/fn.ts +++ /dev/null @@ -1,11 +0,0 @@ -import type { z } from "zod" - -export function fn(schema: T, cb: (input: z.infer) => Result) { - const result = (input: z.infer) => { - const parsed = schema.parse(input) - return cb(parsed) - } - result.force = (input: z.infer) => cb(input) - result.schema = schema - return result -} diff --git a/packages/enterprise/src/core/share.ts b/packages/enterprise/src/core/share.ts index fb8cd30295..a39171462d 100644 --- a/packages/enterprise/src/core/share.ts +++ b/packages/enterprise/src/core/share.ts @@ -1,9 +1,12 @@ import { Message, Model, Part, Session, SnapshotFileDiff } from "@opencode-ai/sdk/v2" -import { fn } from "@opencode-ai/core/util/fn" import { iife } from "@opencode-ai/core/util/iife" import z from "zod" import { Storage } from "./storage" +function fn(schema: T, cb: (input: z.infer) => Result) { + return (input: z.infer) => cb(schema.parse(input)) +} + export namespace Share { export const Info = z.object({ id: z.string(), From fda37b3609954a0f09991f1c76d9238044381e02 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 12 May 2026 14:02:12 -0400 Subject: [PATCH 34/70] Remove Zod from app global SDK (#27111) --- bun.lock | 1 - packages/app/package.json | 3 +-- packages/app/src/context/global-sdk.tsx | 8 +++----- 3 files changed, 4 insertions(+), 8 deletions(-) diff --git a/bun.lock b/bun.lock index 9ab93237d2..5da5889101 100644 --- a/bun.lock +++ b/bun.lock @@ -65,7 +65,6 @@ "solid-list": "catalog:", "tailwindcss": "catalog:", "virtua": "catalog:", - "zod": "catalog:", }, "devDependencies": { "@happy-dom/global-registrator": "20.0.11", diff --git a/packages/app/package.json b/packages/app/package.json index 9eb4083725..86999ed45a 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -73,7 +73,6 @@ "solid-js": "catalog:", "solid-list": "catalog:", "tailwindcss": "catalog:", - "virtua": "catalog:", - "zod": "catalog:" + "virtua": "catalog:" } } diff --git a/packages/app/src/context/global-sdk.tsx b/packages/app/src/context/global-sdk.tsx index e53d60d5a0..001b90b42e 100644 --- a/packages/app/src/context/global-sdk.tsx +++ b/packages/app/src/context/global-sdk.tsx @@ -3,15 +3,13 @@ import { createSimpleContext } from "@opencode-ai/ui/context" import { createGlobalEmitter } from "@solid-primitives/event-bus" import { makeEventListener } from "@solid-primitives/event-listener" import { batch, onCleanup, onMount } from "solid-js" -import z from "zod" import { createSdkForServer } from "@/utils/server" import { useLanguage } from "./language" import { usePlatform } from "./platform" import { useServer } from "./server" -const abortError = z.object({ - name: z.literal("AbortError"), -}) +const isAbortError = (error: unknown) => + error !== null && typeof error === "object" && "name" in error && error.name === "AbortError" export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleContext({ name: "GlobalSDK", @@ -103,7 +101,7 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo let streamErrorLogged = false const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) - const aborted = (error: unknown) => abortError.safeParse(error).success + const aborted = isAbortError let attempt: AbortController | undefined let run: Promise | undefined From 3974520742e1f2a64209429b4e8c6c845f34f6b5 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 12 May 2026 14:09:00 -0400 Subject: [PATCH 35/70] Migrate UI cancel error to tagged error (#27112) --- packages/opencode/src/cli/error.ts | 4 ++-- packages/opencode/src/cli/ui.ts | 3 +-- packages/opencode/test/cli/error.test.ts | 5 +++++ packages/opencode/test/util/error.test.ts | 8 ++------ 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/packages/opencode/src/cli/error.ts b/packages/opencode/src/cli/error.ts index 628aa95696..6fd7b573e2 100644 --- a/packages/opencode/src/cli/error.ts +++ b/packages/opencode/src/cli/error.ts @@ -78,8 +78,8 @@ export function FormatError(input: unknown) { ].join("\n") } - // UICancelledError: void (no data) - if (NamedError.hasName(input, "UICancelledError")) { + // UICancelledError: user cancelled an interactive CLI prompt + if (isTaggedError(input, "UICancelledError") || NamedError.hasName(input, "UICancelledError")) { return "" } } diff --git a/packages/opencode/src/cli/ui.ts b/packages/opencode/src/cli/ui.ts index 69e04b925a..6ad6495cf1 100644 --- a/packages/opencode/src/cli/ui.ts +++ b/packages/opencode/src/cli/ui.ts @@ -1,5 +1,4 @@ import { EOL } from "os" -import { NamedError } from "@opencode-ai/core/util/error" import { Schema } from "effect" import { logo as glyphs } from "./logo" @@ -10,7 +9,7 @@ const wordmark = [ `▀▀▀▀ █▀▀▀ ▀▀▀▀ ▀ ▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀`, ] -export const CancelledError = NamedError.create("UICancelledError", Schema.optional(Schema.Void)) +export class CancelledError extends Schema.TaggedErrorClass()("UICancelledError", {}) {} export const Style = { TEXT_HIGHLIGHT: "\x1b[96m", diff --git a/packages/opencode/test/cli/error.test.ts b/packages/opencode/test/cli/error.test.ts index 6af2633ce6..b4d1dbeda7 100644 --- a/packages/opencode/test/cli/error.test.ts +++ b/packages/opencode/test/cli/error.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from "bun:test" import { AccountTransportError } from "../../src/account/schema" import { FormatError } from "../../src/cli/error" +import { UI } from "../../src/cli/ui" describe("cli.error", () => { test("formats account transport errors clearly", () => { @@ -15,4 +16,8 @@ describe("cli.error", () => { expect(formatted).toContain("This failed before the server returned an HTTP response.") expect(formatted).toContain("Check your network, proxy, or VPN configuration and try again.") }) + + test("formats cancelled UI errors as empty output", () => { + expect(FormatError(new UI.CancelledError())).toBe("") + }) }) diff --git a/packages/opencode/test/util/error.test.ts b/packages/opencode/test/util/error.test.ts index 8d077b1f26..fdb559a231 100644 --- a/packages/opencode/test/util/error.test.ts +++ b/packages/opencode/test/util/error.test.ts @@ -1,8 +1,6 @@ import { describe, expect, test } from "bun:test" -import { Schema } from "effect" import { NamedError } from "@opencode-ai/core/util/error" import { errorData, errorFormat, errorMessage } from "../../src/util/error" -import { UI } from "../../src/cli/ui" import { MessageError } from "../../src/session/message-error" describe("util.error", () => { @@ -60,9 +58,7 @@ describe("util.error", () => { expect(error.toObject()).toEqual({ name: "ProviderAuthError", data: { providerID: "anthropic", message: "boom" } }) }) - test("void named errors accept JSON without data", () => { - const serialized = JSON.parse(JSON.stringify(new UI.CancelledError(undefined).toObject())) - - expect(Schema.decodeUnknownOption(UI.CancelledError.Schema)(serialized)._tag).toBe("Some") + test("named errors without fields serialize data", () => { + expect(new MessageError.OutputLengthError({}).toObject()).toEqual({ name: "MessageOutputLengthError", data: {} }) }) }) From 822eec0d62468cb2927529ba1e706e00c407db54 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 12 May 2026 14:22:56 -0400 Subject: [PATCH 36/70] Fix runner cancel completion (#27115) --- packages/opencode/src/effect/runner.ts | 2 +- packages/opencode/test/effect/runner.test.ts | 39 +++++++++++--------- 2 files changed, 23 insertions(+), 18 deletions(-) diff --git a/packages/opencode/src/effect/runner.ts b/packages/opencode/src/effect/runner.ts index 1e7d4c2966..5d7e8778d7 100644 --- a/packages/opencode/src/effect/runner.ts +++ b/packages/opencode/src/effect/runner.ts @@ -181,7 +181,7 @@ export const make = ( return [ Effect.gen(function* () { yield* Fiber.interrupt(st.run.fiber) - yield* Deferred.await(st.run.done).pipe(Effect.exit, Effect.asVoid) + yield* Deferred.fail(st.run.done, new Cancelled()).pipe(Effect.asVoid) yield* idleIfCurrent() }), { _tag: "Idle" } as const, diff --git a/packages/opencode/test/effect/runner.test.ts b/packages/opencode/test/effect/runner.test.ts index c37cb276b6..3030ca64e0 100644 --- a/packages/opencode/test/effect/runner.test.ts +++ b/packages/opencode/test/effect/runner.test.ts @@ -3,6 +3,11 @@ import { Deferred, Effect, Exit, Fiber, Latch, Ref, Scope } from "effect" import { Runner } from "@/effect/runner" import { it } from "../lib/effect" +const waitForState = (runner: Runner.Runner, tag: Runner.State["_tag"]) => + Effect.gen(function* () { + while (runner.state._tag !== tag) yield* Effect.yieldNow + }).pipe(Effect.timeout("1 second")) + describe("Runner", () => { // --- ensureRunning semantics --- @@ -152,7 +157,7 @@ describe("Runner", () => { const s = yield* Scope.Scope const runner = Runner.make(s, { onInterrupt: Effect.succeed("fallback") }) const fiber = yield* runner.ensureRunning(Effect.never.pipe(Effect.as("never"))).pipe(Effect.forkChild) - yield* Effect.sleep("10 millis") + yield* waitForState(runner, "Running") yield* runner.cancel @@ -169,9 +174,9 @@ describe("Runner", () => { const runner = Runner.make(s, { onInterrupt: Effect.succeed("fallback") }) const a = yield* runner.ensureRunning(Effect.never.pipe(Effect.as("x"))).pipe(Effect.forkChild) - yield* Effect.sleep("10 millis") + yield* waitForState(runner, "Running") const b = yield* runner.ensureRunning(Effect.succeed("y")).pipe(Effect.forkChild) - yield* Effect.sleep("10 millis") + yield* Effect.yieldNow yield* runner.cancel @@ -189,7 +194,7 @@ describe("Runner", () => { const s = yield* Scope.Scope const runner = Runner.make(s) const fiber = yield* runner.ensureRunning(Effect.never.pipe(Effect.as("x"))).pipe(Effect.forkChild) - yield* Effect.sleep("10 millis") + yield* waitForState(runner, "Running") yield* runner.cancel yield* Fiber.await(fiber) @@ -215,7 +220,7 @@ describe("Runner", () => { ) const a = yield* runner.ensureRunning(first).pipe(Effect.exit, Effect.forkChild) - yield* Effect.sleep("10 millis") + yield* waitForState(runner, "Running") const stop = yield* runner.cancel.pipe(Effect.forkChild) yield* Deferred.await(hit).pipe(Effect.timeout("250 millis")) @@ -293,7 +298,7 @@ describe("Runner", () => { const gate = yield* Deferred.make() const sh = yield* runner.startShell(Deferred.await(gate).pipe(Effect.as("first"))).pipe(Effect.forkChild) - yield* Effect.sleep("10 millis") + yield* waitForState(runner, "Shell") const exit = yield* runner.startShell(Effect.succeed("second")).pipe(Effect.exit) expect(Exit.isFailure(exit)).toBe(true) @@ -314,7 +319,7 @@ describe("Runner", () => { }) const sh = yield* runner.startShell(Effect.never.pipe(Effect.as("aborted"))).pipe(Effect.forkChild) - yield* Effect.sleep("10 millis") + yield* waitForState(runner, "Shell") const exit = yield* runner.startShell(Effect.succeed("second")).pipe(Effect.exit) expect(Exit.isFailure(exit)).toBe(true) @@ -333,7 +338,7 @@ describe("Runner", () => { const gate = yield* Deferred.make() const sh = yield* runner.startShell(Deferred.await(gate).pipe(Effect.as("ignored"))).pipe(Effect.forkChild) - yield* Effect.sleep("10 millis") + yield* waitForState(runner, "Shell") const stop = yield* runner.cancel.pipe(Effect.forkChild) const stopExit = yield* Fiber.await(stop).pipe(Effect.timeout("250 millis")) @@ -380,11 +385,11 @@ describe("Runner", () => { const gate = yield* Deferred.make() const sh = yield* runner.startShell(Deferred.await(gate).pipe(Effect.as("shell-result"))).pipe(Effect.forkChild) - yield* Effect.sleep("10 millis") + yield* waitForState(runner, "Shell") expect(runner.state._tag).toBe("Shell") const run = yield* runner.ensureRunning(Effect.succeed("run-result")).pipe(Effect.forkChild) - yield* Effect.sleep("10 millis") + yield* waitForState(runner, "ShellThenRun") expect(runner.state._tag).toBe("ShellThenRun") yield* Deferred.succeed(gate, undefined) @@ -406,7 +411,7 @@ describe("Runner", () => { const gate = yield* Deferred.make() const sh = yield* runner.startShell(Deferred.await(gate).pipe(Effect.as("shell"))).pipe(Effect.forkChild) - yield* Effect.sleep("10 millis") + yield* waitForState(runner, "Shell") const work = Effect.gen(function* () { yield* Ref.update(calls, (n) => n + 1) @@ -414,7 +419,7 @@ describe("Runner", () => { }) const a = yield* runner.ensureRunning(work).pipe(Effect.forkChild) const b = yield* runner.ensureRunning(work).pipe(Effect.forkChild) - yield* Effect.sleep("10 millis") + yield* waitForState(runner, "ShellThenRun") yield* Deferred.succeed(gate, undefined) yield* Fiber.await(sh) @@ -433,10 +438,10 @@ describe("Runner", () => { const runner = Runner.make(s) const sh = yield* runner.startShell(Effect.never.pipe(Effect.as("aborted"))).pipe(Effect.forkChild) - yield* Effect.sleep("10 millis") + yield* waitForState(runner, "Shell") const run = yield* runner.ensureRunning(Effect.succeed("y")).pipe(Effect.forkChild) - yield* Effect.sleep("10 millis") + yield* waitForState(runner, "ShellThenRun") expect(runner.state._tag).toBe("ShellThenRun") yield* runner.cancel @@ -472,7 +477,7 @@ describe("Runner", () => { onIdle: Ref.update(count, (n) => n + 1), }) const fiber = yield* runner.ensureRunning(Effect.never.pipe(Effect.as("x"))).pipe(Effect.forkChild) - yield* Effect.sleep("10 millis") + yield* waitForState(runner, "Running") yield* runner.cancel yield* Fiber.await(fiber) expect(yield* Ref.get(count)).toBeGreaterThanOrEqual(1) @@ -502,7 +507,7 @@ describe("Runner", () => { const gate = yield* Deferred.make() const fiber = yield* runner.ensureRunning(Deferred.await(gate).pipe(Effect.as("ok"))).pipe(Effect.forkChild) - yield* Effect.sleep("10 millis") + yield* waitForState(runner, "Running") expect(runner.busy).toBe(true) yield* Deferred.succeed(gate, undefined) @@ -519,7 +524,7 @@ describe("Runner", () => { const gate = yield* Deferred.make() const fiber = yield* runner.startShell(Deferred.await(gate).pipe(Effect.as("ok"))).pipe(Effect.forkChild) - yield* Effect.sleep("10 millis") + yield* waitForState(runner, "Shell") expect(runner.busy).toBe(true) yield* Deferred.succeed(gate, undefined) From a3714d4399bc7002a111519ad7c5bb9c97a11382 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Tue, 12 May 2026 18:27:42 +0000 Subject: [PATCH 37/70] 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 33003919af..ce8cded232 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,8 +1,8 @@ { "nodeModules": { - "x86_64-linux": "sha256-Q9r1S15YL9LQK7DRhuOpw3Fxi24BPovEM995GZJayKw=", - "aarch64-linux": "sha256-C0rRTLnxxuuEkCBc3JZbkR66TUVwpcPFif3BU9GRAuA=", - "aarch64-darwin": "sha256-1HvalOO/pOkRlYH8CZ93psapt90C+pYzui1JCadBE1Q=", - "x86_64-darwin": "sha256-RrndyLWfhWm4mZ88XytFF2NI+ly8la550Z5LBN/g5u4=" + "x86_64-linux": "sha256-MUHog06sZEi6bXR1m8exdkjSNW9bHEv9bPQXACJ7SFw=", + "aarch64-linux": "sha256-3dwdZ3It++OsdGT8xMOQ10Arz8eeODp/LXOrI4DLEhY=", + "aarch64-darwin": "sha256-TmUPGDCewjsrT13npVH6B55J43NKKut67p/HgPJpQNM=", + "x86_64-darwin": "sha256-j8I7t3MZoUQUMFRWyaFO75TRbAw5TauSZAa4yKOHFMA=" } } From ec30ff9120ade9032c94f899e10274fd45d4ec93 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 12 May 2026 14:49:30 -0400 Subject: [PATCH 38/70] test(agent): migrate agent tests to Effect runner (#27118) --- packages/opencode/test/agent/agent.test.ts | 1038 +++++++++----------- 1 file changed, 478 insertions(+), 560 deletions(-) diff --git a/packages/opencode/test/agent/agent.test.ts b/packages/opencode/test/agent/agent.test.ts index df68fdfdc6..7fd489150b 100644 --- a/packages/opencode/test/agent/agent.test.ts +++ b/packages/opencode/test/agent/agent.test.ts @@ -1,12 +1,15 @@ -import { afterEach, test, expect } from "bun:test" -import { Effect } from "effect" +import { afterEach, expect } from "bun:test" +import { Cause, Effect, Exit } from "effect" import path from "path" -import { disposeAllInstances, provideInstance, tmpdir } from "../fixture/fixture" -import { WithInstance } from "../../src/project/with-instance" +import { disposeAllInstances, TestInstance } from "../fixture/fixture" +import { testEffect } from "../lib/effect" import { Agent } from "../../src/agent/agent" import { Global } from "@opencode-ai/core/global" import { Flag } from "@opencode-ai/core/flag/flag" import { Permission } from "../../src/permission" +import { Truncate } from "../../src/tool/truncate" + +const it = testEffect(Agent.defaultLayer) // Helper to evaluate permission for a tool with wildcard pattern function evalPerm(agent: Agent.Info | undefined, permission: string): Permission.Action | undefined { @@ -14,196 +17,189 @@ function evalPerm(agent: Agent.Info | undefined, permission: string): Permission return Permission.evaluate(permission, "*", agent.permission).action } -function load(dir: string, fn: (svc: Agent.Interface) => Effect.Effect) { - return Effect.runPromise(provideInstance(dir)(Agent.Service.use(fn)).pipe(Effect.provide(Agent.defaultLayer))) +function load(fn: (svc: Agent.Interface) => Effect.Effect) { + return Agent.Service.use(fn) } -async function withExperimentalScout(enabled: boolean, fn: () => Promise) { - const original = Flag.OPENCODE_EXPERIMENTAL_SCOUT - Flag.OPENCODE_EXPERIMENTAL_SCOUT = enabled - try { - await fn() - } finally { - Flag.OPENCODE_EXPERIMENTAL_SCOUT = original - } +function withExperimentalScout(enabled: boolean, self: Effect.Effect) { + return Effect.acquireUseRelease( + Effect.sync(() => { + const original = Flag.OPENCODE_EXPERIMENTAL_SCOUT + Flag.OPENCODE_EXPERIMENTAL_SCOUT = enabled + return original + }), + () => self, + (original) => + Effect.sync(() => { + Flag.OPENCODE_EXPERIMENTAL_SCOUT = original + }), + ) } +const expectDefaultAgentError = Effect.fn("AgentTest.expectDefaultAgentError")(function* (message: string) { + const exit = yield* load((svc) => svc.defaultAgent()).pipe(Effect.exit) + expect(Exit.isFailure(exit)).toBe(true) + if (Exit.isFailure(exit)) expect(Cause.pretty(exit.cause)).toContain(message) +}) + afterEach(async () => { await disposeAllInstances() }) -test("returns default native agents when no config", async () => { - await withExperimentalScout(false, async () => { - await using tmp = await tmpdir() - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const agents = await load(tmp.path, (svc) => svc.list()) - const names = agents.map((a) => a.name) - expect(names).toContain("build") - expect(names).toContain("plan") - expect(names).toContain("general") - expect(names).toContain("explore") - expect(names).not.toContain("scout") - expect(names).toContain("compaction") - expect(names).toContain("title") - expect(names).toContain("summary") - }, - }) - }) -}) +it.instance("returns default native agents when no config", () => + withExperimentalScout( + false, + Effect.gen(function* () { + const agents = yield* load((svc) => svc.list()) + const names = agents.map((a) => a.name) + expect(names).toContain("build") + expect(names).toContain("plan") + expect(names).toContain("general") + expect(names).toContain("explore") + expect(names).not.toContain("scout") + expect(names).toContain("compaction") + expect(names).toContain("title") + expect(names).toContain("summary") + }), + ), +) -test("build agent has correct default properties", async () => { - await using tmp = await tmpdir() - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const build = await load(tmp.path, (svc) => svc.get("build")) - expect(build).toBeDefined() - expect(build?.mode).toBe("primary") - expect(build?.native).toBe(true) - expect(evalPerm(build, "edit")).toBe("allow") - expect(evalPerm(build, "bash")).toBe("allow") - expect(evalPerm(build, "repo_clone")).toBe("deny") - expect(evalPerm(build, "repo_overview")).toBe("deny") - }, - }) -}) +it.instance("build agent has correct default properties", () => + Effect.gen(function* () { + const build = yield* load((svc) => svc.get("build")) + expect(build).toBeDefined() + expect(build?.mode).toBe("primary") + expect(build?.native).toBe(true) + expect(evalPerm(build, "edit")).toBe("allow") + expect(evalPerm(build, "bash")).toBe("allow") + expect(evalPerm(build, "repo_clone")).toBe("deny") + expect(evalPerm(build, "repo_overview")).toBe("deny") + }), +) -test("plan agent denies edits except .opencode/plans/*", async () => { - await using tmp = await tmpdir() - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const plan = await load(tmp.path, (svc) => svc.get("plan")) - expect(plan).toBeDefined() - // Wildcard is denied - expect(evalPerm(plan, "edit")).toBe("deny") - // But specific path is allowed - expect(Permission.evaluate("edit", ".opencode/plans/foo.md", plan!.permission).action).toBe("allow") - }, - }) -}) +it.instance("plan agent denies edits except .opencode/plans/*", () => + Effect.gen(function* () { + const plan = yield* load((svc) => svc.get("plan")) + expect(plan).toBeDefined() + // Wildcard is denied + expect(evalPerm(plan, "edit")).toBe("deny") + // But specific path is allowed + expect(Permission.evaluate("edit", ".opencode/plans/foo.md", plan!.permission).action).toBe("allow") + }), +) -test("explore agent denies edit and write", async () => { - await using tmp = await tmpdir() - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const explore = await load(tmp.path, (svc) => svc.get("explore")) - expect(explore).toBeDefined() - expect(explore?.mode).toBe("subagent") - expect(evalPerm(explore, "edit")).toBe("deny") - expect(evalPerm(explore, "write")).toBe("deny") - expect(evalPerm(explore, "todowrite")).toBe("deny") - }, - }) -}) +it.instance("explore agent denies edit and write", () => + Effect.gen(function* () { + const explore = yield* load((svc) => svc.get("explore")) + expect(explore).toBeDefined() + expect(explore?.mode).toBe("subagent") + expect(evalPerm(explore, "edit")).toBe("deny") + expect(evalPerm(explore, "write")).toBe("deny") + expect(evalPerm(explore, "todowrite")).toBe("deny") + }), +) -test("explore agent asks for external directories and allows whitelisted external paths", async () => { - const { Truncate } = await import("../../src/tool/truncate") - await using tmp = await tmpdir() - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const explore = await load(tmp.path, (svc) => svc.get("explore")) - expect(explore).toBeDefined() - expect(Permission.evaluate("external_directory", "/some/other/path", explore!.permission).action).toBe("ask") - expect(Permission.evaluate("external_directory", Truncate.GLOB, explore!.permission).action).toBe("allow") +it.instance("explore agent asks for external directories and allows whitelisted external paths", () => + Effect.gen(function* () { + const explore = yield* load((svc) => svc.get("explore")) + expect(explore).toBeDefined() + expect(Permission.evaluate("external_directory", "/some/other/path", explore!.permission).action).toBe("ask") + expect(Permission.evaluate("external_directory", Truncate.GLOB, explore!.permission).action).toBe("allow") + expect(Permission.evaluate("external_directory", path.join(Global.Path.tmp, "agent-work"), explore!.permission).action).toBe( + "allow", + ) + }), +) + +it.instance("scout agent allows repo cloning and repo cache reads", () => + withExperimentalScout( + true, + Effect.gen(function* () { + const scout = yield* load((svc) => svc.get("scout")) + expect(scout).toBeDefined() + expect(scout?.mode).toBe("subagent") + expect(evalPerm(scout, "repo_clone")).toBe("allow") + expect(evalPerm(scout, "repo_overview")).toBe("allow") + expect(evalPerm(scout, "edit")).toBe("deny") expect( - Permission.evaluate("external_directory", path.join(Global.Path.tmp, "agent-work"), explore!.permission).action, + Permission.evaluate( + "external_directory", + path.join(Global.Path.repos, "github.com", "owner", "repo", "README.md"), + scout!.permission, + ).action, ).toBe("allow") - }, - }) -}) + }), + ), +) -test("scout agent allows repo cloning and repo cache reads", async () => { - await withExperimentalScout(true, async () => { - await using tmp = await tmpdir() - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const scout = await load(tmp.path, (svc) => svc.get("scout")) - expect(scout).toBeDefined() - expect(scout?.mode).toBe("subagent") - expect(evalPerm(scout, "repo_clone")).toBe("allow") - expect(evalPerm(scout, "repo_overview")).toBe("allow") - expect(evalPerm(scout, "edit")).toBe("deny") - expect( - Permission.evaluate( - "external_directory", - path.join(Global.Path.repos, "github.com", "owner", "repo", "README.md"), - scout!.permission, - ).action, - ).toBe("allow") - }, - }) - }) -}) - -test("reference config does not create subagents", async () => { - await withExperimentalScout(true, async () => { - await using tmp = await tmpdir({ - config: { - reference: { - effect: "github.com/effect/effect-smol", - effectFull: { - repository: "Effect-TS/effect", - branch: "main", - }, - localdocs: "../docs", - localdocsFull: { - path: "../local-docs", - }, - }, - }, - }) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const agents = await load(tmp.path, (svc) => svc.list()) +it.instance( + "reference config does not create subagents", + () => + withExperimentalScout( + true, + Effect.gen(function* () { + const agents = yield* load((svc) => svc.list()) const names = agents.map((agent) => agent.name) expect(names).toContain("scout") expect(names).not.toContain("effect") expect(names).not.toContain("effectFull") expect(names).not.toContain("localdocs") expect(names).not.toContain("localdocsFull") + }), + ), + { + config: { + reference: { + effect: "github.com/effect/effect-smol", + effectFull: { + repository: "Effect-TS/effect", + branch: "main", + }, + localdocs: "../docs", + localdocsFull: { + path: "../local-docs", + }, }, - }) - }) -}) - -test("general agent denies todo tools", async () => { - await using tmp = await tmpdir() - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const general = await load(tmp.path, (svc) => svc.get("general")) - expect(general).toBeDefined() - expect(general?.mode).toBe("subagent") - expect(general?.hidden).toBeUndefined() - expect(evalPerm(general, "todowrite")).toBe("deny") }, - }) -}) + }, +) -test("compaction agent denies all permissions", async () => { - await using tmp = await tmpdir() - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const compaction = await load(tmp.path, (svc) => svc.get("compaction")) - expect(compaction).toBeDefined() - expect(compaction?.hidden).toBe(true) - expect(evalPerm(compaction, "bash")).toBe("deny") - expect(evalPerm(compaction, "edit")).toBe("deny") - expect(evalPerm(compaction, "read")).toBe("deny") - }, - }) -}) +it.instance("general agent denies todo tools", () => + Effect.gen(function* () { + const general = yield* load((svc) => svc.get("general")) + expect(general).toBeDefined() + expect(general?.mode).toBe("subagent") + expect(general?.hidden).toBeUndefined() + expect(evalPerm(general, "todowrite")).toBe("deny") + }), +) -test("custom agent from config creates new agent", async () => { - await using tmp = await tmpdir({ +it.instance("compaction agent denies all permissions", () => + Effect.gen(function* () { + const compaction = yield* load((svc) => svc.get("compaction")) + expect(compaction).toBeDefined() + expect(compaction?.hidden).toBe(true) + expect(evalPerm(compaction, "bash")).toBe("deny") + expect(evalPerm(compaction, "edit")).toBe("deny") + expect(evalPerm(compaction, "read")).toBe("deny") + }), +) + +it.instance( + "custom agent from config creates new agent", + () => + Effect.gen(function* () { + const custom = yield* load((svc) => svc.get("my_custom_agent")) + expect(custom).toBeDefined() + expect(String(custom?.model?.providerID)).toBe("openai") + expect(String(custom?.model?.modelID)).toBe("gpt-4") + expect(custom?.description).toBe("My custom agent") + expect(custom?.temperature).toBe(0.5) + expect(custom?.topP).toBe(0.9) + expect(custom?.native).toBe(false) + expect(custom?.mode).toBe("all") + }), + { config: { agent: { my_custom_agent: { @@ -214,25 +210,23 @@ test("custom agent from config creates new agent", async () => { }, }, }, - }) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const custom = await load(tmp.path, (svc) => svc.get("my_custom_agent")) - expect(custom).toBeDefined() - expect(String(custom?.model?.providerID)).toBe("openai") - expect(String(custom?.model?.modelID)).toBe("gpt-4") - expect(custom?.description).toBe("My custom agent") - expect(custom?.temperature).toBe(0.5) - expect(custom?.topP).toBe(0.9) - expect(custom?.native).toBe(false) - expect(custom?.mode).toBe("all") - }, - }) -}) + }, +) -test("custom agent config overrides native agent properties", async () => { - await using tmp = await tmpdir({ +it.instance( + "custom agent config overrides native agent properties", + () => + Effect.gen(function* () { + const build = yield* load((svc) => svc.get("build")) + expect(build).toBeDefined() + expect(String(build?.model?.providerID)).toBe("anthropic") + expect(String(build?.model?.modelID)).toBe("claude-3") + expect(build?.description).toBe("Custom build agent") + expect(build?.temperature).toBe(0.7) + expect(build?.color).toBe("#FF0000") + expect(build?.native).toBe(true) + }), + { config: { agent: { build: { @@ -243,44 +237,40 @@ test("custom agent config overrides native agent properties", async () => { }, }, }, - }) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const build = await load(tmp.path, (svc) => svc.get("build")) - expect(build).toBeDefined() - expect(String(build?.model?.providerID)).toBe("anthropic") - expect(String(build?.model?.modelID)).toBe("claude-3") - expect(build?.description).toBe("Custom build agent") - expect(build?.temperature).toBe(0.7) - expect(build?.color).toBe("#FF0000") - expect(build?.native).toBe(true) - }, - }) -}) + }, +) -test("agent disable removes agent from list", async () => { - await using tmp = await tmpdir({ +it.instance( + "agent disable removes agent from list", + () => + Effect.gen(function* () { + const explore = yield* load((svc) => svc.get("explore")) + expect(explore).toBeUndefined() + const agents = yield* load((svc) => svc.list()) + const names = agents.map((a) => a.name) + expect(names).not.toContain("explore") + }), + { config: { agent: { explore: { disable: true }, }, }, - }) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const explore = await load(tmp.path, (svc) => svc.get("explore")) - expect(explore).toBeUndefined() - const agents = await load(tmp.path, (svc) => svc.list()) - const names = agents.map((a) => a.name) - expect(names).not.toContain("explore") - }, - }) -}) + }, +) -test("agent permission config merges with defaults", async () => { - await using tmp = await tmpdir({ +it.instance( + "agent permission config merges with defaults", + () => + Effect.gen(function* () { + const build = yield* load((svc) => svc.get("build")) + expect(build).toBeDefined() + // Specific pattern is denied + expect(Permission.evaluate("bash", "rm -rf *", build!.permission).action).toBe("deny") + // Edit still allowed + expect(evalPerm(build, "edit")).toBe("allow") + }), + { config: { agent: { build: { @@ -292,111 +282,102 @@ test("agent permission config merges with defaults", async () => { }, }, }, - }) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const build = await load(tmp.path, (svc) => svc.get("build")) - expect(build).toBeDefined() - // Specific pattern is denied - expect(Permission.evaluate("bash", "rm -rf *", build!.permission).action).toBe("deny") - // Edit still allowed - expect(evalPerm(build, "edit")).toBe("allow") - }, - }) -}) + }, +) -test("global permission config applies to all agents", async () => { - await using tmp = await tmpdir({ +it.instance( + "global permission config applies to all agents", + () => + Effect.gen(function* () { + const build = yield* load((svc) => svc.get("build")) + expect(build).toBeDefined() + expect(evalPerm(build, "bash")).toBe("deny") + }), + { config: { permission: { bash: "deny", }, }, - }) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const build = await load(tmp.path, (svc) => svc.get("build")) - expect(build).toBeDefined() - expect(evalPerm(build, "bash")).toBe("deny") - }, - }) -}) + }, +) -test("agent steps/maxSteps config sets steps property", async () => { - await using tmp = await tmpdir({ +it.instance( + "agent steps/maxSteps config sets steps property", + () => + Effect.gen(function* () { + const build = yield* load((svc) => svc.get("build")) + const plan = yield* load((svc) => svc.get("plan")) + expect(build?.steps).toBe(50) + expect(plan?.steps).toBe(100) + }), + { config: { agent: { build: { steps: 50 }, plan: { maxSteps: 100 }, }, }, - }) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const build = await load(tmp.path, (svc) => svc.get("build")) - const plan = await load(tmp.path, (svc) => svc.get("plan")) - expect(build?.steps).toBe(50) - expect(plan?.steps).toBe(100) - }, - }) -}) + }, +) -test("agent mode can be overridden", async () => { - await using tmp = await tmpdir({ +it.instance( + "agent mode can be overridden", + () => + Effect.gen(function* () { + const explore = yield* load((svc) => svc.get("explore")) + expect(explore?.mode).toBe("primary") + }), + { config: { agent: { explore: { mode: "primary" }, }, }, - }) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const explore = await load(tmp.path, (svc) => svc.get("explore")) - expect(explore?.mode).toBe("primary") - }, - }) -}) + }, +) -test("agent name can be overridden", async () => { - await using tmp = await tmpdir({ +it.instance( + "agent name can be overridden", + () => + Effect.gen(function* () { + const build = yield* load((svc) => svc.get("build")) + expect(build?.name).toBe("Builder") + }), + { config: { agent: { build: { name: "Builder" }, }, }, - }) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const build = await load(tmp.path, (svc) => svc.get("build")) - expect(build?.name).toBe("Builder") - }, - }) -}) + }, +) -test("agent prompt can be set from config", async () => { - await using tmp = await tmpdir({ +it.instance( + "agent prompt can be set from config", + () => + Effect.gen(function* () { + const build = yield* load((svc) => svc.get("build")) + expect(build?.prompt).toBe("Custom system prompt") + }), + { config: { agent: { build: { prompt: "Custom system prompt" }, }, }, - }) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const build = await load(tmp.path, (svc) => svc.get("build")) - expect(build?.prompt).toBe("Custom system prompt") - }, - }) -}) + }, +) -test("unknown agent properties are placed into options", async () => { - await using tmp = await tmpdir({ +it.instance( + "unknown agent properties are placed into options", + () => + Effect.gen(function* () { + const build = yield* load((svc) => svc.get("build")) + expect(build?.options.random_property).toBe("hello") + expect(build?.options.another_random).toBe(123) + }), + { config: { agent: { build: { @@ -405,19 +386,18 @@ test("unknown agent properties are placed into options", async () => { }, }, }, - }) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const build = await load(tmp.path, (svc) => svc.get("build")) - expect(build?.options.random_property).toBe("hello") - expect(build?.options.another_random).toBe(123) - }, - }) -}) + }, +) -test("agent options merge correctly", async () => { - await using tmp = await tmpdir({ +it.instance( + "agent options merge correctly", + () => + Effect.gen(function* () { + const build = yield* load((svc) => svc.get("build")) + expect(build?.options.custom_option).toBe(true) + expect(build?.options.another_option).toBe("value") + }), + { config: { agent: { build: { @@ -428,19 +408,21 @@ test("agent options merge correctly", async () => { }, }, }, - }) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const build = await load(tmp.path, (svc) => svc.get("build")) - expect(build?.options.custom_option).toBe(true) - expect(build?.options.another_option).toBe("value") - }, - }) -}) + }, +) -test("multiple custom agents can be defined", async () => { - await using tmp = await tmpdir({ +it.instance( + "multiple custom agents can be defined", + () => + Effect.gen(function* () { + const agentA = yield* load((svc) => svc.get("agent_a")) + const agentB = yield* load((svc) => svc.get("agent_b")) + expect(agentA?.description).toBe("Agent A") + expect(agentA?.mode).toBe("subagent") + expect(agentB?.description).toBe("Agent B") + expect(agentB?.mode).toBe("primary") + }), + { config: { agent: { agent_a: { @@ -453,22 +435,18 @@ test("multiple custom agents can be defined", async () => { }, }, }, - }) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const agentA = await load(tmp.path, (svc) => svc.get("agent_a")) - const agentB = await load(tmp.path, (svc) => svc.get("agent_b")) - expect(agentA?.description).toBe("Agent A") - expect(agentA?.mode).toBe("subagent") - expect(agentB?.description).toBe("Agent B") - expect(agentB?.mode).toBe("primary") - }, - }) -}) + }, +) -test("Agent.list keeps the default agent first and sorts the rest by name", async () => { - await using tmp = await tmpdir({ +it.instance( + "Agent.list keeps the default agent first and sorts the rest by name", + () => + Effect.gen(function* () { + const names = (yield* load((svc) => svc.list())).map((a) => a.name) + expect(names[0]).toBe("plan") + expect(names.slice(1)).toEqual(names.slice(1).toSorted((a, b) => a.localeCompare(b))) + }), + { config: { default_agent: "plan", agent: { @@ -482,53 +460,40 @@ test("Agent.list keeps the default agent first and sorts the rest by name", asyn }, }, }, - }) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const names = (await load(tmp.path, (svc) => svc.list())).map((a) => a.name) - expect(names[0]).toBe("plan") - expect(names.slice(1)).toEqual(names.slice(1).toSorted((a, b) => a.localeCompare(b))) - }, - }) -}) + }, +) -test("Agent.get returns undefined for non-existent agent", async () => { - await using tmp = await tmpdir() - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const nonExistent = await load(tmp.path, (svc) => svc.get("does_not_exist")) - expect(nonExistent).toBeUndefined() - }, - }) -}) +it.instance("Agent.get returns undefined for non-existent agent", () => + Effect.gen(function* () { + const nonExistent = yield* load((svc) => svc.get("does_not_exist")) + expect(nonExistent).toBeUndefined() + }), +) -test("default permission includes doom_loop and external_directory as ask", async () => { - await using tmp = await tmpdir() - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const build = await load(tmp.path, (svc) => svc.get("build")) - expect(evalPerm(build, "doom_loop")).toBe("ask") - expect(evalPerm(build, "external_directory")).toBe("ask") - }, - }) -}) +it.instance("default permission includes doom_loop and external_directory as ask", () => + Effect.gen(function* () { + const build = yield* load((svc) => svc.get("build")) + expect(evalPerm(build, "doom_loop")).toBe("ask") + expect(evalPerm(build, "external_directory")).toBe("ask") + }), +) -test("webfetch is allowed by default", async () => { - await using tmp = await tmpdir() - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const build = await load(tmp.path, (svc) => svc.get("build")) - expect(evalPerm(build, "webfetch")).toBe("allow") - }, - }) -}) +it.instance("webfetch is allowed by default", () => + Effect.gen(function* () { + const build = yield* load((svc) => svc.get("build")) + expect(evalPerm(build, "webfetch")).toBe("allow") + }), +) -test("legacy tools config converts to permissions", async () => { - await using tmp = await tmpdir({ +it.instance( + "legacy tools config converts to permissions", + () => + Effect.gen(function* () { + const build = yield* load((svc) => svc.get("build")) + expect(evalPerm(build, "bash")).toBe("deny") + expect(evalPerm(build, "read")).toBe("deny") + }), + { config: { agent: { build: { @@ -539,19 +504,17 @@ test("legacy tools config converts to permissions", async () => { }, }, }, - }) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const build = await load(tmp.path, (svc) => svc.get("build")) - expect(evalPerm(build, "bash")).toBe("deny") - expect(evalPerm(build, "read")).toBe("deny") - }, - }) -}) + }, +) -test("legacy tools config maps write/edit/patch to edit permission", async () => { - await using tmp = await tmpdir({ +it.instance( + "legacy tools config maps write/edit/patch to edit permission", + () => + Effect.gen(function* () { + const build = yield* load((svc) => svc.get("build")) + expect(evalPerm(build, "edit")).toBe("deny") + }), + { config: { agent: { build: { @@ -561,53 +524,47 @@ test("legacy tools config maps write/edit/patch to edit permission", async () => }, }, }, - }) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const build = await load(tmp.path, (svc) => svc.get("build")) - expect(evalPerm(build, "edit")).toBe("deny") - }, - }) -}) + }, +) -test("Truncate.GLOB is allowed even when user denies external_directory globally", async () => { - const { Truncate } = await import("../../src/tool/truncate") - await using tmp = await tmpdir({ +it.instance( + "Truncate.GLOB is allowed even when user denies external_directory globally", + () => + Effect.gen(function* () { + const build = yield* load((svc) => svc.get("build")) + expect(Permission.evaluate("external_directory", Truncate.GLOB, build!.permission).action).toBe("allow") + expect(Permission.evaluate("external_directory", Truncate.DIR, build!.permission).action).toBe("deny") + expect(Permission.evaluate("external_directory", "/some/other/path", build!.permission).action).toBe("deny") + }), + { config: { permission: { external_directory: "deny", }, }, - }) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const build = await load(tmp.path, (svc) => svc.get("build")) + }, +) + +it.instance("global tmp directory children are allowed for external_directory", () => + Effect.gen(function* () { + const build = yield* load((svc) => svc.get("build")) + expect(Permission.evaluate("external_directory", path.join(Global.Path.tmp, "scratch"), build!.permission).action).toBe( + "allow", + ) + expect(Permission.evaluate("external_directory", "/some/other/path", build!.permission).action).toBe("ask") + }), +) + +it.instance( + "Truncate.GLOB is allowed even when user denies external_directory per-agent", + () => + Effect.gen(function* () { + const build = yield* load((svc) => svc.get("build")) expect(Permission.evaluate("external_directory", Truncate.GLOB, build!.permission).action).toBe("allow") expect(Permission.evaluate("external_directory", Truncate.DIR, build!.permission).action).toBe("deny") expect(Permission.evaluate("external_directory", "/some/other/path", build!.permission).action).toBe("deny") - }, - }) -}) - -test("global tmp directory children are allowed for external_directory", async () => { - await using tmp = await tmpdir() - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const build = await load(tmp.path, (svc) => svc.get("build")) - expect( - Permission.evaluate("external_directory", path.join(Global.Path.tmp, "scratch"), build!.permission).action, - ).toBe("allow") - expect(Permission.evaluate("external_directory", "/some/other/path", build!.permission).action).toBe("ask") - }, - }) -}) - -test("Truncate.GLOB is allowed even when user denies external_directory per-agent", async () => { - const { Truncate } = await import("../../src/tool/truncate") - await using tmp = await tmpdir({ + }), + { config: { agent: { build: { @@ -617,21 +574,18 @@ test("Truncate.GLOB is allowed even when user denies external_directory per-agen }, }, }, - }) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const build = await load(tmp.path, (svc) => svc.get("build")) - expect(Permission.evaluate("external_directory", Truncate.GLOB, build!.permission).action).toBe("allow") - expect(Permission.evaluate("external_directory", Truncate.DIR, build!.permission).action).toBe("deny") - expect(Permission.evaluate("external_directory", "/some/other/path", build!.permission).action).toBe("deny") - }, - }) -}) + }, +) -test("explicit Truncate.GLOB deny is respected", async () => { - const { Truncate } = await import("../../src/tool/truncate") - await using tmp = await tmpdir({ +it.instance( + "explicit Truncate.GLOB deny is respected", + () => + Effect.gen(function* () { + const build = yield* load((svc) => svc.get("build")) + expect(Permission.evaluate("external_directory", Truncate.GLOB, build!.permission).action).toBe("deny") + expect(Permission.evaluate("external_directory", Truncate.DIR, build!.permission).action).toBe("deny") + }), + { config: { permission: { external_directory: { @@ -640,81 +594,72 @@ test("explicit Truncate.GLOB deny is respected", async () => { }, }, }, - }) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const build = await load(tmp.path, (svc) => svc.get("build")) - expect(Permission.evaluate("external_directory", Truncate.GLOB, build!.permission).action).toBe("deny") - expect(Permission.evaluate("external_directory", Truncate.DIR, build!.permission).action).toBe("deny") - }, - }) -}) + }, +) -test("skill directories are allowed for external_directory", async () => { - await using tmp = await tmpdir({ - git: true, - init: async (dir) => { - const skillDir = path.join(dir, ".opencode", "skill", "perm-skill") - await Bun.write( - path.join(skillDir, "SKILL.md"), - `--- +it.instance( + "skill directories are allowed for external_directory", + () => + Effect.gen(function* () { + const test = yield* TestInstance + const skillDir = path.join(test.directory, ".opencode", "skill", "perm-skill") + yield* Effect.promise(() => + Bun.write( + path.join(skillDir, "SKILL.md"), + `--- name: perm-skill description: Permission skill. --- # Permission Skill `, + ), ) - }, - }) - const home = process.env.OPENCODE_TEST_HOME - process.env.OPENCODE_TEST_HOME = tmp.path + const home = process.env.OPENCODE_TEST_HOME + process.env.OPENCODE_TEST_HOME = test.directory + yield* Effect.addFinalizer(() => + Effect.sync(() => { + process.env.OPENCODE_TEST_HOME = home + }), + ) - try { - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const build = await load(tmp.path, (svc) => svc.get("build")) - const skillDir = path.join(tmp.path, ".opencode", "skill", "perm-skill") - const target = path.join(skillDir, "reference", "notes.md") - expect(Permission.evaluate("external_directory", target, build!.permission).action).toBe("allow") - }, - }) - } finally { - process.env.OPENCODE_TEST_HOME = home - } -}) + const build = yield* load((svc) => svc.get("build")) + const target = path.join(skillDir, "reference", "notes.md") + expect(Permission.evaluate("external_directory", target, build!.permission).action).toBe("allow") + }), + { git: true }, +) -test("defaultAgent returns build when no default_agent config", async () => { - await using tmp = await tmpdir() - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const agent = await load(tmp.path, (svc) => svc.defaultAgent()) - expect(agent).toBe("build") - }, - }) -}) +it.instance("defaultAgent returns build when no default_agent config", () => + Effect.gen(function* () { + const agent = yield* load((svc) => svc.defaultAgent()) + expect(agent).toBe("build") + }), +) -test("defaultAgent respects default_agent config set to plan", async () => { - await using tmp = await tmpdir({ +it.instance( + "defaultAgent respects default_agent config set to plan", + () => + Effect.gen(function* () { + const agent = yield* load((svc) => svc.defaultAgent()) + expect(agent).toBe("plan") + }), + { config: { default_agent: "plan", }, - }) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const agent = await load(tmp.path, (svc) => svc.defaultAgent()) - expect(agent).toBe("plan") - }, - }) -}) + }, +) -test("defaultAgent respects default_agent config set to custom agent with mode all", async () => { - await using tmp = await tmpdir({ +it.instance( + "defaultAgent respects default_agent config set to custom agent with mode all", + () => + Effect.gen(function* () { + const agent = yield* load((svc) => svc.defaultAgent()) + expect(agent).toBe("my_custom") + }), + { config: { default_agent: "my_custom", agent: { @@ -723,92 +668,65 @@ test("defaultAgent respects default_agent config set to custom agent with mode a }, }, }, - }) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const agent = await load(tmp.path, (svc) => svc.defaultAgent()) - expect(agent).toBe("my_custom") - }, - }) -}) + }, +) -test("defaultAgent throws when default_agent points to subagent", async () => { - await using tmp = await tmpdir({ +it.instance( + "defaultAgent throws when default_agent points to subagent", + () => expectDefaultAgentError('default agent "explore" is a subagent'), + { config: { default_agent: "explore", }, - }) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - await expect(load(tmp.path, (svc) => svc.defaultAgent())).rejects.toThrow('default agent "explore" is a subagent') - }, - }) -}) + }, +) -test("defaultAgent throws when default_agent points to hidden agent", async () => { - await using tmp = await tmpdir({ +it.instance( + "defaultAgent throws when default_agent points to hidden agent", + () => expectDefaultAgentError('default agent "compaction" is hidden'), + { config: { default_agent: "compaction", }, - }) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - await expect(load(tmp.path, (svc) => svc.defaultAgent())).rejects.toThrow('default agent "compaction" is hidden') - }, - }) -}) + }, +) -test("defaultAgent throws when default_agent points to non-existent agent", async () => { - await using tmp = await tmpdir({ +it.instance( + "defaultAgent throws when default_agent points to non-existent agent", + () => expectDefaultAgentError('default agent "does_not_exist" not found'), + { config: { default_agent: "does_not_exist", }, - }) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - await expect(load(tmp.path, (svc) => svc.defaultAgent())).rejects.toThrow( - 'default agent "does_not_exist" not found', - ) - }, - }) -}) + }, +) -test("defaultAgent returns plan when build is disabled and default_agent not set", async () => { - await using tmp = await tmpdir({ +it.instance( + "defaultAgent returns plan when build is disabled and default_agent not set", + () => + Effect.gen(function* () { + const agent = yield* load((svc) => svc.defaultAgent()) + // build is disabled, so it should return plan (next primary agent) + expect(agent).toBe("plan") + }), + { config: { agent: { build: { disable: true }, }, }, - }) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const agent = await load(tmp.path, (svc) => svc.defaultAgent()) - // build is disabled, so it should return plan (next primary agent) - expect(agent).toBe("plan") - }, - }) -}) + }, +) -test("defaultAgent throws when all primary agents are disabled", async () => { - await using tmp = await tmpdir({ +it.instance( + "defaultAgent throws when all primary agents are disabled", + () => expectDefaultAgentError("no primary visible agent found"), + { config: { agent: { build: { disable: true }, plan: { disable: true }, }, }, - }) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - // build and plan are disabled, no primary-capable agents remain - await expect(load(tmp.path, (svc) => svc.defaultAgent())).rejects.toThrow("no primary visible agent found") - }, - }) -}) + }, +) From b668af29dd7c2117ed4e5868a6d2f1d73237fbcb Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 12 May 2026 14:50:07 -0400 Subject: [PATCH 39/70] test(git): migrate git tests to Effect runner (#27121) --- packages/opencode/test/git/git.test.ts | 199 +++++++++++++------------ 1 file changed, 101 insertions(+), 98 deletions(-) diff --git a/packages/opencode/test/git/git.test.ts b/packages/opencode/test/git/git.test.ts index 1e56865d72..e80b8fa906 100644 --- a/packages/opencode/test/git/git.test.ts +++ b/packages/opencode/test/git/git.test.ts @@ -1,71 +1,70 @@ import { $ } from "bun" -import { describe, expect, test } from "bun:test" +import { describe, expect } from "bun:test" import fs from "fs/promises" import path from "path" -import { ManagedRuntime } from "effect" +import { Effect } from "effect" import { Git } from "../../src/git" import { tmpdir } from "../fixture/fixture" +import { testEffect } from "../lib/effect" const weird = process.platform === "win32" ? "space file.txt" : "tab\tfile.txt" +const it = testEffect(Git.defaultLayer) -async function withGit(body: (rt: ManagedRuntime.ManagedRuntime) => Promise) { - const rt = ManagedRuntime.make(Git.defaultLayer) - try { - return await body(rt) - } finally { - await rt.dispose() - } -} +const scopedTmpdir = (options?: Parameters[0]) => + Effect.acquireRelease( + Effect.promise(() => tmpdir(options)), + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ) describe("Git", () => { - test("branch() returns current branch name", async () => { - await using tmp = await tmpdir({ git: true }) - - await withGit(async (rt) => { - const branch = await rt.runPromise(Git.Service.use((git) => git.branch(tmp.path))) + it.live("branch() returns current branch name", () => + Effect.gen(function* () { + const tmp = yield* scopedTmpdir({ git: true }) + const git = yield* Git.Service + const branch = yield* git.branch(tmp.path) expect(branch).toBeDefined() expect(typeof branch).toBe("string") - }) - }) + }), + ) - test("branch() returns undefined for non-git directories", async () => { - await using tmp = await tmpdir() - - await withGit(async (rt) => { - const branch = await rt.runPromise(Git.Service.use((git) => git.branch(tmp.path))) + it.live("branch() returns undefined for non-git directories", () => + Effect.gen(function* () { + const tmp = yield* scopedTmpdir() + const git = yield* Git.Service + const branch = yield* git.branch(tmp.path) expect(branch).toBeUndefined() - }) - }) + }), + ) - test("branch() returns undefined for detached HEAD", async () => { - await using tmp = await tmpdir({ git: true }) - const hash = (await $`git rev-parse HEAD`.cwd(tmp.path).quiet().text()).trim() - await $`git checkout --detach ${hash}`.cwd(tmp.path).quiet() - - await withGit(async (rt) => { - const branch = await rt.runPromise(Git.Service.use((git) => git.branch(tmp.path))) + it.live("branch() returns undefined for detached HEAD", () => + Effect.gen(function* () { + const tmp = yield* scopedTmpdir({ git: true }) + const hash = (yield* Effect.promise(() => $`git rev-parse HEAD`.cwd(tmp.path).quiet().text())).trim() + yield* Effect.promise(() => $`git checkout --detach ${hash}`.cwd(tmp.path).quiet()) + const git = yield* Git.Service + const branch = yield* git.branch(tmp.path) expect(branch).toBeUndefined() - }) - }) + }), + ) - test("defaultBranch() uses init.defaultBranch when available", async () => { - await using tmp = await tmpdir({ git: true }) - await $`git branch -M trunk`.cwd(tmp.path).quiet() - await $`git config init.defaultBranch trunk`.cwd(tmp.path).quiet() - - await withGit(async (rt) => { - const branch = await rt.runPromise(Git.Service.use((git) => git.defaultBranch(tmp.path))) + it.live("defaultBranch() uses init.defaultBranch when available", () => + Effect.gen(function* () { + const tmp = yield* scopedTmpdir({ git: true }) + yield* Effect.promise(() => $`git branch -M trunk`.cwd(tmp.path).quiet()) + yield* Effect.promise(() => $`git config init.defaultBranch trunk`.cwd(tmp.path).quiet()) + const git = yield* Git.Service + const branch = yield* git.defaultBranch(tmp.path) expect(branch?.name).toBe("trunk") expect(branch?.ref).toBe("trunk") - }) - }) + }), + ) - test("status() handles special filenames", async () => { - await using tmp = await tmpdir({ git: true }) - await fs.writeFile(path.join(tmp.path, weird), "hello\n", "utf-8") - - await withGit(async (rt) => { - const status = await rt.runPromise(Git.Service.use((git) => git.status(tmp.path))) + it.live("status() handles special filenames", () => + Effect.gen(function* () { + const tmp = yield* scopedTmpdir({ git: true }) + yield* Effect.promise(() => fs.writeFile(path.join(tmp.path, weird), "hello\n", "utf-8")) + const git = yield* Git.Service + const status = yield* git.status(tmp.path) expect(status).toEqual( expect.arrayContaining([ expect.objectContaining({ @@ -74,23 +73,24 @@ describe("Git", () => { }), ]), ) - }) - }) + }), + ) - test("diff(), stats(), and mergeBase() parse tracked changes", async () => { - await using tmp = await tmpdir({ git: true }) - await $`git branch -M main`.cwd(tmp.path).quiet() - await fs.writeFile(path.join(tmp.path, weird), "before\n", "utf-8") - await $`git add .`.cwd(tmp.path).quiet() - await $`git commit --no-gpg-sign -m "add file"`.cwd(tmp.path).quiet() - await $`git checkout -b feature/test`.cwd(tmp.path).quiet() - await fs.writeFile(path.join(tmp.path, weird), "after\n", "utf-8") + it.live("diff(), stats(), and mergeBase() parse tracked changes", () => + Effect.gen(function* () { + const tmp = yield* scopedTmpdir({ git: true }) + yield* Effect.promise(() => $`git branch -M main`.cwd(tmp.path).quiet()) + yield* Effect.promise(() => fs.writeFile(path.join(tmp.path, weird), "before\n", "utf-8")) + yield* Effect.promise(() => $`git add .`.cwd(tmp.path).quiet()) + yield* Effect.promise(() => $`git commit --no-gpg-sign -m "add file"`.cwd(tmp.path).quiet()) + yield* Effect.promise(() => $`git checkout -b feature/test`.cwd(tmp.path).quiet()) + yield* Effect.promise(() => fs.writeFile(path.join(tmp.path, weird), "after\n", "utf-8")) - await withGit(async (rt) => { - const [base, diff, stats] = await Promise.all([ - rt.runPromise(Git.Service.use((git) => git.mergeBase(tmp.path, "main"))), - rt.runPromise(Git.Service.use((git) => git.diff(tmp.path, "HEAD"))), - rt.runPromise(Git.Service.use((git) => git.stats(tmp.path, "HEAD"))), + const git = yield* Git.Service + const [base, diff, stats] = yield* Effect.all([ + git.mergeBase(tmp.path, "main"), + git.diff(tmp.path, "HEAD"), + git.stats(tmp.path, "HEAD"), ]) expect(base).toBeTruthy() @@ -111,23 +111,24 @@ describe("Git", () => { }), ]), ) - }) - }) + }), + ) - test("patch() returns capped native patch output", async () => { - await using tmp = await tmpdir({ git: true }) - await fs.writeFile(path.join(tmp.path, weird), "before\n", "utf-8") - await fs.writeFile(path.join(tmp.path, "other.txt"), "old\n", "utf-8") - await $`git add .`.cwd(tmp.path).quiet() - await $`git commit --no-gpg-sign -m "add file"`.cwd(tmp.path).quiet() - await fs.writeFile(path.join(tmp.path, weird), "after\n", "utf-8") - await fs.writeFile(path.join(tmp.path, "other.txt"), "new\n", "utf-8") + it.live("patch() returns capped native patch output", () => + Effect.gen(function* () { + const tmp = yield* scopedTmpdir({ git: true }) + yield* Effect.promise(() => fs.writeFile(path.join(tmp.path, weird), "before\n", "utf-8")) + yield* Effect.promise(() => fs.writeFile(path.join(tmp.path, "other.txt"), "old\n", "utf-8")) + yield* Effect.promise(() => $`git add .`.cwd(tmp.path).quiet()) + yield* Effect.promise(() => $`git commit --no-gpg-sign -m "add file"`.cwd(tmp.path).quiet()) + yield* Effect.promise(() => fs.writeFile(path.join(tmp.path, weird), "after\n", "utf-8")) + yield* Effect.promise(() => fs.writeFile(path.join(tmp.path, "other.txt"), "new\n", "utf-8")) - await withGit(async (rt) => { - const [patch, all, capped] = await Promise.all([ - rt.runPromise(Git.Service.use((git) => git.patch(tmp.path, "HEAD", weird, { context: 2_147_483_647 }))), - rt.runPromise(Git.Service.use((git) => git.patchAll(tmp.path, "HEAD", { context: 2_147_483_647 }))), - rt.runPromise(Git.Service.use((git) => git.patch(tmp.path, "HEAD", weird, { maxOutputBytes: 1 }))), + const git = yield* Git.Service + const [patch, all, capped] = yield* Effect.all([ + git.patch(tmp.path, "HEAD", weird, { context: 2_147_483_647 }), + git.patchAll(tmp.path, "HEAD", { context: 2_147_483_647 }), + git.patch(tmp.path, "HEAD", weird, { maxOutputBytes: 1 }), ]) expect(patch.truncated).toBe(false) @@ -140,17 +141,18 @@ describe("Git", () => { expect(all.text).toContain("+new") expect(capped.truncated).toBe(true) expect(capped.text).toBe("") - }) - }) + }), + ) - test("patchUntracked() and statUntracked() handle added files", async () => { - await using tmp = await tmpdir({ git: true }) - await fs.writeFile(path.join(tmp.path, weird), "one\ntwo\n", "utf-8") + it.live("patchUntracked() and statUntracked() handle added files", () => + Effect.gen(function* () { + const tmp = yield* scopedTmpdir({ git: true }) + yield* Effect.promise(() => fs.writeFile(path.join(tmp.path, weird), "one\ntwo\n", "utf-8")) - await withGit(async (rt) => { - const [patch, stat] = await Promise.all([ - rt.runPromise(Git.Service.use((git) => git.patchUntracked(tmp.path, weird, { context: 2_147_483_647 }))), - rt.runPromise(Git.Service.use((git) => git.statUntracked(tmp.path, weird))), + const git = yield* Git.Service + const [patch, stat] = yield* Effect.all([ + git.patchUntracked(tmp.path, weird, { context: 2_147_483_647 }), + git.statUntracked(tmp.path, weird), ]) expect(patch.truncated).toBe(false) @@ -158,18 +160,19 @@ describe("Git", () => { expect(patch.text).toContain("+one") expect(patch.text).toContain("+two") expect(stat).toEqual(expect.objectContaining({ file: weird, additions: 2, deletions: 0 })) - }) - }) + }), + ) - test("show() returns empty text for binary blobs", async () => { - await using tmp = await tmpdir({ git: true }) - await fs.writeFile(path.join(tmp.path, "bin.dat"), new Uint8Array([0, 1, 2, 3])) - await $`git add .`.cwd(tmp.path).quiet() - await $`git commit --no-gpg-sign -m "add binary"`.cwd(tmp.path).quiet() + it.live("show() returns empty text for binary blobs", () => + Effect.gen(function* () { + const tmp = yield* scopedTmpdir({ git: true }) + yield* Effect.promise(() => fs.writeFile(path.join(tmp.path, "bin.dat"), new Uint8Array([0, 1, 2, 3]))) + yield* Effect.promise(() => $`git add .`.cwd(tmp.path).quiet()) + yield* Effect.promise(() => $`git commit --no-gpg-sign -m "add binary"`.cwd(tmp.path).quiet()) - await withGit(async (rt) => { - const text = await rt.runPromise(Git.Service.use((git) => git.show(tmp.path, "HEAD", "bin.dat"))) + const git = yield* Git.Service + const text = yield* git.show(tmp.path, "HEAD", "bin.dat") expect(text).toBe("") - }) - }) + }), + ) }) From 8f1ded9e0812e5bc1288a850d58290d76715105c Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 12 May 2026 14:50:26 -0400 Subject: [PATCH 40/70] test(file): migrate ripgrep tests to Effect runner (#27120) --- packages/opencode/test/file/ripgrep.test.ts | 394 ++++++++++---------- 1 file changed, 200 insertions(+), 194 deletions(-) diff --git a/packages/opencode/test/file/ripgrep.test.ts b/packages/opencode/test/file/ripgrep.test.ts index a76c7ebe26..d71ce205ea 100644 --- a/packages/opencode/test/file/ripgrep.test.ts +++ b/packages/opencode/test/file/ripgrep.test.ts @@ -1,214 +1,220 @@ -import { describe, expect, test } from "bun:test" +import { describe, expect } from "bun:test" import { Effect } from "effect" import * as Stream from "effect/Stream" import fs from "fs/promises" +import os from "os" import path from "path" -import { tmpdir } from "../fixture/fixture" import { Ripgrep } from "../../src/file/ripgrep" +import { testEffect } from "../lib/effect" -const run = (effect: Effect.Effect) => - effect.pipe(Effect.provide(Ripgrep.defaultLayer), Effect.runPromise) +const it = testEffect(Ripgrep.defaultLayer) + +const tmpdir = (init?: (dir: string) => Effect.Effect) => + Effect.acquireRelease( + Effect.promise(async () => fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), "opencode-test-")))), + (dir) => + Effect.promise(() => + fs.rm(dir, { + recursive: true, + force: true, + maxRetries: 5, + retryDelay: 100, + }), + ).pipe(Effect.ignore), + ).pipe(Effect.tap((dir) => init?.(dir) ?? Effect.void)) + +const write = (file: string, data: string) => Effect.promise(() => Bun.write(file, data)) +const mkdir = (dir: string) => Effect.promise(() => fs.mkdir(dir, { recursive: true })) +const collectFiles = (input: Ripgrep.FilesInput) => + Ripgrep.Service.use((rg) => + rg.files(input).pipe( + Stream.runCollect, + Effect.map((c) => [...c]), + ), + ) + +const withRipgrepConfig = (value: string, effect: Effect.Effect) => + Effect.acquireUseRelease( + Effect.sync(() => { + const prev = process.env["RIPGREP_CONFIG_PATH"] + process.env["RIPGREP_CONFIG_PATH"] = value + return prev + }), + () => effect, + (prev) => + Effect.sync(() => { + if (prev === undefined) delete process.env["RIPGREP_CONFIG_PATH"] + else process.env["RIPGREP_CONFIG_PATH"] = prev + }), + ) describe("file.ripgrep", () => { - test("defaults to include hidden", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { - await Bun.write(path.join(dir, "visible.txt"), "hello") - await fs.mkdir(path.join(dir, ".opencode"), { recursive: true }) - await Bun.write(path.join(dir, ".opencode", "thing.json"), "{}") - }, - }) + it.live("defaults to include hidden", () => + Effect.gen(function* () { + const dir = yield* tmpdir((dir) => + Effect.gen(function* () { + yield* write(path.join(dir, "visible.txt"), "hello") + yield* mkdir(path.join(dir, ".opencode")) + yield* write(path.join(dir, ".opencode", "thing.json"), "{}") + }), + ) - const files = await run( - Ripgrep.Service.use((rg) => - rg.files({ cwd: tmp.path }).pipe( - Stream.runCollect, - Effect.map((c) => [...c]), - ), - ), - ) - expect(files.includes("visible.txt")).toBe(true) - expect(files.includes(path.join(".opencode", "thing.json"))).toBe(true) - }) + const files = yield* collectFiles({ cwd: dir }) + expect(files.includes("visible.txt")).toBe(true) + expect(files.includes(path.join(".opencode", "thing.json"))).toBe(true) + }), + ) - test("hidden false excludes hidden", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { - await Bun.write(path.join(dir, "visible.txt"), "hello") - await fs.mkdir(path.join(dir, ".opencode"), { recursive: true }) - await Bun.write(path.join(dir, ".opencode", "thing.json"), "{}") - }, - }) + it.live("hidden false excludes hidden", () => + Effect.gen(function* () { + const dir = yield* tmpdir((dir) => + Effect.gen(function* () { + yield* write(path.join(dir, "visible.txt"), "hello") + yield* mkdir(path.join(dir, ".opencode")) + yield* write(path.join(dir, ".opencode", "thing.json"), "{}") + }), + ) - const files = await run( - Ripgrep.Service.use((rg) => - rg.files({ cwd: tmp.path, hidden: false }).pipe( - Stream.runCollect, - Effect.map((c) => [...c]), - ), - ), - ) - expect(files.includes("visible.txt")).toBe(true) - expect(files.includes(path.join(".opencode", "thing.json"))).toBe(false) - }) + const files = yield* collectFiles({ cwd: dir, hidden: false }) + expect(files.includes("visible.txt")).toBe(true) + expect(files.includes(path.join(".opencode", "thing.json"))).toBe(false) + }), + ) - test("search returns empty when nothing matches", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { - await Bun.write(path.join(dir, "match.ts"), "const value = 'other'\n") - }, - }) + it.live("search returns empty when nothing matches", () => + Effect.gen(function* () { + const dir = yield* tmpdir((dir) => write(path.join(dir, "match.ts"), "const value = 'other'\n")) - const result = await run(Ripgrep.Service.use((rg) => rg.search({ cwd: tmp.path, pattern: "needle" }))) - expect(result.partial).toBe(false) - expect(result.items).toEqual([]) - }) + const result = yield* Ripgrep.Service.use((rg) => rg.search({ cwd: dir, pattern: "needle" })) + expect(result.partial).toBe(false) + expect(result.items).toEqual([]) + }), + ) - test("search returns match metadata with normalized path", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { - await fs.mkdir(path.join(dir, "src"), { recursive: true }) - await Bun.write(path.join(dir, "src", "match.ts"), "const needle = 1\n") - }, - }) + it.live("search returns match metadata with normalized path", () => + Effect.gen(function* () { + const dir = yield* tmpdir((dir) => + Effect.gen(function* () { + yield* mkdir(path.join(dir, "src")) + yield* write(path.join(dir, "src", "match.ts"), "const needle = 1\n") + }), + ) - const result = await run(Ripgrep.Service.use((rg) => rg.search({ cwd: tmp.path, pattern: "needle" }))) - expect(result.partial).toBe(false) - expect(result.items).toHaveLength(1) - expect(result.items[0]?.path.text).toBe(path.join("src", "match.ts")) - expect(result.items[0]?.line_number).toBe(1) - expect(result.items[0]?.lines.text).toContain("needle") - }) - - test("search returns matched rows with glob filter", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { - await Bun.write(path.join(dir, "match.ts"), "const value = 'needle'\n") - await Bun.write(path.join(dir, "skip.txt"), "const value = 'other'\n") - }, - }) - - const result = await run( - Ripgrep.Service.use((rg) => rg.search({ cwd: tmp.path, pattern: "needle", glob: ["*.ts"] })), - ) - expect(result.partial).toBe(false) - expect(result.items).toHaveLength(1) - expect(result.items[0]?.path.text).toContain("match.ts") - expect(result.items[0]?.lines.text).toContain("needle") - }) - - test("search supports explicit file targets", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { - await Bun.write(path.join(dir, "match.ts"), "const value = 'needle'\n") - await Bun.write(path.join(dir, "skip.ts"), "const value = 'needle'\n") - }, - }) - - const file = path.join(tmp.path, "match.ts") - const result = await run(Ripgrep.Service.use((rg) => rg.search({ cwd: tmp.path, pattern: "needle", file: [file] }))) - expect(result.partial).toBe(false) - expect(result.items).toHaveLength(1) - expect(result.items[0]?.path.text).toBe(file) - }) - - test("files returns empty when glob matches no files", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { - await fs.mkdir(path.join(dir, "packages", "console"), { recursive: true }) - await Bun.write(path.join(dir, "packages", "console", "package.json"), "{}") - }, - }) - - const files = await run( - Ripgrep.Service.use((rg) => - rg.files({ cwd: tmp.path, glob: ["packages/*"] }).pipe( - Stream.runCollect, - Effect.map((c) => [...c]), - ), - ), - ) - expect(files).toEqual([]) - }) - - test("files returns stream of filenames", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { - await Bun.write(path.join(dir, "a.txt"), "hello") - await Bun.write(path.join(dir, "b.txt"), "world") - }, - }) - - const files = await run( - Ripgrep.Service.use((rg) => - rg.files({ cwd: tmp.path }).pipe( - Stream.runCollect, - Effect.map((c) => [...c].sort()), - ), - ), - ) - expect(files).toEqual(["a.txt", "b.txt"]) - }) - - test("files respects glob filter", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { - await Bun.write(path.join(dir, "keep.ts"), "yes") - await Bun.write(path.join(dir, "skip.txt"), "no") - }, - }) - - const files = await run( - Ripgrep.Service.use((rg) => - rg.files({ cwd: tmp.path, glob: ["*.ts"] }).pipe( - Stream.runCollect, - Effect.map((c) => [...c]), - ), - ), - ) - expect(files).toEqual(["keep.ts"]) - }) - - test("files dies on nonexistent directory", async () => { - const exit = await Ripgrep.Service.use((rg) => - rg.files({ cwd: "/tmp/nonexistent-dir-12345" }).pipe(Stream.runCollect), - ).pipe(Effect.provide(Ripgrep.defaultLayer), Effect.runPromiseExit) - expect(exit._tag).toBe("Failure") - }) - - test("ignores RIPGREP_CONFIG_PATH in direct mode", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { - await Bun.write(path.join(dir, "match.ts"), "const needle = 1\n") - }, - }) - - const prev = process.env["RIPGREP_CONFIG_PATH"] - process.env["RIPGREP_CONFIG_PATH"] = path.join(tmp.path, "missing-ripgreprc") - try { - const result = await run(Ripgrep.Service.use((rg) => rg.search({ cwd: tmp.path, pattern: "needle" }))) + const result = yield* Ripgrep.Service.use((rg) => rg.search({ cwd: dir, pattern: "needle" })) + expect(result.partial).toBe(false) expect(result.items).toHaveLength(1) - } finally { - if (prev === undefined) delete process.env["RIPGREP_CONFIG_PATH"] - else process.env["RIPGREP_CONFIG_PATH"] = prev - } - }) + expect(result.items[0]?.path.text).toBe(path.join("src", "match.ts")) + expect(result.items[0]?.line_number).toBe(1) + expect(result.items[0]?.lines.text).toContain("needle") + }), + ) - test("ignores RIPGREP_CONFIG_PATH in worker mode", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { - await Bun.write(path.join(dir, "match.ts"), "const needle = 1\n") - }, - }) + it.live("search returns matched rows with glob filter", () => + Effect.gen(function* () { + const dir = yield* tmpdir((dir) => + Effect.gen(function* () { + yield* write(path.join(dir, "match.ts"), "const value = 'needle'\n") + yield* write(path.join(dir, "skip.txt"), "const value = 'other'\n") + }), + ) - const prev = process.env["RIPGREP_CONFIG_PATH"] - process.env["RIPGREP_CONFIG_PATH"] = path.join(tmp.path, "missing-ripgreprc") - try { - const result = await run(Ripgrep.Service.use((rg) => rg.search({ cwd: tmp.path, pattern: "needle" }))) + const result = yield* Ripgrep.Service.use((rg) => rg.search({ cwd: dir, pattern: "needle", glob: ["*.ts"] })) + expect(result.partial).toBe(false) expect(result.items).toHaveLength(1) - } finally { - if (prev === undefined) delete process.env["RIPGREP_CONFIG_PATH"] - else process.env["RIPGREP_CONFIG_PATH"] = prev - } - }) + expect(result.items[0]?.path.text).toContain("match.ts") + expect(result.items[0]?.lines.text).toContain("needle") + }), + ) + + it.live("search supports explicit file targets", () => + Effect.gen(function* () { + const dir = yield* tmpdir((dir) => + Effect.gen(function* () { + yield* write(path.join(dir, "match.ts"), "const value = 'needle'\n") + yield* write(path.join(dir, "skip.ts"), "const value = 'needle'\n") + }), + ) + + const file = path.join(dir, "match.ts") + const result = yield* Ripgrep.Service.use((rg) => rg.search({ cwd: dir, pattern: "needle", file: [file] })) + expect(result.partial).toBe(false) + expect(result.items).toHaveLength(1) + expect(result.items[0]?.path.text).toBe(file) + }), + ) + + it.live("files returns empty when glob matches no files", () => + Effect.gen(function* () { + const dir = yield* tmpdir((dir) => + Effect.gen(function* () { + yield* mkdir(path.join(dir, "packages", "console")) + yield* write(path.join(dir, "packages", "console", "package.json"), "{}") + }), + ) + + const files = yield* collectFiles({ cwd: dir, glob: ["packages/*"] }) + expect(files).toEqual([]) + }), + ) + + it.live("files returns stream of filenames", () => + Effect.gen(function* () { + const dir = yield* tmpdir((dir) => + Effect.gen(function* () { + yield* write(path.join(dir, "a.txt"), "hello") + yield* write(path.join(dir, "b.txt"), "world") + }), + ) + + const files = yield* collectFiles({ cwd: dir }).pipe(Effect.map((files) => files.sort())) + expect(files).toEqual(["a.txt", "b.txt"]) + }), + ) + + it.live("files respects glob filter", () => + Effect.gen(function* () { + const dir = yield* tmpdir((dir) => + Effect.gen(function* () { + yield* write(path.join(dir, "keep.ts"), "yes") + yield* write(path.join(dir, "skip.txt"), "no") + }), + ) + + const files = yield* collectFiles({ cwd: dir, glob: ["*.ts"] }) + expect(files).toEqual(["keep.ts"]) + }), + ) + + it.live("files dies on nonexistent directory", () => + Effect.gen(function* () { + const exit = yield* Ripgrep.Service.use((rg) => + rg.files({ cwd: "/tmp/nonexistent-dir-12345" }).pipe(Stream.runCollect), + ).pipe(Effect.exit) + expect(exit._tag).toBe("Failure") + }), + ) + + it.live("ignores RIPGREP_CONFIG_PATH in direct mode", () => + Effect.gen(function* () { + const dir = yield* tmpdir((dir) => write(path.join(dir, "match.ts"), "const needle = 1\n")) + + const result = yield* withRipgrepConfig( + path.join(dir, "missing-ripgreprc"), + Ripgrep.Service.use((rg) => rg.search({ cwd: dir, pattern: "needle" })), + ) + expect(result.items).toHaveLength(1) + }), + ) + + it.live("ignores RIPGREP_CONFIG_PATH in worker mode", () => + Effect.gen(function* () { + const dir = yield* tmpdir((dir) => write(path.join(dir, "match.ts"), "const needle = 1\n")) + + const result = yield* withRipgrepConfig( + path.join(dir, "missing-ripgreprc"), + Ripgrep.Service.use((rg) => rg.search({ cwd: dir, pattern: "needle" })), + ) + expect(result.items).toHaveLength(1) + }), + ) }) From 3c7569d852a07e245357eaaf73c4399765c5b68e Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 12 May 2026 14:50:56 -0400 Subject: [PATCH 41/70] test(tool): migrate external directory tests to Effect runner (#27122) --- .../test/tool/external-directory.test.ts | 201 ++++++++---------- 1 file changed, 92 insertions(+), 109 deletions(-) diff --git a/packages/opencode/test/tool/external-directory.test.ts b/packages/opencode/test/tool/external-directory.test.ts index 0560ea0300..7585ff98f1 100644 --- a/packages/opencode/test/tool/external-directory.test.ts +++ b/packages/opencode/test/tool/external-directory.test.ts @@ -1,14 +1,16 @@ -import { describe, expect, test } from "bun:test" +import { describe, expect } from "bun:test" import path from "path" import { Effect } from "effect" +import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import type { Tool } from "@/tool/tool" -import { Instance } from "../../src/project/instance" -import { WithInstance } from "../../src/project/with-instance" -import { assertExternalDirectory } from "../../src/tool/external-directory" +import { assertExternalDirectoryEffect } from "../../src/tool/external-directory" import { Filesystem } from "@/util/filesystem" -import { tmpdir } from "../fixture/fixture" +import { provideInstance, TestInstance, tmpdirScoped } from "../fixture/fixture" import type { Permission } from "../../src/permission" import { SessionID, MessageID } from "../../src/session/schema" +import { testEffect } from "../lib/effect" + +const it = testEffect(CrossSpawnSpawner.defaultLayer) const baseCtx: Omit = { sessionID: SessionID.make("ses_test"), @@ -36,135 +38,116 @@ function makeCtx() { } describe("tool.assertExternalDirectory", () => { - test("no-ops for empty target", async () => { - const { requests, ctx } = makeCtx() + it.live("no-ops for empty target", () => + Effect.gen(function* () { + const { requests, ctx } = makeCtx() - await WithInstance.provide({ - directory: "/tmp", - fn: async () => { - await assertExternalDirectory(ctx) - }, - }) + yield* assertExternalDirectoryEffect(ctx) - expect(requests.length).toBe(0) - }) + expect(requests.length).toBe(0) + }), + ) - test("no-ops for paths inside Instance.directory", async () => { - const { requests, ctx } = makeCtx() + it.live("no-ops for paths inside Instance.directory", () => + provideInstance("/tmp/project")( + Effect.gen(function* () { + const { requests, ctx } = makeCtx() - await WithInstance.provide({ - directory: "/tmp/project", - fn: async () => { - await assertExternalDirectory(ctx, path.join("/tmp/project", "file.txt")) - }, - }) + yield* assertExternalDirectoryEffect(ctx, path.join("/tmp/project", "file.txt")) - expect(requests.length).toBe(0) - }) + expect(requests.length).toBe(0) + }), + ), + ) - test("asks with a single canonical glob", async () => { - const { requests, ctx } = makeCtx() + it.live("asks with a single canonical glob", () => + Effect.gen(function* () { + const { requests, ctx } = makeCtx() - const directory = "/tmp/project" - const target = "/tmp/outside/file.txt" - const expected = glob(path.join(path.dirname(target), "*")) + const directory = "/tmp/project" + const target = "/tmp/outside/file.txt" + const expected = glob(path.join(path.dirname(target), "*")) - await WithInstance.provide({ - directory, - fn: async () => { - await assertExternalDirectory(ctx, target) - }, - }) + yield* provideInstance(directory)(assertExternalDirectoryEffect(ctx, target)) - const req = requests.find((r) => r.permission === "external_directory") - expect(req).toBeDefined() - expect(req!.patterns).toEqual([expected]) - expect(req!.always).toEqual([expected]) - }) + const req = requests.find((r) => r.permission === "external_directory") + expect(req).toBeDefined() + expect(req!.patterns).toEqual([expected]) + expect(req!.always).toEqual([expected]) + }), + ) - test("uses target directory when kind=directory", async () => { - const { requests, ctx } = makeCtx() + it.live("uses target directory when kind=directory", () => + Effect.gen(function* () { + const { requests, ctx } = makeCtx() - const directory = "/tmp/project" - const target = "/tmp/outside" - const expected = glob(path.join(target, "*")) + const directory = "/tmp/project" + const target = "/tmp/outside" + const expected = glob(path.join(target, "*")) - await WithInstance.provide({ - directory, - fn: async () => { - await assertExternalDirectory(ctx, target, { kind: "directory" }) - }, - }) + yield* provideInstance(directory)(assertExternalDirectoryEffect(ctx, target, { kind: "directory" })) - const req = requests.find((r) => r.permission === "external_directory") - expect(req).toBeDefined() - expect(req!.patterns).toEqual([expected]) - expect(req!.always).toEqual([expected]) - }) + const req = requests.find((r) => r.permission === "external_directory") + expect(req).toBeDefined() + expect(req!.patterns).toEqual([expected]) + expect(req!.always).toEqual([expected]) + }), + ) - test("skips prompting when bypass=true", async () => { - const { requests, ctx } = makeCtx() + it.live("skips prompting when bypass=true", () => + provideInstance("/tmp/project")( + Effect.gen(function* () { + const { requests, ctx } = makeCtx() - await WithInstance.provide({ - directory: "/tmp/project", - fn: async () => { - await assertExternalDirectory(ctx, "/tmp/outside/file.txt", { bypass: true }) - }, - }) + yield* assertExternalDirectoryEffect(ctx, "/tmp/outside/file.txt", { bypass: true }) - expect(requests.length).toBe(0) - }) + expect(requests.length).toBe(0) + }), + ), + ) if (process.platform === "win32") { - test("normalizes Windows path variants to one glob", async () => { - const { requests, ctx } = makeCtx() + it.instance("normalizes Windows path variants to one glob", () => + Effect.gen(function* () { + const { requests, ctx } = makeCtx() - await using outerTmp = await tmpdir({ - init: async (dir) => { - await Bun.write(path.join(dir, "outside.txt"), "x") - }, - }) - await using tmp = await tmpdir({ git: true }) + const outerTmp = yield* tmpdirScoped() + yield* Effect.promise(() => Bun.write(path.join(outerTmp, "outside.txt"), "x")) - const target = path.join(outerTmp.path, "outside.txt") - const alt = target - .replace(/^[A-Za-z]:/, "") - .replaceAll("\\", "/") - .toLowerCase() + const target = path.join(outerTmp, "outside.txt") + const alt = target + .replace(/^[A-Za-z]:/, "") + .replaceAll("\\", "/") + .toLowerCase() - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - await assertExternalDirectory(ctx, alt) - }, - }) + yield* assertExternalDirectoryEffect(ctx, alt) - const req = requests.find((r) => r.permission === "external_directory") - const expected = glob(path.join(outerTmp.path, "*")) - expect(req).toBeDefined() - expect(req!.patterns).toEqual([expected]) - expect(req!.always).toEqual([expected]) - }) + const req = requests.find((r) => r.permission === "external_directory") + const expected = glob(path.join(outerTmp, "*")) + expect(req).toBeDefined() + expect(req!.patterns).toEqual([expected]) + expect(req!.always).toEqual([expected]) + }), + { git: true }, + ) - test("uses drive root glob for root files", async () => { - const { requests, ctx } = makeCtx() + it.instance("uses drive root glob for root files", () => + Effect.gen(function* () { + const { requests, ctx } = makeCtx() - await using tmp = await tmpdir({ git: true }) - const root = path.parse(tmp.path).root - const target = path.join(root, "boot.ini") + const tmp = yield* TestInstance + const root = path.parse(tmp.directory).root + const target = path.join(root, "boot.ini") - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - await assertExternalDirectory(ctx, target) - }, - }) + yield* assertExternalDirectoryEffect(ctx, target) - const req = requests.find((r) => r.permission === "external_directory") - const expected = path.join(root, "*") - expect(req).toBeDefined() - expect(req!.patterns).toEqual([expected]) - expect(req!.always).toEqual([expected]) - }) + const req = requests.find((r) => r.permission === "external_directory") + const expected = path.join(root, "*") + expect(req).toBeDefined() + expect(req!.patterns).toEqual([expected]) + expect(req!.always).toEqual([expected]) + }), + { git: true }, + ) } }) From 549b146ea6529cc29dd556644543fcb48acf6d45 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 12 May 2026 14:51:18 -0400 Subject: [PATCH 42/70] Stabilize session event tests (#27117) --- .../opencode/test/session/session.test.ts | 276 +++++++++--------- 1 file changed, 132 insertions(+), 144 deletions(-) diff --git a/packages/opencode/test/session/session.test.ts b/packages/opencode/test/session/session.test.ts index bb69e459bc..ada55d1349 100644 --- a/packages/opencode/test/session/session.test.ts +++ b/packages/opencode/test/session/session.test.ts @@ -1,186 +1,174 @@ -import { describe, expect, test } from "bun:test" -import path from "path" +import { describe, expect } from "bun:test" +import { Deferred, Effect, Exit, Layer } from "effect" import { Session as SessionNs } from "@/session/session" -import { Bus } from "../../src/bus" +import { GlobalBus, type GlobalEvent } from "../../src/bus/global" import * as Log from "@opencode-ai/core/util/log" -import { Instance } from "../../src/project/instance" -import { WithInstance } from "../../src/project/with-instance" +import { Flag } from "@opencode-ai/core/flag/flag" import { MessageV2 } from "../../src/session/message-v2" import { MessageID, PartID, type SessionID } from "../../src/session/schema" -import { AppRuntime } from "../../src/effect/app-runtime" -import { tmpdir } from "../fixture/fixture" +import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" +import { provideInstance, tmpdirScoped } from "../fixture/fixture" +import { testEffect } from "../lib/effect" -const projectRoot = path.join(__dirname, "../..") void Log.init({ print: false }) -function create(input?: SessionNs.CreateInput) { - return AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.create(input))) -} +const it = testEffect(Layer.mergeAll(SessionNs.defaultLayer, CrossSpawnSpawner.defaultLayer)) -function get(id: SessionID) { - return AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.get(id))) -} +const awaitDeferred = (deferred: Deferred.Deferred, message: string) => + Effect.race( + Deferred.await(deferred), + Effect.sleep("2 seconds").pipe(Effect.flatMap(() => Effect.fail(new Error(message)))), + ) -function remove(id: SessionID) { - return AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.remove(id))) -} +const remove = (id: SessionID) => SessionNs.Service.use((svc) => svc.remove(id)) -function updateMessage(msg: T) { - return AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.updateMessage(msg))) -} - -function updatePart(part: T) { - return AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.updatePart(part))) +const subscribeGlobal = (type: string, callback: (event: NonNullable) => void) => { + const listener = (event: GlobalEvent) => { + if (event.payload?.type === type) callback(event.payload) + } + GlobalBus.on("event", listener) + return () => GlobalBus.off("event", listener) } describe("session.created event", () => { - test("should emit session.created event when session is created", async () => { - await WithInstance.provide({ - directory: projectRoot, - fn: async () => { - let eventReceived = false - let receivedInfo: SessionNs.Info | undefined + it.instance("should emit session.created event when session is created", () => + Effect.gen(function* () { + const session = yield* SessionNs.Service + const received = yield* Deferred.make() - const unsub = Bus.subscribe(SessionNs.Event.Created, (event) => { - eventReceived = true - receivedInfo = event.properties.info as SessionNs.Info - }) + const unsub = subscribeGlobal(SessionNs.Event.Created.type, (event) => { + Deferred.doneUnsafe(received, Effect.succeed(event.properties.info as SessionNs.Info)) + }) + yield* Effect.addFinalizer(() => Effect.sync(unsub)) - const info = await create({}) - await new Promise((resolve) => setTimeout(resolve, 100)) - unsub() + const info = yield* session.create({}) + const receivedInfo = yield* awaitDeferred(received, "timed out waiting for session.created") - expect(eventReceived).toBe(true) - expect(receivedInfo).toBeDefined() - expect(receivedInfo?.id).toBe(info.id) - expect(receivedInfo?.projectID).toBe(info.projectID) - expect(receivedInfo?.directory).toBe(info.directory) - expect(receivedInfo?.path).toBe(info.path) - expect(receivedInfo?.title).toBe(info.title) + expect(receivedInfo.id).toBe(info.id) + expect(receivedInfo.projectID).toBe(info.projectID) + expect(receivedInfo.directory).toBe(info.directory) + expect(receivedInfo.path).toBe(info.path) + expect(receivedInfo.title).toBe(info.title) - await remove(info.id) - }, - }) - }) + yield* session.remove(info.id) + }), + ) - test("session.created event should be emitted before session.updated", async () => { - await WithInstance.provide({ - directory: projectRoot, - fn: async () => { - const events: string[] = [] + it.instance("session.created event should be emitted before session.updated", () => + Effect.gen(function* () { + if (Flag.OPENCODE_EXPERIMENTAL_WORKSPACES) return - const unsubCreated = Bus.subscribe(SessionNs.Event.Created, () => { - events.push("created") - }) + const session = yield* SessionNs.Service + const events: string[] = [] + const received = yield* Deferred.make() + const push = (event: string) => { + events.push(event) + if (events.includes("created") && events.includes("updated")) { + Deferred.doneUnsafe(received, Effect.succeed(events)) + } + } - const unsubUpdated = Bus.subscribe(SessionNs.Event.Updated, () => { - events.push("updated") - }) + const unsubCreated = subscribeGlobal(SessionNs.Event.Created.type, () => { + push("created") + }) + yield* Effect.addFinalizer(() => Effect.sync(unsubCreated)) - const info = await create({}) - await new Promise((resolve) => setTimeout(resolve, 100)) - unsubCreated() - unsubUpdated() + const unsubUpdated = subscribeGlobal(SessionNs.Event.Updated.type, () => { + push("updated") + }) + yield* Effect.addFinalizer(() => Effect.sync(unsubUpdated)) - expect(events).toContain("created") - expect(events).toContain("updated") - expect(events.indexOf("created")).toBeLessThan(events.indexOf("updated")) + const info = yield* session.create({}) + const receivedEvents = yield* awaitDeferred(received, "timed out waiting for session created/updated events") - await remove(info.id) - }, - }) - }) + expect(receivedEvents).toContain("created") + expect(receivedEvents).toContain("updated") + expect(receivedEvents.indexOf("created")).toBeLessThan(receivedEvents.indexOf("updated")) + + yield* session.remove(info.id) + }), + ) }) describe("step-finish token propagation via Bus event", () => { - test( + it.instance( "non-zero tokens propagate through PartUpdated event", - async () => { - await WithInstance.provide({ - directory: projectRoot, - fn: async () => { - const info = await create({}) + () => + Effect.gen(function* () { + const session = yield* SessionNs.Service + const info = yield* session.create({}) - const messageID = MessageID.ascending() - await updateMessage({ - id: messageID, - sessionID: info.id, - role: "user", - time: { created: Date.now() }, - agent: "user", - model: { providerID: "test", modelID: "test" }, - tools: {}, - mode: "", - } as unknown as MessageV2.Info) + const messageID = MessageID.ascending() + yield* session.updateMessage({ + id: messageID, + sessionID: info.id, + role: "user", + time: { created: Date.now() }, + agent: "user", + model: { providerID: "test", modelID: "test" }, + tools: {}, + mode: "", + } as unknown as MessageV2.Info) - // Bus subscribers receive readonly Schema.Type payloads; `MessageV2.Part` - // is the mutable domain type. Cast bridges the two — safe because the - // test only reads the value afterwards. - let received: MessageV2.Part | undefined - const unsub = Bus.subscribe(MessageV2.Event.PartUpdated, (event) => { - received = event.properties.part as MessageV2.Part - }) + // Bus subscribers receive readonly Schema.Type payloads; `MessageV2.Part` + // is the mutable domain type. Cast bridges the two — safe because the + // test only reads the value afterwards. + const received = yield* Deferred.make() + const unsub = subscribeGlobal(MessageV2.Event.PartUpdated.type, (event) => { + Deferred.doneUnsafe(received, Effect.succeed(event.properties.part as MessageV2.Part)) + }) + yield* Effect.addFinalizer(() => Effect.sync(unsub)) - const tokens = { - total: 1500, - input: 500, - output: 800, - reasoning: 200, - cache: { read: 100, write: 50 }, - } + const tokens = { + total: 1500, + input: 500, + output: 800, + reasoning: 200, + cache: { read: 100, write: 50 }, + } - const partInput = { - id: PartID.ascending(), - messageID, - sessionID: info.id, - type: "step-finish" as const, - reason: "stop", - cost: 0.005, - tokens, - } + const partInput = { + id: PartID.ascending(), + messageID, + sessionID: info.id, + type: "step-finish" as const, + reason: "stop", + cost: 0.005, + tokens, + } - await updatePart(partInput) - await new Promise((resolve) => setTimeout(resolve, 100)) + yield* session.updatePart(partInput) + const receivedPart = yield* awaitDeferred(received, "timed out waiting for message.part.updated") - expect(received).toBeDefined() - expect(received!.type).toBe("step-finish") - const finish = received as MessageV2.StepFinishPart - expect(finish.tokens.input).toBe(500) - expect(finish.tokens.output).toBe(800) - expect(finish.tokens.reasoning).toBe(200) - expect(finish.tokens.total).toBe(1500) - expect(finish.tokens.cache.read).toBe(100) - expect(finish.tokens.cache.write).toBe(50) - expect(finish.cost).toBe(0.005) - expect(received).not.toBe(partInput) + expect(receivedPart.type).toBe("step-finish") + const finish = receivedPart as MessageV2.StepFinishPart + expect(finish.tokens.input).toBe(500) + expect(finish.tokens.output).toBe(800) + expect(finish.tokens.reasoning).toBe(200) + expect(finish.tokens.total).toBe(1500) + expect(finish.tokens.cache.read).toBe(100) + expect(finish.tokens.cache.write).toBe(50) + expect(finish.cost).toBe(0.005) + expect(receivedPart).not.toBe(partInput) - unsub() - await remove(info.id) - }, - }) - }, + yield* session.remove(info.id) + }), { timeout: 30000 }, ) }) describe("Session", () => { - test("remove works without an instance", async () => { - await using tmp = await tmpdir({ git: true }) + it.live("remove works without an instance", () => + Effect.gen(function* () { + const session = yield* SessionNs.Service + const dir = yield* tmpdirScoped({ git: true }) + const info = yield* provideInstance(dir)(session.create({ title: "remove-without-instance" })) - const info = await WithInstance.provide({ - directory: tmp.path, - fn: () => create({ title: "remove-without-instance" }), - }) + const removeExit = yield* remove(info.id).pipe(Effect.exit) + expect(Exit.isSuccess(removeExit)).toBe(true) - await expect(async () => { - await remove(info.id) - }).not.toThrow() - - let missing = false - await get(info.id).catch(() => { - missing = true - }) - - expect(missing).toBe(true) - }) + const getExit = yield* session.get(info.id).pipe(Effect.exit) + expect(Exit.isFailure(getExit)).toBe(true) + }), + ) }) From f3c91c5f96dc959f8a9aab6b4793d116ae83ab8d Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Tue, 12 May 2026 18:52:25 +0000 Subject: [PATCH 43/70] chore: generate --- packages/opencode/test/agent/agent.test.ts | 12 ++-- .../test/tool/external-directory.test.ts | 64 ++++++++++--------- 2 files changed, 40 insertions(+), 36 deletions(-) diff --git a/packages/opencode/test/agent/agent.test.ts b/packages/opencode/test/agent/agent.test.ts index 7fd489150b..9893ee8223 100644 --- a/packages/opencode/test/agent/agent.test.ts +++ b/packages/opencode/test/agent/agent.test.ts @@ -105,9 +105,9 @@ it.instance("explore agent asks for external directories and allows whitelisted expect(explore).toBeDefined() expect(Permission.evaluate("external_directory", "/some/other/path", explore!.permission).action).toBe("ask") expect(Permission.evaluate("external_directory", Truncate.GLOB, explore!.permission).action).toBe("allow") - expect(Permission.evaluate("external_directory", path.join(Global.Path.tmp, "agent-work"), explore!.permission).action).toBe( - "allow", - ) + expect( + Permission.evaluate("external_directory", path.join(Global.Path.tmp, "agent-work"), explore!.permission).action, + ).toBe("allow") }), ) @@ -548,9 +548,9 @@ it.instance( it.instance("global tmp directory children are allowed for external_directory", () => Effect.gen(function* () { const build = yield* load((svc) => svc.get("build")) - expect(Permission.evaluate("external_directory", path.join(Global.Path.tmp, "scratch"), build!.permission).action).toBe( - "allow", - ) + expect( + Permission.evaluate("external_directory", path.join(Global.Path.tmp, "scratch"), build!.permission).action, + ).toBe("allow") expect(Permission.evaluate("external_directory", "/some/other/path", build!.permission).action).toBe("ask") }), ) diff --git a/packages/opencode/test/tool/external-directory.test.ts b/packages/opencode/test/tool/external-directory.test.ts index 7585ff98f1..04ef5c5d01 100644 --- a/packages/opencode/test/tool/external-directory.test.ts +++ b/packages/opencode/test/tool/external-directory.test.ts @@ -107,46 +107,50 @@ describe("tool.assertExternalDirectory", () => { ) if (process.platform === "win32") { - it.instance("normalizes Windows path variants to one glob", () => - Effect.gen(function* () { - const { requests, ctx } = makeCtx() + it.instance( + "normalizes Windows path variants to one glob", + () => + Effect.gen(function* () { + const { requests, ctx } = makeCtx() - const outerTmp = yield* tmpdirScoped() - yield* Effect.promise(() => Bun.write(path.join(outerTmp, "outside.txt"), "x")) + const outerTmp = yield* tmpdirScoped() + yield* Effect.promise(() => Bun.write(path.join(outerTmp, "outside.txt"), "x")) - const target = path.join(outerTmp, "outside.txt") - const alt = target - .replace(/^[A-Za-z]:/, "") - .replaceAll("\\", "/") - .toLowerCase() + const target = path.join(outerTmp, "outside.txt") + const alt = target + .replace(/^[A-Za-z]:/, "") + .replaceAll("\\", "/") + .toLowerCase() - yield* assertExternalDirectoryEffect(ctx, alt) + yield* assertExternalDirectoryEffect(ctx, alt) - const req = requests.find((r) => r.permission === "external_directory") - const expected = glob(path.join(outerTmp, "*")) - expect(req).toBeDefined() - expect(req!.patterns).toEqual([expected]) - expect(req!.always).toEqual([expected]) - }), + const req = requests.find((r) => r.permission === "external_directory") + const expected = glob(path.join(outerTmp, "*")) + expect(req).toBeDefined() + expect(req!.patterns).toEqual([expected]) + expect(req!.always).toEqual([expected]) + }), { git: true }, ) - it.instance("uses drive root glob for root files", () => - Effect.gen(function* () { - const { requests, ctx } = makeCtx() + it.instance( + "uses drive root glob for root files", + () => + Effect.gen(function* () { + const { requests, ctx } = makeCtx() - const tmp = yield* TestInstance - const root = path.parse(tmp.directory).root - const target = path.join(root, "boot.ini") + const tmp = yield* TestInstance + const root = path.parse(tmp.directory).root + const target = path.join(root, "boot.ini") - yield* assertExternalDirectoryEffect(ctx, target) + yield* assertExternalDirectoryEffect(ctx, target) - const req = requests.find((r) => r.permission === "external_directory") - const expected = path.join(root, "*") - expect(req).toBeDefined() - expect(req!.patterns).toEqual([expected]) - expect(req!.always).toEqual([expected]) - }), + const req = requests.find((r) => r.permission === "external_directory") + const expected = path.join(root, "*") + expect(req).toBeDefined() + expect(req!.patterns).toEqual([expected]) + expect(req!.always).toEqual([expected]) + }), { git: true }, ) } From e540daabc4ca21df3871cc0e2e3cc570d9157c60 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 12 May 2026 14:56:01 -0400 Subject: [PATCH 44/70] test(agent): migrate plan bypass tests to Effect runner (#27119) --- .../agent/plan-mode-subagent-bypass.test.ts | 144 ++++++++---------- 1 file changed, 64 insertions(+), 80 deletions(-) diff --git a/packages/opencode/test/agent/plan-mode-subagent-bypass.test.ts b/packages/opencode/test/agent/plan-mode-subagent-bypass.test.ts index 5ba6b54834..255aea12ee 100644 --- a/packages/opencode/test/agent/plan-mode-subagent-bypass.test.ts +++ b/packages/opencode/test/agent/plan-mode-subagent-bypass.test.ts @@ -18,110 +18,85 @@ * permissions are passed through, and Plan Mode's restrictions live on the * agent, not the session. */ -import { test, expect, afterEach } from "bun:test" +import { expect } from "bun:test" import { Effect } from "effect" -import { disposeAllInstances, provideInstance, tmpdir } from "../fixture/fixture" -import { WithInstance } from "../../src/project/with-instance" import { Agent } from "../../src/agent/agent" import { deriveSubagentSessionPermission } from "../../src/agent/subagent-permissions" import { Permission } from "../../src/permission" +import { testEffect } from "../lib/effect" -afterEach(async () => { - await disposeAllInstances() -}) - -function load(dir: string, fn: (svc: Agent.Interface) => Effect.Effect) { - return Effect.runPromise(provideInstance(dir)(Agent.Service.use(fn)).pipe(Effect.provide(Agent.defaultLayer))) -} +const it = testEffect(Agent.defaultLayer) // `deriveSubagentSessionPermission` is imported from production. The test // exercises the actual helper that task.ts uses to build the subagent's // session permission, so any regression in that helper trips this test. -test("[#26514] subagent spawned from plan mode inherits read-only restriction (edit denied)", async () => { - await using tmp = await tmpdir() - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const planAgent = await load(tmp.path, (svc) => svc.get("plan")) - const generalAgent = await load(tmp.path, (svc) => svc.get("general")) +it.instance("[#26514] subagent spawned from plan mode inherits read-only restriction (edit denied)", () => + Effect.gen(function* () { + const planAgent = yield* Agent.Service.use((svc) => svc.get("plan")) + const generalAgent = yield* Agent.Service.use((svc) => svc.get("general")) - expect(planAgent).toBeDefined() - expect(generalAgent).toBeDefined() - // Sanity: the plan agent itself blocks edit. (Note: `write` and - // `apply_patch` route through the `edit` permission at the runtime - // tool layer — see Permission.disabled / EDIT_TOOLS.) - expect(Permission.evaluate("edit", "/some/file.ts", planAgent!.permission).action).toBe("deny") + expect(planAgent).toBeDefined() + expect(generalAgent).toBeDefined() + // Sanity: the plan agent itself blocks edit. (Note: `write` and + // `apply_patch` route through the `edit` permission at the runtime + // tool layer — see Permission.disabled / EDIT_TOOLS.) + expect(Permission.evaluate("edit", "/some/file.ts", planAgent!.permission).action).toBe("deny") - // Simulate the plan-mode parent session: in real flow the plan - // session's `permission` field is empty (Plan Mode lives on the agent - // ruleset, not the session). So we pass [] through as the parent - // session permission, exactly like the actual code path. - const parentSessionPermission: Permission.Ruleset = [] + // Simulate the plan-mode parent session: in real flow the plan + // session's `permission` field is empty (Plan Mode lives on the agent + // ruleset, not the session). So we pass [] through as the parent + // session permission, exactly like the actual code path. + const parentSessionPermission: Permission.Ruleset = [] - const subagentSessionPermission = deriveSubagentSessionPermission({ - parentSessionPermission, - parentAgent: planAgent, - subagent: generalAgent!, - }) + const subagentSessionPermission = deriveSubagentSessionPermission({ + parentSessionPermission, + parentAgent: planAgent, + subagent: generalAgent!, + }) - // Mirror the runtime evaluation in session/prompt.ts (~line 410, 639): - // ruleset: Permission.merge(agent.permission, session.permission ?? []) - const effective = Permission.merge(generalAgent!.permission, subagentSessionPermission) + // Mirror the runtime evaluation in session/prompt.ts (~line 410, 639): + // ruleset: Permission.merge(agent.permission, session.permission ?? []) + const effective = Permission.merge(generalAgent!.permission, subagentSessionPermission) - expect(Permission.evaluate("edit", "/some/file.ts", effective).action).toBe("deny") - expect(Permission.evaluate("edit", "/another/path/index.tsx", effective).action).toBe("deny") - }, - }) -}) + expect(Permission.evaluate("edit", "/some/file.ts", effective).action).toBe("deny") + expect(Permission.evaluate("edit", "/another/path/index.tsx", effective).action).toBe("deny") + }), +) -test("[#26514] explore subagent launched from plan mode also stays read-only", async () => { +it.instance("[#26514] explore subagent launched from plan mode also stays read-only", () => // Sibling check: even though `explore` is intrinsically read-only, the // bug surface is the same. Including this case to document that the fix // should propagate the parent **agent** permissions, not just deny edit // when the subagent happens to already deny it. - await using tmp = await tmpdir() - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const planAgent = await load(tmp.path, (svc) => svc.get("plan")) - const explore = await load(tmp.path, (svc) => svc.get("explore")) - expect(planAgent).toBeDefined() - expect(explore).toBeDefined() + Effect.gen(function* () { + const planAgent = yield* Agent.Service.use((svc) => svc.get("plan")) + const explore = yield* Agent.Service.use((svc) => svc.get("explore")) + expect(planAgent).toBeDefined() + expect(explore).toBeDefined() - const parentSessionPermission: Permission.Ruleset = [] - const subagentSessionPermission = deriveSubagentSessionPermission({ - parentSessionPermission, - parentAgent: planAgent, - subagent: explore!, - }) - const effective = Permission.merge(explore!.permission, subagentSessionPermission) + const parentSessionPermission: Permission.Ruleset = [] + const subagentSessionPermission = deriveSubagentSessionPermission({ + parentSessionPermission, + parentAgent: planAgent, + subagent: explore!, + }) + const effective = Permission.merge(explore!.permission, subagentSessionPermission) - // Already deny — sanity check. - expect(Permission.evaluate("edit", "/x.ts", effective).action).toBe("deny") - }, - }) -}) + // Already deny — sanity check. + expect(Permission.evaluate("edit", "/x.ts", effective).action).toBe("deny") + }), +) -test("[#26514] custom user subagent launched from plan mode bypasses Plan Mode read-only", async () => { +it.instance( + "[#26514] custom user subagent launched from plan mode bypasses Plan Mode read-only", // The most damaging case: a user-defined subagent with default // permissions (allow-by-default, like `general`). The subagent must NOT // be able to edit when the parent agent is `plan`. - await using tmp = await tmpdir({ - config: { - agent: { - my_subagent: { - description: "A user-defined subagent", - mode: "subagent", - }, - }, - }, - }) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const planAgent = await load(tmp.path, (svc) => svc.get("plan")) - const my = await load(tmp.path, (svc) => svc.get("my_subagent")) + () => + Effect.gen(function* () { + const planAgent = yield* Agent.Service.use((svc) => svc.get("plan")) + const my = yield* Agent.Service.use((svc) => svc.get("my_subagent")) expect(planAgent).toBeDefined() expect(my).toBeDefined() @@ -136,6 +111,15 @@ test("[#26514] custom user subagent launched from plan mode bypasses Plan Mode r // BUG: on origin/dev edit resolves to "allow" because the plan // agent's `edit: deny *` rule never reaches the subagent. expect(Permission.evaluate("edit", "/some/file.ts", effective).action).toBe("deny") + }), + { + config: { + agent: { + my_subagent: { + description: "A user-defined subagent", + mode: "subagent", + }, + }, }, - }) -}) + }, +) From 45de4975dec676e398a308149ae62bac5e017ce8 Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Wed, 13 May 2026 01:08:30 +0530 Subject: [PATCH 45/70] refactor(core): resolve default agent info (#27125) --- packages/opencode/src/acp/agent.ts | 6 +++--- packages/opencode/src/agent/agent.ts | 15 ++++++++++++--- .../instance/httpapi/handlers/experimental.ts | 2 +- packages/opencode/src/session/prompt.ts | 10 +++++----- packages/opencode/test/agent/agent.test.ts | 8 ++++++++ packages/opencode/test/tool/registry.test.ts | 2 +- 6 files changed, 30 insertions(+), 13 deletions(-) diff --git a/packages/opencode/src/acp/agent.ts b/packages/opencode/src/acp/agent.ts index 867b830cf2..a8eacf835c 100644 --- a/packages/opencode/src/acp/agent.ts +++ b/packages/opencode/src/acp/agent.ts @@ -1094,8 +1094,8 @@ export class Agent implements ACPAgent { const currentModeId = await (async () => { if (!availableModes.length) return undefined - const defaultAgentName = await AppRuntime.runPromise(AgentModule.Service.use((svc) => svc.defaultAgent())) - const resolvedModeId = availableModes.find((mode) => mode.name === defaultAgentName)?.id ?? availableModes[0].id + const defaultAgent = await AppRuntime.runPromise(AgentModule.Service.use((svc) => svc.defaultInfo())) + const resolvedModeId = availableModes.find((mode) => mode.name === defaultAgent.name)?.id ?? availableModes[0].id this.sessionManager.setMode(sessionId, resolvedModeId) return resolvedModeId })() @@ -1328,7 +1328,7 @@ export class Agent implements ACPAgent { if (!current) { this.sessionManager.setModel(session.id, model) } - const agent = session.modeId ?? (await AppRuntime.runPromise(AgentModule.Service.use((svc) => svc.defaultAgent()))) + const agent = session.modeId ?? (await AppRuntime.runPromise(AgentModule.Service.use((svc) => svc.defaultInfo()))).name const parts: Array< | { type: "text"; text: string; synthetic?: boolean; ignored?: boolean } diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index c1a644282b..423a513180 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -57,6 +57,7 @@ const GeneratedAgent = Schema.Struct({ export interface Interface { readonly get: (agent: string) => Effect.Effect readonly list: () => Effect.Effect + readonly defaultInfo: () => Effect.Effect readonly defaultAgent: () => Effect.Effect readonly generate: (input: { description: string @@ -333,23 +334,28 @@ export const layer = Layer.effect( ) }) - const defaultAgent = Effect.fnUntraced(function* () { + const defaultInfo = Effect.fnUntraced(function* () { const c = yield* config.get() if (c.default_agent) { const agent = agents[c.default_agent] if (!agent) throw new Error(`default agent "${c.default_agent}" not found`) if (agent.mode === "subagent") throw new Error(`default agent "${c.default_agent}" is a subagent`) if (agent.hidden === true) throw new Error(`default agent "${c.default_agent}" is hidden`) - return agent.name + return agent } const visible = Object.values(agents).find((a) => a.mode !== "subagent" && a.hidden !== true) if (!visible) throw new Error("no primary visible agent found") - return visible.name + return visible + }) + + const defaultAgent = Effect.fnUntraced(function* () { + return (yield* defaultInfo()).name }) return { get, list, + defaultInfo, defaultAgent, } satisfies State }), @@ -362,6 +368,9 @@ export const layer = Layer.effect( list: Effect.fn("Agent.list")(function* () { return yield* InstanceState.useEffect(state, (s) => s.list()) }), + defaultInfo: Effect.fn("Agent.defaultInfo")(function* () { + return yield* InstanceState.useEffect(state, (s) => s.defaultInfo()) + }), defaultAgent: Effect.fn("Agent.defaultAgent")(function* () { return yield* InstanceState.useEffect(state, (s) => s.defaultAgent()) }), diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/experimental.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/experimental.ts index 360daf54a5..9cf668cebb 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/experimental.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/experimental.ts @@ -79,7 +79,7 @@ export const experimentalHandlers = HttpApiBuilder.group(InstanceHttpApi, "exper const list = yield* registry.tools({ providerID: ctx.query.provider, modelID: ctx.query.model, - agent: yield* agents.get(yield* agents.defaultAgent()), + agent: yield* agents.defaultInfo(), }) return list.map((item) => ({ id: item.id, diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 15246dac39..b89561d5d1 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -1083,8 +1083,8 @@ NOTE: At any point in time through this workflow you should feel free to ask the }) const createUserMessage = Effect.fn("SessionPrompt.createUserMessage")(function* (input: PromptInput) { - const agentName = input.agent || (yield* agents.defaultAgent()) - const ag = yield* agents.get(agentName) + const agentName = input.agent + const ag = agentName ? yield* agents.get(agentName) : yield* agents.defaultInfo() if (!ag) { const available = (yield* agents.list()).filter((a) => !a.hidden).map((a) => a.name) const hint = available.length ? ` Available agents: ${available.join(", ")}` : "" @@ -1875,7 +1875,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the yield* bus.publish(Session.Event.Error, { sessionID: input.sessionID, error: error.toObject() }) throw error } - const agentName = cmd.agent ?? input.agent ?? (yield* agents.defaultAgent()) + const agentName = cmd.agent ?? input.agent const raw = input.arguments.match(argsRegex) ?? [] const args = raw.map((arg) => arg.replace(quoteTrimRegex, "")) @@ -1928,7 +1928,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the yield* getModel(taskModel.providerID, taskModel.modelID, input.sessionID) - const agent = yield* agents.get(agentName) + const agent = agentName ? yield* agents.get(agentName) : yield* agents.defaultInfo() if (!agent) { const available = (yield* agents.list()).filter((a) => !a.hidden).map((a) => a.name) const hint = available.length ? ` Available agents: ${available.join(", ")}` : "" @@ -1952,7 +1952,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the ] : [...templateParts, ...(input.parts ?? [])] - const userAgent = isSubtask ? (input.agent ?? (yield* agents.defaultAgent())) : agentName + const userAgent = isSubtask ? (input.agent ?? (yield* agents.defaultInfo()).name) : agent.name const userModel = isSubtask ? input.model ? Provider.parseModel(input.model) diff --git a/packages/opencode/test/agent/agent.test.ts b/packages/opencode/test/agent/agent.test.ts index 9893ee8223..a781a855c3 100644 --- a/packages/opencode/test/agent/agent.test.ts +++ b/packages/opencode/test/agent/agent.test.ts @@ -638,6 +638,14 @@ it.instance("defaultAgent returns build when no default_agent config", () => }), ) +it.instance("defaultInfo returns resolved build agent when no default_agent config", () => + Effect.gen(function* () { + const agent = yield* load((svc) => svc.defaultInfo()) + expect(agent.name).toBe("build") + expect(agent.mode).toBe("primary") + }), +) + it.instance( "defaultAgent respects default_agent config set to plan", () => diff --git a/packages/opencode/test/tool/registry.test.ts b/packages/opencode/test/tool/registry.test.ts index 5ee56300c4..fb4dd31a5f 100644 --- a/packages/opencode/test/tool/registry.test.ts +++ b/packages/opencode/test/tool/registry.test.ts @@ -180,7 +180,7 @@ describe("tool.registry", () => { const promptTools = yield* registry.tools({ providerID: ProviderID.opencode, modelID: ModelID.make("test"), - agent: yield* agents.get(yield* agents.defaultAgent()), + agent: yield* agents.defaultInfo(), }) const promptTool = promptTools.find((tool) => tool.id === "sql") if (!promptTool) throw new Error("custom sql tool was not returned for prompts") From ec960da42a2f3fef7523cbe026208b8c85323d40 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 12 May 2026 15:39:03 -0400 Subject: [PATCH 46/70] test(skill): migrate discovery tests to Effect runner (#27127) --- .../opencode/test/skill/discovery.test.ts | 131 ++++++++++-------- 1 file changed, 73 insertions(+), 58 deletions(-) diff --git a/packages/opencode/test/skill/discovery.test.ts b/packages/opencode/test/skill/discovery.test.ts index f4a17f25ce..43018d9a4f 100644 --- a/packages/opencode/test/skill/discovery.test.ts +++ b/packages/opencode/test/skill/discovery.test.ts @@ -1,10 +1,11 @@ -import { describe, test, expect, beforeAll, afterAll } from "bun:test" +import { describe, expect, beforeAll, afterAll } from "bun:test" import { Effect } from "effect" import { Discovery } from "../../src/skill/discovery" import { Global } from "@opencode-ai/core/global" import { Filesystem } from "@/util/filesystem" import { rm } from "fs/promises" import path from "path" +import { testEffect } from "../lib/effect" let CLOUDFLARE_SKILLS_URL: string let server: ReturnType @@ -12,6 +13,7 @@ let downloadCount = 0 const fixturePath = path.join(import.meta.dir, "../fixture/skills") const cacheDir = path.join(Global.Path.cache, "skills") +const it = testEffect(Discovery.defaultLayer) beforeAll(async () => { await rm(cacheDir, { recursive: true, force: true }) @@ -47,70 +49,83 @@ afterAll(async () => { }) describe("Discovery.pull", () => { - const pull = (url: string) => - Effect.runPromise(Discovery.Service.use((s) => s.pull(url)).pipe(Effect.provide(Discovery.defaultLayer))) - - test("downloads skills from cloudflare url", async () => { - const dirs = await pull(CLOUDFLARE_SKILLS_URL) - expect(dirs.length).toBeGreaterThan(0) - for (const dir of dirs) { - expect(dir).toStartWith(cacheDir) - const md = path.join(dir, "SKILL.md") - expect(await Filesystem.exists(md)).toBe(true) - } + const pull = Effect.fn("DiscoveryTest.pull")(function* (url: string) { + return yield* Discovery.Service.use((s) => s.pull(url)) }) - test("url without trailing slash works", async () => { - const dirs = await pull(CLOUDFLARE_SKILLS_URL.replace(/\/$/, "")) - expect(dirs.length).toBeGreaterThan(0) - for (const dir of dirs) { - const md = path.join(dir, "SKILL.md") - expect(await Filesystem.exists(md)).toBe(true) - } - }) + it.live("downloads skills from cloudflare url", () => + Effect.gen(function* () { + const dirs = yield* pull(CLOUDFLARE_SKILLS_URL) + expect(dirs.length).toBeGreaterThan(0) + for (const dir of dirs) { + expect(dir).toStartWith(cacheDir) + const md = path.join(dir, "SKILL.md") + expect(yield* Effect.promise(() => Filesystem.exists(md))).toBe(true) + } + }), + ) - test("returns empty array for invalid url", async () => { - const dirs = await pull(`http://localhost:${server.port}/invalid-url/`) - expect(dirs).toEqual([]) - }) + it.live("url without trailing slash works", () => + Effect.gen(function* () { + const dirs = yield* pull(CLOUDFLARE_SKILLS_URL.replace(/\/$/, "")) + expect(dirs.length).toBeGreaterThan(0) + for (const dir of dirs) { + const md = path.join(dir, "SKILL.md") + expect(yield* Effect.promise(() => Filesystem.exists(md))).toBe(true) + } + }), + ) - test("returns empty array for non-json response", async () => { - // any url not explicitly handled in server returns 404 text "Not Found" - const dirs = await pull(`http://localhost:${server.port}/some-other-path/`) - expect(dirs).toEqual([]) - }) + it.live("returns empty array for invalid url", () => + Effect.gen(function* () { + const dirs = yield* pull(`http://localhost:${server.port}/invalid-url/`) + expect(dirs).toEqual([]) + }), + ) - test("downloads reference files alongside SKILL.md", async () => { - const dirs = await pull(CLOUDFLARE_SKILLS_URL) - // find a skill dir that should have reference files (e.g. agents-sdk) - const agentsSdk = dirs.find((d) => d.endsWith(path.sep + "agents-sdk")) - expect(agentsSdk).toBeDefined() - if (agentsSdk) { - const refs = path.join(agentsSdk, "references") - expect(await Filesystem.exists(path.join(agentsSdk, "SKILL.md"))).toBe(true) - // agents-sdk has reference files per the index - const refDir = await Array.fromAsync(new Bun.Glob("**/*.md").scan({ cwd: refs, onlyFiles: true })) - expect(refDir.length).toBeGreaterThan(0) - } - }) + it.live("returns empty array for non-json response", () => + Effect.gen(function* () { + // any url not explicitly handled in server returns 404 text "Not Found" + const dirs = yield* pull(`http://localhost:${server.port}/some-other-path/`) + expect(dirs).toEqual([]) + }), + ) - test("caches downloaded files on second pull", async () => { - // clear dir and downloadCount - await rm(cacheDir, { recursive: true, force: true }) - downloadCount = 0 + it.live("downloads reference files alongside SKILL.md", () => + Effect.gen(function* () { + const dirs = yield* pull(CLOUDFLARE_SKILLS_URL) + // find a skill dir that should have reference files (e.g. agents-sdk) + const agentsSdk = dirs.find((d) => d.endsWith(path.sep + "agents-sdk")) + expect(agentsSdk).toBeDefined() + if (agentsSdk) { + const refs = path.join(agentsSdk, "references") + expect(yield* Effect.promise(() => Filesystem.exists(path.join(agentsSdk, "SKILL.md")))).toBe(true) + // agents-sdk has reference files per the index + const refDir = yield* Effect.promise(() => Array.fromAsync(new Bun.Glob("**/*.md").scan({ cwd: refs, onlyFiles: true }))) + expect(refDir.length).toBeGreaterThan(0) + } + }), + ) - // first pull to populate cache - const first = await pull(CLOUDFLARE_SKILLS_URL) - expect(first.length).toBeGreaterThan(0) - const firstCount = downloadCount - expect(firstCount).toBeGreaterThan(0) + it.live("caches downloaded files on second pull", () => + Effect.gen(function* () { + // clear dir and downloadCount + yield* Effect.promise(() => rm(cacheDir, { recursive: true, force: true })) + downloadCount = 0 - // second pull should return same results from cache - const second = await pull(CLOUDFLARE_SKILLS_URL) - expect(second.length).toBe(first.length) - expect(second.sort()).toEqual(first.sort()) + // first pull to populate cache + const first = yield* pull(CLOUDFLARE_SKILLS_URL) + expect(first.length).toBeGreaterThan(0) + const firstCount = downloadCount + expect(firstCount).toBeGreaterThan(0) - // second pull should NOT increment download count - expect(downloadCount).toBe(firstCount) - }) + // second pull should return same results from cache + const second = yield* pull(CLOUDFLARE_SKILLS_URL) + expect(second.length).toBe(first.length) + expect(second.sort()).toEqual(first.sort()) + + // second pull should NOT increment download count + expect(downloadCount).toBe(firstCount) + }), + ) }) From 3e2ec192cf06970bd51722918070134f5ab4e7b1 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 12 May 2026 15:40:01 -0400 Subject: [PATCH 47/70] test(question): remove WithInstance bridge (#27128) --- packages/opencode/test/question/question.test.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/packages/opencode/test/question/question.test.ts b/packages/opencode/test/question/question.test.ts index 4e2c8ef9bb..a87907c57e 100644 --- a/packages/opencode/test/question/question.test.ts +++ b/packages/opencode/test/question/question.test.ts @@ -2,7 +2,6 @@ import { afterEach, expect } from "bun:test" import { Cause, Effect, Exit, Fiber, Layer } from "effect" import { Question } from "../../src/question" import { Instance } from "../../src/project/instance" -import { WithInstance } from "../../src/project/with-instance" import { InstanceRuntime } from "../../src/project/instance-runtime" import { QuestionID } from "../../src/question/schema" import { disposeAllInstances, provideInstance, reloadTestInstance, tmpdirScoped } from "../fixture/fixture" @@ -398,9 +397,7 @@ it.live("pending question rejects on instance dispose", () => }).pipe(provideInstance(dir), Effect.forkScoped) expect(yield* waitForPending(1).pipe(provideInstance(dir))).toHaveLength(1) - yield* Effect.promise(() => - WithInstance.provide({ directory: dir, fn: () => InstanceRuntime.disposeInstance(Instance.current) }), - ) + yield* Effect.promise(() => InstanceRuntime.disposeInstance(Instance.current)).pipe(provideInstance(dir)) const exit = yield* Fiber.await(fiber) expect(Exit.isFailure(exit)).toBe(true) From fec78154b5449f8995d2676853769643eef8ce4d Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 12 May 2026 15:41:24 -0400 Subject: [PATCH 48/70] test(bus): migrate bus tests to Effect runner (#27131) --- packages/opencode/test/bus/bus.test.ts | 316 +++++++++++++------------ 1 file changed, 168 insertions(+), 148 deletions(-) diff --git a/packages/opencode/test/bus/bus.test.ts b/packages/opencode/test/bus/bus.test.ts index 876cb1ed74..0844986162 100644 --- a/packages/opencode/test/bus/bus.test.ts +++ b/packages/opencode/test/bus/bus.test.ts @@ -1,220 +1,240 @@ -import { afterEach, describe, expect, test } from "bun:test" -import { Schema } from "effect" +import { afterEach, describe, expect } from "bun:test" +import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" +import { Deferred, Effect, Layer, Schema } from "effect" import { Bus } from "../../src/bus" import { BusEvent } from "../../src/bus/bus-event" -import { Instance } from "../../src/project/instance" -import { WithInstance } from "../../src/project/with-instance" -import { disposeAllInstances, tmpdir } from "../fixture/fixture" +import { disposeAllInstances, provideInstance, tmpdirScoped } from "../fixture/fixture" +import { testEffect } from "../lib/effect" const TestEvent = { Ping: BusEvent.define("test.ping", Schema.Struct({ value: Schema.Number })), Pong: BusEvent.define("test.pong", Schema.Struct({ message: Schema.String })), } -function withInstance(directory: string, fn: () => Promise) { - return WithInstance.provide({ directory, fn }) -} +const it = testEffect(Layer.mergeAll(Bus.layer, CrossSpawnSpawner.defaultLayer)) describe("Bus", () => { afterEach(() => disposeAllInstances()) describe("publish + subscribe", () => { - test("subscriber is live immediately after subscribe returns", async () => { - await using tmp = await tmpdir() - const received: number[] = [] + it.instance("subscriber is live immediately after subscribe returns", () => + Effect.gen(function* () { + const bus = yield* Bus.Service + const received: number[] = [] + const done = yield* Deferred.make() - await withInstance(tmp.path, async () => { - Bus.subscribe(TestEvent.Ping, (evt) => { + yield* bus.subscribeCallback(TestEvent.Ping, (evt) => { received.push(evt.properties.value) + Deferred.doneUnsafe(done, Effect.void) }) - await Bus.publish(TestEvent.Ping, { value: 42 }) - await Bun.sleep(10) - }) + yield* bus.publish(TestEvent.Ping, { value: 42 }) + yield* Deferred.await(done).pipe(Effect.timeout("2 seconds")) - expect(received).toEqual([42]) - }) + expect(received).toEqual([42]) + }), + ) - test("subscriber receives matching events", async () => { - await using tmp = await tmpdir() - const received: number[] = [] + it.instance("subscriber receives matching events", () => + Effect.gen(function* () { + const bus = yield* Bus.Service + const received: number[] = [] + const done = yield* Deferred.make() - await withInstance(tmp.path, async () => { - Bus.subscribe(TestEvent.Ping, (evt) => { + yield* bus.subscribeCallback(TestEvent.Ping, (evt) => { received.push(evt.properties.value) + if (received.length === 2) Deferred.doneUnsafe(done, Effect.void) }) - // Give the subscriber fiber time to start consuming - await Bun.sleep(10) - await Bus.publish(TestEvent.Ping, { value: 42 }) - await Bus.publish(TestEvent.Ping, { value: 99 }) - // Give subscriber time to process - await Bun.sleep(10) - }) + yield* bus.publish(TestEvent.Ping, { value: 42 }) + yield* bus.publish(TestEvent.Ping, { value: 99 }) + yield* Deferred.await(done).pipe(Effect.timeout("2 seconds")) - expect(received).toEqual([42, 99]) - }) + expect(received).toEqual([42, 99]) + }), + ) - test("subscriber does not receive events of other types", async () => { - await using tmp = await tmpdir() - const pings: number[] = [] + it.instance("subscriber does not receive events of other types", () => + Effect.gen(function* () { + const bus = yield* Bus.Service + const pings: number[] = [] + const done = yield* Deferred.make() - await withInstance(tmp.path, async () => { - Bus.subscribe(TestEvent.Ping, (evt) => { + yield* bus.subscribeCallback(TestEvent.Ping, (evt) => { pings.push(evt.properties.value) + Deferred.doneUnsafe(done, Effect.void) }) - await Bun.sleep(10) - await Bus.publish(TestEvent.Pong, { message: "hello" }) - await Bus.publish(TestEvent.Ping, { value: 1 }) - await Bun.sleep(10) - }) + yield* bus.publish(TestEvent.Pong, { message: "hello" }) + yield* bus.publish(TestEvent.Ping, { value: 1 }) + yield* Deferred.await(done).pipe(Effect.timeout("2 seconds")) - expect(pings).toEqual([1]) - }) + expect(pings).toEqual([1]) + }), + ) - test("publish with no subscribers does not throw", async () => { - await using tmp = await tmpdir() - - await withInstance(tmp.path, async () => { - await Bus.publish(TestEvent.Ping, { value: 1 }) - }) - }) + it.instance("publish with no subscribers does not throw", () => + Effect.gen(function* () { + const bus = yield* Bus.Service + yield* bus.publish(TestEvent.Ping, { value: 1 }) + }), + ) }) describe("unsubscribe", () => { - test("unsubscribe stops delivery", async () => { - await using tmp = await tmpdir() - const received: number[] = [] + it.instance("unsubscribe stops delivery", () => + Effect.gen(function* () { + const bus = yield* Bus.Service + const received: number[] = [] + const first = yield* Deferred.make() - await withInstance(tmp.path, async () => { - const unsub = Bus.subscribe(TestEvent.Ping, (evt) => { + const unsub = yield* bus.subscribeCallback(TestEvent.Ping, (evt) => { received.push(evt.properties.value) + if (evt.properties.value === 1) Deferred.doneUnsafe(first, Effect.void) }) - await Bun.sleep(10) - await Bus.publish(TestEvent.Ping, { value: 1 }) - await Bun.sleep(10) - unsub() - await Bun.sleep(10) - await Bus.publish(TestEvent.Ping, { value: 2 }) - await Bun.sleep(10) - }) + yield* bus.publish(TestEvent.Ping, { value: 1 }) + yield* Deferred.await(first).pipe(Effect.timeout("2 seconds")) + yield* Effect.sync(unsub) + yield* bus.publish(TestEvent.Ping, { value: 2 }) + yield* Effect.sleep("10 millis") - expect(received).toEqual([1]) - }) + expect(received).toEqual([1]) + }), + ) }) describe("subscribeAll", () => { - test("subscribeAll is live immediately after subscribe returns", async () => { - await using tmp = await tmpdir() - const received: string[] = [] + it.instance("subscribeAll is live immediately after subscribe returns", () => + Effect.gen(function* () { + const bus = yield* Bus.Service + const received: string[] = [] + const done = yield* Deferred.make() - await withInstance(tmp.path, async () => { - Bus.subscribeAll((evt) => { + yield* bus.subscribeAllCallback((evt) => { received.push(evt.type) + Deferred.doneUnsafe(done, Effect.void) }) - await Bus.publish(TestEvent.Ping, { value: 1 }) - await Bun.sleep(10) - }) + yield* bus.publish(TestEvent.Ping, { value: 1 }) + yield* Deferred.await(done).pipe(Effect.timeout("2 seconds")) - expect(received).toEqual(["test.ping"]) - }) + expect(received).toEqual(["test.ping"]) + }), + ) - test("receives all event types", async () => { - await using tmp = await tmpdir() - const received: string[] = [] + it.instance("receives all event types", () => + Effect.gen(function* () { + const bus = yield* Bus.Service + const received: string[] = [] + const done = yield* Deferred.make() - await withInstance(tmp.path, async () => { - Bus.subscribeAll((evt) => { + yield* bus.subscribeAllCallback((evt) => { received.push(evt.type) + if (received.length === 2) Deferred.doneUnsafe(done, Effect.void) }) - await Bun.sleep(10) - await Bus.publish(TestEvent.Ping, { value: 1 }) - await Bus.publish(TestEvent.Pong, { message: "hi" }) - await Bun.sleep(10) - }) + yield* bus.publish(TestEvent.Ping, { value: 1 }) + yield* bus.publish(TestEvent.Pong, { message: "hi" }) + yield* Deferred.await(done).pipe(Effect.timeout("2 seconds")) - expect(received).toContain("test.ping") - expect(received).toContain("test.pong") - }) + expect(received).toContain("test.ping") + expect(received).toContain("test.pong") + }), + ) }) describe("multiple subscribers", () => { - test("all subscribers for same event type are called", async () => { - await using tmp = await tmpdir() - const a: number[] = [] - const b: number[] = [] + it.instance("all subscribers for same event type are called", () => + Effect.gen(function* () { + const bus = yield* Bus.Service + const a: number[] = [] + const b: number[] = [] + const doneA = yield* Deferred.make() + const doneB = yield* Deferred.make() - await withInstance(tmp.path, async () => { - Bus.subscribe(TestEvent.Ping, (evt) => { + yield* bus.subscribeCallback(TestEvent.Ping, (evt) => { a.push(evt.properties.value) + Deferred.doneUnsafe(doneA, Effect.void) }) - Bus.subscribe(TestEvent.Ping, (evt) => { + yield* bus.subscribeCallback(TestEvent.Ping, (evt) => { b.push(evt.properties.value) + Deferred.doneUnsafe(doneB, Effect.void) }) - await Bun.sleep(10) - await Bus.publish(TestEvent.Ping, { value: 7 }) - await Bun.sleep(10) - }) + yield* bus.publish(TestEvent.Ping, { value: 7 }) + yield* Deferred.await(doneA).pipe(Effect.timeout("2 seconds")) + yield* Deferred.await(doneB).pipe(Effect.timeout("2 seconds")) - expect(a).toEqual([7]) - expect(b).toEqual([7]) - }) + expect(a).toEqual([7]) + expect(b).toEqual([7]) + }), + ) }) describe("instance isolation", () => { - test("events in one directory do not reach subscribers in another", async () => { - await using tmpA = await tmpdir() - await using tmpB = await tmpdir() - const receivedA: number[] = [] - const receivedB: number[] = [] + it.live("events in one directory do not reach subscribers in another", () => + Effect.gen(function* () { + const tmpA = yield* tmpdirScoped() + const tmpB = yield* tmpdirScoped() + const receivedA: number[] = [] + const receivedB: number[] = [] + const doneA = yield* Deferred.make() + const doneB = yield* Deferred.make() - await withInstance(tmpA.path, async () => { - Bus.subscribe(TestEvent.Ping, (evt) => { - receivedA.push(evt.properties.value) - }) - await Bun.sleep(10) - }) + yield* Effect.gen(function* () { + const bus = yield* Bus.Service + yield* bus.subscribeCallback(TestEvent.Ping, (evt) => { + receivedA.push(evt.properties.value) + Deferred.doneUnsafe(doneA, Effect.void) + }) + }).pipe(provideInstance(tmpA)) - await withInstance(tmpB.path, async () => { - Bus.subscribe(TestEvent.Ping, (evt) => { - receivedB.push(evt.properties.value) - }) - await Bun.sleep(10) - }) + yield* Effect.gen(function* () { + const bus = yield* Bus.Service + yield* bus.subscribeCallback(TestEvent.Ping, (evt) => { + receivedB.push(evt.properties.value) + Deferred.doneUnsafe(doneB, Effect.void) + }) + }).pipe(provideInstance(tmpB)) - await withInstance(tmpA.path, async () => { - await Bus.publish(TestEvent.Ping, { value: 1 }) - await Bun.sleep(10) - }) + yield* Effect.gen(function* () { + const bus = yield* Bus.Service + yield* bus.publish(TestEvent.Ping, { value: 1 }) + }).pipe(provideInstance(tmpA)) - await withInstance(tmpB.path, async () => { - await Bus.publish(TestEvent.Ping, { value: 2 }) - await Bun.sleep(10) - }) + yield* Effect.gen(function* () { + const bus = yield* Bus.Service + yield* bus.publish(TestEvent.Ping, { value: 2 }) + }).pipe(provideInstance(tmpB)) - expect(receivedA).toEqual([1]) - expect(receivedB).toEqual([2]) - }) + yield* Deferred.await(doneA).pipe(Effect.timeout("2 seconds")) + yield* Deferred.await(doneB).pipe(Effect.timeout("2 seconds")) + + expect(receivedA).toEqual([1]) + expect(receivedB).toEqual([2]) + }), + ) }) describe("instance disposal", () => { - test("InstanceDisposed is delivered to wildcard subscribers before stream ends", async () => { - await using tmp = await tmpdir() - const received: string[] = [] + it.live("InstanceDisposed is delivered to wildcard subscribers before stream ends", () => + Effect.gen(function* () { + const tmp = yield* tmpdirScoped() + const received: string[] = [] + const seen = yield* Deferred.make() + const disposed = yield* Deferred.make() - await withInstance(tmp.path, async () => { - Bus.subscribeAll((evt) => { - received.push(evt.type) - }) - await Bun.sleep(10) - await Bus.publish(TestEvent.Ping, { value: 1 }) - await Bun.sleep(10) - }) + yield* Effect.gen(function* () { + const bus = yield* Bus.Service + yield* bus.subscribeAllCallback((evt) => { + received.push(evt.type) + if (evt.type === TestEvent.Ping.type) Deferred.doneUnsafe(seen, Effect.void) + if (evt.type === Bus.InstanceDisposed.type) Deferred.doneUnsafe(disposed, Effect.void) + }) + yield* bus.publish(TestEvent.Ping, { value: 1 }) + yield* Deferred.await(seen).pipe(Effect.timeout("2 seconds")) + }).pipe(provideInstance(tmp)) - // disposeAllInstances triggers the finalizer which publishes InstanceDisposed - await disposeAllInstances() - await Bun.sleep(50) + yield* Effect.promise(disposeAllInstances) + yield* Deferred.await(disposed).pipe(Effect.timeout("2 seconds")) - expect(received).toContain("test.ping") - expect(received).toContain(Bus.InstanceDisposed.type) - }) + expect(received).toContain("test.ping") + expect(received).toContain(Bus.InstanceDisposed.type) + }), + ) }) }) From 71040c54aa89acbda7987dd2244091ae05e10cc8 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 12 May 2026 15:41:44 -0400 Subject: [PATCH 49/70] test(plugin): migrate loader shared tests to Effect runner (#27129) --- .../test/plugin/loader-shared.test.ts | 1034 +++++++++-------- 1 file changed, 563 insertions(+), 471 deletions(-) diff --git a/packages/opencode/test/plugin/loader-shared.test.ts b/packages/opencode/test/plugin/loader-shared.test.ts index 8c55950aff..ffdb3291b4 100644 --- a/packages/opencode/test/plugin/loader-shared.test.ts +++ b/packages/opencode/test/plugin/loader-shared.test.ts @@ -1,9 +1,11 @@ -import { afterAll, afterEach, describe, expect, spyOn, test } from "bun:test" +import { afterAll, afterEach, describe, expect, spyOn } from "bun:test" import { Effect, Layer } from "effect" import fs from "fs/promises" import path from "path" import { pathToFileURL } from "url" -import { disposeAllInstances, provideInstance, tmpdir } from "../fixture/fixture" +import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" +import { disposeAllInstances, provideInstance, tmpdirScoped } from "../fixture/fixture" +import { testEffect } from "../lib/effect" import { Filesystem } from "@/util/filesystem" const disableDefault = process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS @@ -28,38 +30,54 @@ afterEach(async () => { await disposeAllInstances() }) -async function load(dir: string) { - const source = path.join(dir, "opencode.json") - const config = (await Bun.file(source).json()) as { plugin?: Array]> } - const plugins = config.plugin ?? [] +const it = testEffect(CrossSpawnSpawner.defaultLayer) + +function withTmp( + init: (dir: string) => Promise, + body: (tmp: { path: string; extra: T }) => Effect.Effect, +) { return Effect.gen(function* () { - const plugin = yield* Plugin.Service - yield* plugin.list() - }).pipe( - Effect.provide( - Plugin.layer.pipe( - Layer.provide(Bus.layer), - Layer.provide( - TestConfig.layer({ - get: () => - Effect.succeed({ - plugin: plugins, - plugin_origins: plugins.map((plugin) => ({ spec: plugin, source, scope: "local" as const })), - }), - directories: () => Effect.succeed([dir]), - }), + const dir = yield* tmpdirScoped() + const extra = yield* Effect.promise(() => init(dir)) + return yield* body({ path: dir, extra }) + }) +} + +function load(dir: string) { + const source = path.join(dir, "opencode.json") + return Effect.gen(function* () { + const config = yield* Effect.promise( + () => Bun.file(source).json() as Promise<{ plugin?: Array]> }>, + ) + const plugins = config.plugin ?? [] + return yield* Effect.gen(function* () { + const plugin = yield* Plugin.Service + yield* plugin.list() + }).pipe( + Effect.provide( + Plugin.layer.pipe( + Layer.provide(Bus.layer), + Layer.provide( + TestConfig.layer({ + get: () => + Effect.succeed({ + plugin: plugins, + plugin_origins: plugins.map((plugin) => ({ spec: plugin, source, scope: "local" as const })), + }), + directories: () => Effect.succeed([dir]), + }), + ), ), ), - ), - provideInstance(dir), - Effect.runPromise, - ) + provideInstance(dir), + ) + }) } describe("plugin.loader.shared", () => { - test("loads a file:// plugin function export", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { + it.live("loads a file:// plugin function export", () => + withTmp( + async (dir) => { const file = path.join(dir, "plugin.ts") const mark = path.join(dir, "called.txt") await Bun.write( @@ -80,15 +98,17 @@ describe("plugin.loader.shared", () => { return { mark } }, - }) + (tmp) => + Effect.gen(function* () { + yield* load(tmp.path) + expect(yield* Effect.promise(() => fs.readFile(tmp.extra.mark, "utf8"))).toBe("called") + }), + ), + ) - await load(tmp.path) - expect(await fs.readFile(tmp.extra.mark, "utf8")).toBe("called") - }) - - test("deduplicates same function exported as default and named", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { + it.live("deduplicates same function exported as default and named", () => + withTmp( + async (dir) => { const file = path.join(dir, "plugin.ts") const mark = path.join(dir, "count.txt") await Bun.write(mark, "") @@ -113,15 +133,17 @@ describe("plugin.loader.shared", () => { return { mark } }, - }) + (tmp) => + Effect.gen(function* () { + yield* load(tmp.path) + expect(yield* Effect.promise(() => fs.readFile(tmp.extra.mark, "utf8"))).toBe("1") + }), + ), + ) - await load(tmp.path) - expect(await fs.readFile(tmp.extra.mark, "utf8")).toBe("1") - }) - - test("uses only default v1 server plugin when present", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { + it.live("uses only default v1 server plugin when present", () => + withTmp( + async (dir) => { const file = path.join(dir, "plugin.ts") const mark = path.join(dir, "count.txt") await Bun.write( @@ -149,15 +171,17 @@ describe("plugin.loader.shared", () => { return { mark } }, - }) + (tmp) => + Effect.gen(function* () { + yield* load(tmp.path) + expect(yield* Effect.promise(() => Bun.file(tmp.extra.mark).text())).toBe("default") + }), + ), + ) - await load(tmp.path) - expect(await Bun.file(tmp.extra.mark).text()).toBe("default") - }) - - test("rejects v1 file server plugin without id", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { + it.live("rejects v1 file server plugin without id", () => + withTmp( + async (dir) => { const file = path.join(dir, "plugin.ts") const mark = path.join(dir, "called.txt") await Bun.write( @@ -180,20 +204,24 @@ describe("plugin.loader.shared", () => { return { mark } }, - }) + (tmp) => + Effect.gen(function* () { + yield* load(tmp.path) + const called = yield* Effect.promise(() => + Bun.file(tmp.extra.mark) + .text() + .then(() => true) + .catch(() => false), + ) - await load(tmp.path) - const called = await Bun.file(tmp.extra.mark) - .text() - .then(() => true) - .catch(() => false) + expect(called).toBe(false) + }), + ), + ) - expect(called).toBe(false) - }) - - test("rejects v1 plugin that exports server and tui together", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { + it.live("rejects v1 plugin that exports server and tui together", () => + withTmp( + async (dir) => { const file = path.join(dir, "plugin.ts") const mark = path.join(dir, "called.txt") await Bun.write( @@ -218,20 +246,24 @@ describe("plugin.loader.shared", () => { return { mark } }, - }) + (tmp) => + Effect.gen(function* () { + yield* load(tmp.path) + const called = yield* Effect.promise(() => + Bun.file(tmp.extra.mark) + .text() + .then(() => true) + .catch(() => false), + ) - await load(tmp.path) - const called = await Bun.file(tmp.extra.mark) - .text() - .then(() => true) - .catch(() => false) + expect(called).toBe(false) + }), + ), + ) - expect(called).toBe(false) - }) - - test("resolves npm plugin specs with explicit and default versions", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { + it.live("resolves npm plugin specs with explicit and default versions", () => + withTmp( + async (dir) => { const acme = path.join(dir, "node_modules", "acme-plugin") const scope = path.join(dir, "node_modules", "scope-plugin") await fs.mkdir(acme, { recursive: true }) @@ -254,26 +286,28 @@ describe("plugin.loader.shared", () => { return { acme, scope } }, - }) + (tmp) => + Effect.gen(function* () { + const add = spyOn(Npm, "add").mockImplementation(async (pkg) => { + if (pkg === "acme-plugin") return { directory: tmp.extra.acme, entrypoint: undefined } + return { directory: tmp.extra.scope, entrypoint: undefined } + }) - const add = spyOn(Npm, "add").mockImplementation(async (pkg) => { - if (pkg === "acme-plugin") return { directory: tmp.extra.acme, entrypoint: undefined } - return { directory: tmp.extra.scope, entrypoint: undefined } - }) + try { + yield* load(tmp.path) - try { - await load(tmp.path) + expect(add.mock.calls).toContainEqual(["acme-plugin@latest"]) + expect(add.mock.calls).toContainEqual(["scope-plugin@2.3.4"]) + } finally { + add.mockRestore() + } + }), + ), + ) - expect(add.mock.calls).toContainEqual(["acme-plugin@latest"]) - expect(add.mock.calls).toContainEqual(["scope-plugin@2.3.4"]) - } finally { - add.mockRestore() - } - }) - - test("loads npm server plugin from package ./server export", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { + it.live("loads npm server plugin from package ./server export", () => + withTmp( + async (dir) => { const mod = path.join(dir, "mods", "acme-plugin") const mark = path.join(dir, "server-called.txt") await fs.mkdir(mod, { recursive: true }) @@ -317,21 +351,23 @@ describe("plugin.loader.shared", () => { mark, } }, - }) + (tmp) => + Effect.gen(function* () { + const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined }) - const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined }) + try { + yield* load(tmp.path) + expect(yield* Effect.promise(() => Bun.file(tmp.extra.mark).text())).toBe("called") + } finally { + install.mockRestore() + } + }), + ), + ) - try { - await load(tmp.path) - expect(await Bun.file(tmp.extra.mark).text()).toBe("called") - } finally { - install.mockRestore() - } - }) - - test("loads npm server plugin from package server export without leading dot", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { + it.live("loads npm server plugin from package server export without leading dot", () => + withTmp( + async (dir) => { const mod = path.join(dir, "mods", "acme-plugin") const dist = path.join(mod, "dist") const mark = path.join(dir, "server-called.txt") @@ -374,21 +410,23 @@ describe("plugin.loader.shared", () => { mark, } }, - }) + (tmp) => + Effect.gen(function* () { + const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined }) - const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined }) + try { + yield* load(tmp.path) + expect(yield* Effect.promise(() => Bun.file(tmp.extra.mark).text())).toBe("called") + } finally { + install.mockRestore() + } + }), + ), + ) - try { - await load(tmp.path) - expect(await Bun.file(tmp.extra.mark).text()).toBe("called") - } finally { - install.mockRestore() - } - }) - - test("loads npm server plugin from package main without leading dot", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { + it.live("loads npm server plugin from package main without leading dot", () => + withTmp( + async (dir) => { const mod = path.join(dir, "mods", "acme-plugin") const dist = path.join(mod, "dist") const mark = path.join(dir, "main-called.txt") @@ -426,21 +464,23 @@ describe("plugin.loader.shared", () => { mark, } }, - }) + (tmp) => + Effect.gen(function* () { + const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined }) - const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined }) + try { + yield* load(tmp.path) + expect(yield* Effect.promise(() => Bun.file(tmp.extra.mark).text())).toBe("called") + } finally { + install.mockRestore() + } + }), + ), + ) - try { - await load(tmp.path) - expect(await Bun.file(tmp.extra.mark).text()).toBe("called") - } finally { - install.mockRestore() - } - }) - - test("does not use npm package exports dot for server entry", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { + it.live("does not use npm package exports dot for server entry", () => + withTmp( + async (dir) => { const mod = path.join(dir, "mods", "acme-plugin") const mark = path.join(dir, "dot-server.txt") await fs.mkdir(mod, { recursive: true }) @@ -471,26 +511,30 @@ describe("plugin.loader.shared", () => { return { mod, mark } }, - }) + (tmp) => + Effect.gen(function* () { + const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined }) - const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined }) + try { + yield* load(tmp.path) + const called = yield* Effect.promise(() => + Bun.file(tmp.extra.mark) + .text() + .then(() => true) + .catch(() => false), + ) - try { - await load(tmp.path) - const called = await Bun.file(tmp.extra.mark) - .text() - .then(() => true) - .catch(() => false) + expect(called).toBe(false) + } finally { + install.mockRestore() + } + }), + ), + ) - expect(called).toBe(false) - } finally { - install.mockRestore() - } - }) - - test("rejects npm server export that resolves outside plugin directory", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { + it.live("rejects npm server export that resolves outside plugin directory", () => + withTmp( + async (dir) => { const mod = path.join(dir, "mods", "acme-plugin") const outside = path.join(dir, "outside") const mark = path.join(dir, "outside-server.txt") @@ -534,25 +578,29 @@ describe("plugin.loader.shared", () => { mark, } }, - }) + (tmp) => + Effect.gen(function* () { + const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined }) - const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined }) + try { + yield* load(tmp.path) + const called = yield* Effect.promise(() => + Bun.file(tmp.extra.mark) + .text() + .then(() => true) + .catch(() => false), + ) + expect(called).toBe(false) + } finally { + install.mockRestore() + } + }), + ), + ) - try { - await load(tmp.path) - const called = await Bun.file(tmp.extra.mark) - .text() - .then(() => true) - .catch(() => false) - expect(called).toBe(false) - } finally { - install.mockRestore() - } - }) - - test("skips legacy codex and copilot auth plugin specs", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { + it.live("skips legacy codex and copilot auth plugin specs", () => + withTmp( + async (dir) => { await Bun.write( path.join(dir, "opencode.json"), JSON.stringify( @@ -564,25 +612,27 @@ describe("plugin.loader.shared", () => { ), ) }, - }) + (_tmp) => + Effect.gen(function* () { + const install = spyOn(Npm, "add").mockResolvedValue({ directory: "", entrypoint: undefined }) - const install = spyOn(Npm, "add").mockResolvedValue({ directory: "", entrypoint: undefined }) + try { + yield* load(_tmp.path) - try { - await load(tmp.path) + const pkgs = install.mock.calls.map((call) => call[0]) + expect(pkgs).toContain("regular-plugin@1.0.0") + expect(pkgs).not.toContain("opencode-openai-codex-auth@1.0.0") + expect(pkgs).not.toContain("opencode-copilot-auth@1.0.0") + } finally { + install.mockRestore() + } + }), + ), + ) - const pkgs = install.mock.calls.map((call) => call[0]) - expect(pkgs).toContain("regular-plugin@1.0.0") - expect(pkgs).not.toContain("opencode-openai-codex-auth@1.0.0") - expect(pkgs).not.toContain("opencode-copilot-auth@1.0.0") - } finally { - install.mockRestore() - } - }) - - test("skips broken plugin when install fails", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { + it.live("skips broken plugin when install fails", () => + withTmp( + async (dir) => { const ok = path.join(dir, "ok.ts") const mark = path.join(dir, "ok.txt") await Bun.write( @@ -604,22 +654,24 @@ describe("plugin.loader.shared", () => { ) return { mark } }, - }) + (tmp) => + Effect.gen(function* () { + const install = spyOn(Npm, "add").mockRejectedValue(new Error("boom")) - const install = spyOn(Npm, "add").mockRejectedValue(new Error("boom")) + try { + yield* load(tmp.path) + expect(install).toHaveBeenCalledWith("broken-plugin@9.9.9") + expect(yield* Effect.promise(() => Bun.file(tmp.extra.mark).text())).toBe("ok") + } finally { + install.mockRestore() + } + }), + ), + ) - try { - await load(tmp.path) - expect(install).toHaveBeenCalledWith("broken-plugin@9.9.9") - expect(await Bun.file(tmp.extra.mark).text()).toBe("ok") - } finally { - install.mockRestore() - } - }) - - test("continues loading plugins when plugin init throws", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { + it.live("continues loading plugins when plugin init throws", () => + withTmp( + async (dir) => { const file = pathToFileURL(path.join(dir, "throws.ts")).href const ok = pathToFileURL(path.join(dir, "ok.ts")).href const mark = path.join(dir, "ok.txt") @@ -653,15 +705,17 @@ describe("plugin.loader.shared", () => { return { mark } }, - }) + (tmp) => + Effect.gen(function* () { + yield* load(tmp.path) + expect(yield* Effect.promise(() => Bun.file(tmp.extra.mark).text())).toBe("ok") + }), + ), + ) - await load(tmp.path) - expect(await Bun.file(tmp.extra.mark).text()).toBe("ok") - }) - - test("continues loading plugins when plugin module has invalid export", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { + it.live("continues loading plugins when plugin module has invalid export", () => + withTmp( + async (dir) => { const file = pathToFileURL(path.join(dir, "invalid.ts")).href const ok = pathToFileURL(path.join(dir, "ok.ts")).href const mark = path.join(dir, "ok.txt") @@ -687,15 +741,17 @@ describe("plugin.loader.shared", () => { return { mark } }, - }) + (tmp) => + Effect.gen(function* () { + yield* load(tmp.path) + expect(yield* Effect.promise(() => Bun.file(tmp.extra.mark).text())).toBe("ok") + }), + ), + ) - await load(tmp.path) - expect(await Bun.file(tmp.extra.mark).text()).toBe("ok") - }) - - test("continues loading plugins when plugin import fails", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { + it.live("continues loading plugins when plugin import fails", () => + withTmp( + async (dir) => { const missing = pathToFileURL(path.join(dir, "missing-plugin.ts")).href const ok = pathToFileURL(path.join(dir, "ok.ts")).href const mark = path.join(dir, "ok.txt") @@ -716,15 +772,17 @@ describe("plugin.loader.shared", () => { return { mark } }, - }) + (tmp) => + Effect.gen(function* () { + yield* load(tmp.path) + expect(yield* Effect.promise(() => Bun.file(tmp.extra.mark).text())).toBe("ok") + }), + ), + ) - await load(tmp.path) - expect(await Bun.file(tmp.extra.mark).text()).toBe("ok") - }) - - test("loads object plugin via plugin.server", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { + it.live("loads object plugin via plugin.server", () => + withTmp( + async (dir) => { const file = path.join(dir, "object-plugin.ts") const mark = path.join(dir, "object-called.txt") await Bun.write( @@ -749,15 +807,17 @@ describe("plugin.loader.shared", () => { return { mark } }, - }) + (tmp) => + Effect.gen(function* () { + yield* load(tmp.path) + expect(yield* Effect.promise(() => fs.readFile(tmp.extra.mark, "utf8"))).toBe("called") + }), + ), + ) - await load(tmp.path) - expect(await fs.readFile(tmp.extra.mark, "utf8")).toBe("called") - }) - - test("passes tuple plugin options into server plugin", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { + it.live("passes tuple plugin options into server plugin", () => + withTmp( + async (dir) => { const file = path.join(dir, "options-plugin.ts") const mark = path.join(dir, "options.json") await Bun.write( @@ -782,18 +842,20 @@ describe("plugin.loader.shared", () => { return { mark } }, - }) + (tmp) => + Effect.gen(function* () { + yield* load(tmp.path) + expect(yield* Effect.promise(() => Filesystem.readJson<{ source: string; enabled: boolean }>(tmp.extra.mark))).toEqual({ + source: "tuple", + enabled: true, + }) + }), + ), + ) - await load(tmp.path) - expect(await Filesystem.readJson<{ source: string; enabled: boolean }>(tmp.extra.mark)).toEqual({ - source: "tuple", - enabled: true, - }) - }) - - test("initializes server plugins in config order", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { + it.live("initializes server plugins in config order", () => + withTmp( + async (dir) => { const a = path.join(dir, "a-plugin.ts") const b = path.join(dir, "b-plugin.ts") const marker = path.join(dir, "server-order.txt") @@ -833,16 +895,18 @@ export default { return { marker } }, - }) + (tmp) => + Effect.gen(function* () { + yield* load(tmp.path) + const lines = (yield* Effect.promise(() => fs.readFile(tmp.extra.marker, "utf8"))).trim().split("\n") + expect(lines).toEqual(["a-start", "a-end", "b"]) + }), + ), + ) - await load(tmp.path) - const lines = (await fs.readFile(tmp.extra.marker, "utf8")).trim().split("\n") - expect(lines).toEqual(["a-start", "a-end", "b"]) - }) - - test("skips external plugins in pure mode", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { + it.live("skips external plugins in pure mode", () => + withTmp( + async (dir) => { const file = path.join(dir, "plugin.ts") const mark = path.join(dir, "called.txt") await Bun.write( @@ -866,30 +930,34 @@ export default { return { mark } }, - }) + (tmp) => + Effect.gen(function* () { + const pure = process.env.OPENCODE_PURE + process.env.OPENCODE_PURE = "1" - const pure = process.env.OPENCODE_PURE - process.env.OPENCODE_PURE = "1" + try { + yield* load(tmp.path) + const called = yield* Effect.promise(() => + fs + .readFile(tmp.extra.mark, "utf8") + .then(() => true) + .catch(() => false), + ) + expect(called).toBe(false) + } finally { + if (pure === undefined) { + delete process.env.OPENCODE_PURE + } else { + process.env.OPENCODE_PURE = pure + } + } + }), + ), + ) - try { - await load(tmp.path) - const called = await fs - .readFile(tmp.extra.mark, "utf8") - .then(() => true) - .catch(() => false) - expect(called).toBe(false) - } finally { - if (pure === undefined) { - delete process.env.OPENCODE_PURE - } else { - process.env.OPENCODE_PURE = pure - } - } - }) - - test("reads oc-themes from package manifest", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { + it.live("reads oc-themes from package manifest", () => + withTmp( + async (dir) => { const mod = path.join(dir, "mod") await fs.mkdir(path.join(mod, "themes"), { recursive: true }) await Bun.write( @@ -907,25 +975,27 @@ export default { return { mod } }, - }) + (tmp) => + Effect.gen(function* () { + const file = path.join(tmp.extra.mod, "package.json") + const json = yield* Effect.promise(() => Filesystem.readJson>(file)) + const list = readPackageThemes("acme-plugin", { + dir: tmp.extra.mod, + pkg: file, + json, + }) - const file = path.join(tmp.extra.mod, "package.json") - const json = await Filesystem.readJson>(file) - const list = readPackageThemes("acme-plugin", { - dir: tmp.extra.mod, - pkg: file, - json, - }) + expect(list).toEqual([ + Filesystem.resolve(path.join(tmp.extra.mod, "themes", "one.json")), + Filesystem.resolve(path.join(tmp.extra.mod, "themes", "two.json")), + ]) + }), + ), + ) - expect(list).toEqual([ - Filesystem.resolve(path.join(tmp.extra.mod, "themes", "one.json")), - Filesystem.resolve(path.join(tmp.extra.mod, "themes", "two.json")), - ]) - }) - - test("handles no-entrypoint tui packages via missing callback", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { + it.live("handles no-entrypoint tui packages via missing callback", () => + withTmp( + async (dir) => { const mod = path.join(dir, "mods", "acme-plugin") await fs.mkdir(path.join(mod, "themes"), { recursive: true }) await Bun.write( @@ -943,54 +1013,58 @@ export default { await Bun.write(path.join(mod, "themes", "night.json"), "{}\n") return { mod } }, - }) + (tmp) => + Effect.gen(function* () { + const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined }) + const missing: string[] = [] - const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined }) - const missing: string[] = [] + try { + const loaded = yield* Effect.promise(() => + PluginLoader.loadExternal({ + items: [ + { + spec: "acme-plugin@1.0.0", + scope: "local" as const, + source: tmp.path, + }, + ], + kind: "tui", + missing: async (item) => { + if (!item.pkg) return + const themes = readPackageThemes(item.spec, item.pkg) + if (!themes.length) return + return { + spec: item.spec, + target: item.target, + themes, + } + }, + report: { + missing(_candidate, _retry, message) { + missing.push(message) + }, + }, + }), + ) - try { - const loaded = await PluginLoader.loadExternal({ - items: [ - { - spec: "acme-plugin@1.0.0", - scope: "local" as const, - source: tmp.path, - }, - ], - kind: "tui", - missing: async (item) => { - if (!item.pkg) return - const themes = readPackageThemes(item.spec, item.pkg) - if (!themes.length) return - return { - spec: item.spec, - target: item.target, - themes, + expect(loaded).toEqual([ + { + spec: "acme-plugin@1.0.0", + target: tmp.extra.mod, + themes: [Filesystem.resolve(path.join(tmp.extra.mod, "themes", "night.json"))], + }, + ]) + expect(missing).toHaveLength(0) + } finally { + install.mockRestore() } - }, - report: { - missing(_candidate, _retry, message) { - missing.push(message) - }, - }, - }) + }), + ), + ) - expect(loaded).toEqual([ - { - spec: "acme-plugin@1.0.0", - target: tmp.extra.mod, - themes: [Filesystem.resolve(path.join(tmp.extra.mod, "themes", "night.json"))], - }, - ]) - expect(missing).toHaveLength(0) - } finally { - install.mockRestore() - } - }) - - test("passes package metadata for entrypoint tui plugins", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { + it.live("passes package metadata for entrypoint tui plugins", () => + withTmp( + async (dir) => { const mod = path.join(dir, "mods", "acme-plugin") await fs.mkdir(path.join(mod, "themes"), { recursive: true }) await Bun.write( @@ -1012,64 +1086,70 @@ export default { await Bun.write(path.join(mod, "themes", "night.json"), "{}\n") return { mod } }, - }) + (tmp) => + Effect.gen(function* () { + const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined }) - const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined }) + try { + const loaded = yield* Effect.promise(() => + PluginLoader.loadExternal({ + items: [ + { + spec: "acme-plugin@1.0.0", + scope: "local" as const, + source: tmp.path, + }, + ], + kind: "tui", + finish: async (item) => { + if (!item.pkg) return + return { + spec: item.spec, + themes: readPackageThemes(item.spec, item.pkg), + } + }, + }), + ) - try { - const loaded = await PluginLoader.loadExternal({ - items: [ - { - spec: "acme-plugin@1.0.0", - scope: "local" as const, - source: tmp.path, - }, - ], - kind: "tui", - finish: async (item) => { - if (!item.pkg) return - return { - spec: item.spec, - themes: readPackageThemes(item.spec, item.pkg), + expect(loaded).toEqual([ + { + spec: "acme-plugin@1.0.0", + themes: [Filesystem.resolve(path.join(tmp.extra.mod, "themes", "night.json"))], + }, + ]) + } finally { + install.mockRestore() } - }, - }) + }), + ), + ) - expect(loaded).toEqual([ - { - spec: "acme-plugin@1.0.0", - themes: [Filesystem.resolve(path.join(tmp.extra.mod, "themes", "night.json"))], - }, - ]) - } finally { - install.mockRestore() - } - }) - - test("rejects oc-themes path traversal", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { + it.live("rejects oc-themes path traversal", () => + withTmp( + async (dir) => { const mod = path.join(dir, "mod") await fs.mkdir(mod, { recursive: true }) const file = path.join(mod, "package.json") await Bun.write(file, JSON.stringify({ name: "acme", "oc-themes": ["../escape.json"] }, null, 2)) return { mod, file } }, - }) + (tmp) => + Effect.gen(function* () { + const json = yield* Effect.promise(() => Filesystem.readJson>(tmp.extra.file)) + expect(() => + readPackageThemes("acme", { + dir: tmp.extra.mod, + pkg: tmp.extra.file, + json, + }), + ).toThrow("outside plugin directory") + }), + ), + ) - const json = await Filesystem.readJson>(tmp.extra.file) - expect(() => - readPackageThemes("acme", { - dir: tmp.extra.mod, - pkg: tmp.extra.file, - json, - }), - ).toThrow("outside plugin directory") - }) - - test("retries failed file plugins once after wait and keeps order", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { + it.live("retries failed file plugins once after wait and keeps order", () => + withTmp( + async (dir) => { const a = path.join(dir, "a") const b = path.join(dir, "b") const aSpec = pathToFileURL(a).href @@ -1078,110 +1158,122 @@ export default { await fs.mkdir(b, { recursive: true }) return { a, b, aSpec, bSpec } }, - }) + (tmp) => + Effect.gen(function* () { + let wait = 0 + const calls: Array<[string, boolean]> = [] - let wait = 0 - const calls: Array<[string, boolean]> = [] + const loaded = yield* Effect.promise(() => + PluginLoader.loadExternal({ + items: [tmp.extra.aSpec, tmp.extra.bSpec].map((spec) => ({ + spec, + scope: "local" as const, + source: tmp.path, + })), + kind: "tui", + wait: async () => { + wait += 1 + await Bun.write(path.join(tmp.extra.a, "index.ts"), "export default {}\n") + await Bun.write(path.join(tmp.extra.b, "index.ts"), "export default {}\n") + }, + report: { + start(candidate, retry) { + calls.push([candidate.plan.spec, retry]) + }, + }, + }), + ) - const loaded = await PluginLoader.loadExternal({ - items: [tmp.extra.aSpec, tmp.extra.bSpec].map((spec) => ({ - spec, - scope: "local" as const, - source: tmp.path, - })), - kind: "tui", - wait: async () => { - wait += 1 - await Bun.write(path.join(tmp.extra.a, "index.ts"), "export default {}\n") - await Bun.write(path.join(tmp.extra.b, "index.ts"), "export default {}\n") - }, - report: { - start(candidate, retry) { - calls.push([candidate.plan.spec, retry]) - }, - }, - }) + expect(wait).toBe(1) + expect(calls).toEqual([ + [tmp.extra.aSpec, false], + [tmp.extra.bSpec, false], + [tmp.extra.aSpec, true], + [tmp.extra.bSpec, true], + ]) + expect(loaded.map((item) => item.spec)).toEqual([tmp.extra.aSpec, tmp.extra.bSpec]) + }), + ), + ) - expect(wait).toBe(1) - expect(calls).toEqual([ - [tmp.extra.aSpec, false], - [tmp.extra.bSpec, false], - [tmp.extra.aSpec, true], - [tmp.extra.bSpec, true], - ]) - expect(loaded.map((item) => item.spec)).toEqual([tmp.extra.aSpec, tmp.extra.bSpec]) - }) - - test("retries file plugins when finish returns undefined", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { + it.live("retries file plugins when finish returns undefined", () => + withTmp( + async (dir) => { const file = path.join(dir, "plugin.ts") const spec = pathToFileURL(file).href await Bun.write(file, "export default {}\n") return { spec } }, - }) + (tmp) => + Effect.gen(function* () { + let wait = 0 + let count = 0 - let wait = 0 - let count = 0 + const loaded = yield* Effect.promise(() => + PluginLoader.loadExternal({ + items: [ + { + spec: tmp.extra.spec, + scope: "local" as const, + source: tmp.path, + }, + ], + kind: "tui", + wait: async () => { + wait += 1 + }, + finish: async (load, _item, retry) => { + count += 1 + if (!retry) return + return { + retry, + spec: load.spec, + } + }, + }), + ) - const loaded = await PluginLoader.loadExternal({ - items: [ - { - spec: tmp.extra.spec, - scope: "local" as const, - source: tmp.path, - }, - ], - kind: "tui", - wait: async () => { - wait += 1 - }, - finish: async (load, _item, retry) => { - count += 1 - if (!retry) return - return { - retry, - spec: load.spec, - } - }, - }) + expect(wait).toBe(1) + expect(count).toBe(2) + expect(loaded).toEqual([{ retry: true, spec: tmp.extra.spec }]) + }), + ), + ) - expect(wait).toBe(1) - expect(count).toBe(2) - expect(loaded).toEqual([{ retry: true, spec: tmp.extra.spec }]) - }) + it.live("does not wait or retry npm plugin failures", () => + Effect.gen(function* () { + const install = spyOn(Npm, "add").mockRejectedValue(new Error("boom")) + let wait = 0 + const errors: Array<[string, boolean]> = [] - test("does not wait or retry npm plugin failures", async () => { - const install = spyOn(Npm, "add").mockRejectedValue(new Error("boom")) - let wait = 0 - const errors: Array<[string, boolean]> = [] + try { + const loaded = yield* Effect.promise(() => + PluginLoader.loadExternal({ + items: [ + { + spec: "acme-plugin@1.0.0", + scope: "local" as const, + source: "test", + }, + ], + kind: "tui", + wait: async () => { + wait += 1 + }, + report: { + error(_candidate, retry, stage) { + errors.push([stage, retry]) + }, + }, + }), + ) - try { - const loaded = await PluginLoader.loadExternal({ - items: [ - { - spec: "acme-plugin@1.0.0", - scope: "local" as const, - source: "test", - }, - ], - kind: "tui", - wait: async () => { - wait += 1 - }, - report: { - error(_candidate, retry, stage) { - errors.push([stage, retry]) - }, - }, - }) - - expect(loaded).toEqual([]) - expect(wait).toBe(0) - expect(errors).toEqual([["install", false]]) - } finally { - install.mockRestore() - } - }) + expect(loaded).toEqual([]) + expect(wait).toBe(0) + expect(errors).toEqual([["install", false]]) + } finally { + install.mockRestore() + } + }), + ) }) From 1d4613006aa2dc25ec2a8050c87763b74f27c46d Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 12 May 2026 15:41:46 -0400 Subject: [PATCH 50/70] test(project): migrate instance tests to Effect runner (#27130) --- .../opencode/test/project/instance.test.ts | 61 +++++++++---------- 1 file changed, 29 insertions(+), 32 deletions(-) diff --git a/packages/opencode/test/project/instance.test.ts b/packages/opencode/test/project/instance.test.ts index 99b0f0666b..5ec64754d4 100644 --- a/packages/opencode/test/project/instance.test.ts +++ b/packages/opencode/test/project/instance.test.ts @@ -1,13 +1,12 @@ import { afterEach, describe, expect } from "bun:test" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" -import { Effect, Fiber, Layer } from "effect" +import { Deferred, Effect, Fiber, Layer } from "effect" import { InstanceRef } from "../../src/effect/instance-ref" import { registerDisposer } from "../../src/effect/instance-registry" import { InstanceBootstrap } from "../../src/project/bootstrap-service" import { Instance } from "../../src/project/instance" -import { WithInstance } from "../../src/project/with-instance" import { InstanceStore } from "../../src/project/instance-store" -import { disposeAllInstances, tmpdirScoped } from "../fixture/fixture" +import { disposeAllInstances, TestInstance, tmpdirScoped } from "../fixture/fixture" import { testEffect } from "../lib/effect" let bootstrapRun: Effect.Effect = Effect.void @@ -75,18 +74,18 @@ describe("InstanceStore", () => { Effect.gen(function* () { const dir = yield* tmpdirScoped({ git: true }) const store = yield* InstanceStore.Service - const started = Promise.withResolvers() - const release = Promise.withResolvers() + const started = yield* Deferred.make() + const release = yield* Deferred.make() let initialized = 0 - bootstrapRun = Effect.promise(async () => { + bootstrapRun = Effect.gen(function* () { initialized++ - started.resolve() - await release.promise + yield* Deferred.succeed(started, undefined) + yield* Deferred.await(release) }) const first = yield* store.load({ directory: dir }).pipe(Effect.forkScoped) - yield* Effect.promise(() => started.promise) + yield* Deferred.await(started) bootstrapRun = Effect.sync(() => { initialized++ @@ -94,7 +93,7 @@ describe("InstanceStore", () => { const second = yield* store.load({ directory: dir }).pipe(Effect.forkScoped) expect(initialized).toBe(1) - release.resolve() + yield* Deferred.succeed(release, undefined) const [firstCtx, secondCtx] = yield* Effect.all([Fiber.join(first), Fiber.join(second)]) expect(secondCtx).toBe(firstCtx) @@ -147,8 +146,8 @@ describe("InstanceStore", () => { Effect.gen(function* () { const dir = yield* tmpdirScoped({ git: true }) const store = yield* InstanceStore.Service - const reloading = Promise.withResolvers() - const releaseReload = Promise.withResolvers() + const reloading = yield* Deferred.make() + const releaseReload = yield* Deferred.make() const disposed: Array = [] const off = registerDisposer(async (directory) => { disposed.push(directory) @@ -156,15 +155,15 @@ describe("InstanceStore", () => { yield* Effect.addFinalizer(() => Effect.sync(off)) const first = yield* store.load({ directory: dir }) - bootstrapRun = Effect.promise(async () => { - reloading.resolve() - await releaseReload.promise + bootstrapRun = Effect.gen(function* () { + yield* Deferred.succeed(reloading, undefined) + yield* Deferred.await(releaseReload) }) const reload = yield* store.reload({ directory: dir }).pipe(Effect.forkScoped) - yield* Effect.promise(() => reloading.promise) + yield* Deferred.await(reloading) const staleDispose = yield* store.dispose(first).pipe(Effect.forkScoped) - releaseReload.resolve() + yield* Deferred.succeed(releaseReload, undefined) const second = yield* Fiber.join(reload) yield* Fiber.join(staleDispose) @@ -178,23 +177,23 @@ describe("InstanceStore", () => { Effect.gen(function* () { const dir = yield* tmpdirScoped({ git: true }) const store = yield* InstanceStore.Service - const disposing = Promise.withResolvers() - const releaseDispose = Promise.withResolvers() + const disposing = yield* Deferred.make() + const releaseDispose = yield* Deferred.make() const disposed: Array = [] const off = registerDisposer(async (directory) => { disposed.push(directory) - disposing.resolve() - await releaseDispose.promise + Deferred.doneUnsafe(disposing, Effect.void) + await Effect.runPromise(Deferred.await(releaseDispose)) }) yield* Effect.addFinalizer(() => Effect.sync(off)) yield* store.load({ directory: dir }) const first = yield* store.disposeAll().pipe(Effect.forkScoped) - yield* Effect.promise(() => disposing.promise) + yield* Deferred.await(disposing) const second = yield* store.disposeAll().pipe(Effect.forkScoped) expect(disposed).toEqual([dir]) - releaseDispose.resolve() + yield* Deferred.succeed(releaseDispose, undefined) yield* Effect.all([Fiber.join(first), Fiber.join(second)]) expect(disposed).toEqual([dir]) }), @@ -221,19 +220,17 @@ describe("InstanceStore", () => { }), ) - it.live("provides legacy Promise callers with instance ALS", () => + it.instance("provides legacy Promise callers with instance ALS", () => Effect.gen(function* () { - const dir = yield* tmpdirScoped({ git: true }) + const test = yield* TestInstance + const ctx = yield* InstanceRef + if (!ctx) throw new Error("InstanceRef not provided") - const directory = yield* Effect.promise(() => - WithInstance.provide({ - directory: dir, - fn: () => Instance.directory, - }), - ) + const directory = yield* Effect.promise(() => Promise.resolve(Instance.restore(ctx, () => Instance.directory))) - expect(directory).toBe(dir) + expect(directory).toBe(test.directory) expect(() => Instance.current).toThrow() }), + { git: true }, ) }) From e46ab34d27ccbe7afe108331d6bfed59dcc6a14e Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Tue, 12 May 2026 19:44:26 +0000 Subject: [PATCH 51/70] chore: generate --- packages/opencode/src/acp/agent.ts | 3 ++- .../test/plugin/loader-shared.test.ts | 4 +++- .../opencode/test/project/instance.test.ts | 20 ++++++++++--------- .../opencode/test/skill/discovery.test.ts | 4 +++- 4 files changed, 19 insertions(+), 12 deletions(-) diff --git a/packages/opencode/src/acp/agent.ts b/packages/opencode/src/acp/agent.ts index a8eacf835c..aa123d5991 100644 --- a/packages/opencode/src/acp/agent.ts +++ b/packages/opencode/src/acp/agent.ts @@ -1328,7 +1328,8 @@ export class Agent implements ACPAgent { if (!current) { this.sessionManager.setModel(session.id, model) } - const agent = session.modeId ?? (await AppRuntime.runPromise(AgentModule.Service.use((svc) => svc.defaultInfo()))).name + const agent = + session.modeId ?? (await AppRuntime.runPromise(AgentModule.Service.use((svc) => svc.defaultInfo()))).name const parts: Array< | { type: "text"; text: string; synthetic?: boolean; ignored?: boolean } diff --git a/packages/opencode/test/plugin/loader-shared.test.ts b/packages/opencode/test/plugin/loader-shared.test.ts index ffdb3291b4..1b6372390e 100644 --- a/packages/opencode/test/plugin/loader-shared.test.ts +++ b/packages/opencode/test/plugin/loader-shared.test.ts @@ -845,7 +845,9 @@ describe("plugin.loader.shared", () => { (tmp) => Effect.gen(function* () { yield* load(tmp.path) - expect(yield* Effect.promise(() => Filesystem.readJson<{ source: string; enabled: boolean }>(tmp.extra.mark))).toEqual({ + expect( + yield* Effect.promise(() => Filesystem.readJson<{ source: string; enabled: boolean }>(tmp.extra.mark)), + ).toEqual({ source: "tuple", enabled: true, }) diff --git a/packages/opencode/test/project/instance.test.ts b/packages/opencode/test/project/instance.test.ts index 5ec64754d4..dc87fde45e 100644 --- a/packages/opencode/test/project/instance.test.ts +++ b/packages/opencode/test/project/instance.test.ts @@ -220,17 +220,19 @@ describe("InstanceStore", () => { }), ) - it.instance("provides legacy Promise callers with instance ALS", () => - Effect.gen(function* () { - const test = yield* TestInstance - const ctx = yield* InstanceRef - if (!ctx) throw new Error("InstanceRef not provided") + it.instance( + "provides legacy Promise callers with instance ALS", + () => + Effect.gen(function* () { + const test = yield* TestInstance + const ctx = yield* InstanceRef + if (!ctx) throw new Error("InstanceRef not provided") - const directory = yield* Effect.promise(() => Promise.resolve(Instance.restore(ctx, () => Instance.directory))) + const directory = yield* Effect.promise(() => Promise.resolve(Instance.restore(ctx, () => Instance.directory))) - expect(directory).toBe(test.directory) - expect(() => Instance.current).toThrow() - }), + expect(directory).toBe(test.directory) + expect(() => Instance.current).toThrow() + }), { git: true }, ) }) diff --git a/packages/opencode/test/skill/discovery.test.ts b/packages/opencode/test/skill/discovery.test.ts index 43018d9a4f..0b07d4df0f 100644 --- a/packages/opencode/test/skill/discovery.test.ts +++ b/packages/opencode/test/skill/discovery.test.ts @@ -101,7 +101,9 @@ describe("Discovery.pull", () => { const refs = path.join(agentsSdk, "references") expect(yield* Effect.promise(() => Filesystem.exists(path.join(agentsSdk, "SKILL.md")))).toBe(true) // agents-sdk has reference files per the index - const refDir = yield* Effect.promise(() => Array.fromAsync(new Bun.Glob("**/*.md").scan({ cwd: refs, onlyFiles: true }))) + const refDir = yield* Effect.promise(() => + Array.fromAsync(new Bun.Glob("**/*.md").scan({ cwd: refs, onlyFiles: true })), + ) expect(refDir.length).toBeGreaterThan(0) } }), From c5849e56cc5280427be59c2252429b6c9938951b Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 12 May 2026 15:47:17 -0400 Subject: [PATCH 52/70] test(project): migrate project tests to Effect runner (#27134) --- .../opencode/test/project/project.test.ts | 802 ++++++++++-------- 1 file changed, 427 insertions(+), 375 deletions(-) diff --git a/packages/opencode/test/project/project.test.ts b/packages/opencode/test/project/project.test.ts index 9906b31645..9ed1e60bbf 100644 --- a/packages/opencode/test/project/project.test.ts +++ b/packages/opencode/test/project/project.test.ts @@ -4,26 +4,41 @@ import { Project } from "@/project/project" import * as Log from "@opencode-ai/core/util/log" import { $ } from "bun" import path from "path" -import { tmpdir } from "../fixture/fixture" +import { tmpdirScoped } from "../fixture/fixture" import { GlobalBus } from "../../src/bus/global" import { ProjectID } from "../../src/project/schema" -import { Effect, Layer, Stream } from "effect" +import { Cause, Effect, Exit, Layer, Stream } from "effect" import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" import { NodePath } from "@effect/platform-node" import { AppFileSystem } from "@opencode-ai/core/filesystem" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" +import { testEffect } from "../lib/effect" void Log.init({ print: false }) const encoder = new TextEncoder() -function run(fn: (svc: Project.Interface) => Effect.Effect, layer = Project.defaultLayer) { - return Effect.runPromise( - Effect.gen(function* () { - const svc = yield* Project.Service - return yield* fn(svc) - }).pipe(Effect.provide(layer)), - ) +const layer = Layer.mergeAll(Project.defaultLayer, CrossSpawnSpawner.defaultLayer) +const it = testEffect(layer) + +function run(fn: (svc: Project.Interface) => Effect.Effect) { + return Effect.gen(function* () { + const svc = yield* Project.Service + return yield* fn(svc) + }) +} + +function gitTmpdir() { + return Effect.gen(function* () { + const tmp = yield* tmpdirScoped() + yield* Effect.promise(() => $`git init`.cwd(tmp).quiet()) + yield* Effect.promise(() => $`git config core.fsmonitor false`.cwd(tmp).quiet()) + yield* Effect.promise(() => $`git config commit.gpgsign false`.cwd(tmp).quiet()) + yield* Effect.promise(() => $`git config user.email "test@opencode.test"`.cwd(tmp).quiet()) + yield* Effect.promise(() => $`git config user.name "Test"`.cwd(tmp).quiet()) + yield* Effect.promise(() => $`git commit --allow-empty -m ${`root commit ${tmp}`}`.cwd(tmp).quiet()) + return tmp + }) } /** @@ -70,400 +85,430 @@ function projectLayerWithFailure(failArg: string) { ) } +const failureIt = (failArg: string) => testEffect(Layer.mergeAll(projectLayerWithFailure(failArg), CrossSpawnSpawner.defaultLayer)) + describe("Project.fromDirectory", () => { - test("should handle git repository with no commits", async () => { - await using tmp = await tmpdir() - await $`git init`.cwd(tmp.path).quiet() + it.live("should handle git repository with no commits", () => + Effect.gen(function* () { + const tmp = yield* tmpdirScoped() + yield* Effect.promise(() => $`git init`.cwd(tmp).quiet()) - const { project } = await run((svc) => svc.fromDirectory(tmp.path)) + const { project } = yield* run((svc) => svc.fromDirectory(tmp)) - expect(project).toBeDefined() - expect(project.id).toBe(ProjectID.global) - expect(project.vcs).toBe("git") - expect(project.worktree).toBe(tmp.path) + expect(project).toBeDefined() + expect(project.id).toBe(ProjectID.global) + expect(project.vcs).toBe("git") + expect(project.worktree).toBe(tmp) - const opencodeFile = path.join(tmp.path, ".git", "opencode") - expect(await Bun.file(opencodeFile).exists()).toBe(false) - }) + const opencodeFile = path.join(tmp, ".git", "opencode") + expect(yield* Effect.promise(() => Bun.file(opencodeFile).exists())).toBe(false) + }), + ) - test("should handle git repository with commits", async () => { - await using tmp = await tmpdir({ git: true }) + it.live("should handle git repository with commits", () => + Effect.gen(function* () { + const tmp = yield* gitTmpdir() - const { project } = await run((svc) => svc.fromDirectory(tmp.path)) + const { project } = yield* run((svc) => svc.fromDirectory(tmp)) - expect(project).toBeDefined() - expect(project.id).not.toBe(ProjectID.global) - expect(project.vcs).toBe("git") - expect(project.worktree).toBe(tmp.path) + expect(project).toBeDefined() + expect(project.id).not.toBe(ProjectID.global) + expect(project.vcs).toBe("git") + expect(project.worktree).toBe(tmp) - const opencodeFile = path.join(tmp.path, ".git", "opencode") - expect(await Bun.file(opencodeFile).exists()).toBe(true) - }) + const opencodeFile = path.join(tmp, ".git", "opencode") + expect(yield* Effect.promise(() => Bun.file(opencodeFile).exists())).toBe(true) + }), + ) - test("returns global for non-git directory", async () => { - await using tmp = await tmpdir() - const { project } = await run((svc) => svc.fromDirectory(tmp.path)) - expect(project.id).toBe(ProjectID.global) - }) + it.live("returns global for non-git directory", () => + Effect.gen(function* () { + const tmp = yield* tmpdirScoped() + const { project } = yield* run((svc) => svc.fromDirectory(tmp)) + expect(project.id).toBe(ProjectID.global) + }), + ) - test("derives stable project ID from root commit", async () => { - await using tmp = await tmpdir({ git: true }) - const { project: a } = await run((svc) => svc.fromDirectory(tmp.path)) - const { project: b } = await run((svc) => svc.fromDirectory(tmp.path)) - expect(b.id).toBe(a.id) - }) + it.live("derives stable project ID from root commit", () => + Effect.gen(function* () { + const tmp = yield* gitTmpdir() + const { project: a } = yield* run((svc) => svc.fromDirectory(tmp)) + const { project: b } = yield* run((svc) => svc.fromDirectory(tmp)) + expect(b.id).toBe(a.id) + }), + ) }) describe("Project.fromDirectory git failure paths", () => { - test("keeps vcs when rev-list exits non-zero (no commits)", async () => { - await using tmp = await tmpdir() - await $`git init`.cwd(tmp.path).quiet() + it.live("keeps vcs when rev-list exits non-zero (no commits)", () => + Effect.gen(function* () { + const tmp = yield* tmpdirScoped() + yield* Effect.promise(() => $`git init`.cwd(tmp).quiet()) - // rev-list fails because HEAD doesn't exist yet — this is the natural scenario - const { project } = await run((svc) => svc.fromDirectory(tmp.path)) - expect(project.vcs).toBe("git") - expect(project.id).toBe(ProjectID.global) - expect(project.worktree).toBe(tmp.path) - }) + // rev-list fails because HEAD doesn't exist yet: this is the natural scenario. + const { project } = yield* run((svc) => svc.fromDirectory(tmp)) + expect(project.vcs).toBe("git") + expect(project.id).toBe(ProjectID.global) + expect(project.worktree).toBe(tmp) + }), + ) - test("handles show-toplevel failure gracefully", async () => { - await using tmp = await tmpdir({ git: true }) - const layer = projectLayerWithFailure("--show-toplevel") + failureIt("--show-toplevel").live("handles show-toplevel failure gracefully", () => + Effect.gen(function* () { + const tmp = yield* gitTmpdir() - const { project, sandbox } = await run((svc) => svc.fromDirectory(tmp.path), layer) - expect(project.worktree).toBe(tmp.path) - expect(sandbox).toBe(tmp.path) - }) + const { project, sandbox } = yield* run((svc) => svc.fromDirectory(tmp)) + expect(project.worktree).toBe(tmp) + expect(sandbox).toBe(tmp) + }), + ) - test("handles git-common-dir failure gracefully", async () => { - await using tmp = await tmpdir({ git: true }) - const layer = projectLayerWithFailure("--git-common-dir") + failureIt("--git-common-dir").live("handles git-common-dir failure gracefully", () => + Effect.gen(function* () { + const tmp = yield* gitTmpdir() - const { project, sandbox } = await run((svc) => svc.fromDirectory(tmp.path), layer) - expect(project.worktree).toBe(tmp.path) - expect(sandbox).toBe(tmp.path) - }) + const { project, sandbox } = yield* run((svc) => svc.fromDirectory(tmp)) + expect(project.worktree).toBe(tmp) + expect(sandbox).toBe(tmp) + }), + ) }) describe("Project.fromDirectory with worktrees", () => { - test("should set worktree to root when called from root", async () => { - await using tmp = await tmpdir({ git: true }) + it.live("should set worktree to root when called from root", () => + Effect.gen(function* () { + const tmp = yield* gitTmpdir() - const { project, sandbox } = await run((svc) => svc.fromDirectory(tmp.path)) + const { project, sandbox } = yield* run((svc) => svc.fromDirectory(tmp)) - expect(project.worktree).toBe(tmp.path) - expect(sandbox).toBe(tmp.path) - expect(project.sandboxes).not.toContain(tmp.path) - }) + expect(project.worktree).toBe(tmp) + expect(sandbox).toBe(tmp) + expect(project.sandboxes).not.toContain(tmp) + }), + ) - test("should set worktree to root when called from a worktree", async () => { - await using tmp = await tmpdir({ git: true }) + it.live("should set worktree to root when called from a worktree", () => + Effect.gen(function* () { + const tmp = yield* gitTmpdir() - const worktreePath = path.join(tmp.path, "..", path.basename(tmp.path) + "-worktree") - try { - await $`git worktree add ${worktreePath} -b test-branch-${Date.now()}`.cwd(tmp.path).quiet() + const worktreePath = path.join(tmp, "..", path.basename(tmp) + "-worktree") + yield* Effect.addFinalizer(() => Effect.promise(() => $`git worktree remove ${worktreePath}`.cwd(tmp).quiet().catch(() => {}))) + yield* Effect.promise(() => $`git worktree add ${worktreePath} -b test-branch-${Date.now()}`.cwd(tmp).quiet()) - const { project, sandbox } = await run((svc) => svc.fromDirectory(worktreePath)) + const { project, sandbox } = yield* run((svc) => svc.fromDirectory(worktreePath)) - expect(project.worktree).toBe(tmp.path) + expect(project.worktree).toBe(tmp) expect(sandbox).toBe(worktreePath) expect(project.sandboxes).toContain(worktreePath) - expect(project.sandboxes).not.toContain(tmp.path) - } finally { - await $`git worktree remove ${worktreePath}` - .cwd(tmp.path) - .quiet() - .catch(() => {}) - } - }) + expect(project.sandboxes).not.toContain(tmp) + }), + ) - test("worktree should share project ID with main repo", async () => { - await using tmp = await tmpdir({ git: true }) + it.live("worktree should share project ID with main repo", () => + Effect.gen(function* () { + const tmp = yield* gitTmpdir() - const { project: main } = await run((svc) => svc.fromDirectory(tmp.path)) + const { project: main } = yield* run((svc) => svc.fromDirectory(tmp)) - const worktreePath = path.join(tmp.path, "..", path.basename(tmp.path) + "-wt-shared") - try { - await $`git worktree add ${worktreePath} -b shared-${Date.now()}`.cwd(tmp.path).quiet() + const worktreePath = path.join(tmp, "..", path.basename(tmp) + "-wt-shared") + yield* Effect.addFinalizer(() => Effect.promise(() => $`git worktree remove ${worktreePath}`.cwd(tmp).quiet().catch(() => {}))) + yield* Effect.promise(() => $`git worktree add ${worktreePath} -b shared-${Date.now()}`.cwd(tmp).quiet()) - const { project: wt } = await run((svc) => svc.fromDirectory(worktreePath)) + const { project: wt } = yield* run((svc) => svc.fromDirectory(worktreePath)) expect(wt.id).toBe(main.id) // Cache should live in the common .git dir, not the worktree's .git file - const cache = path.join(tmp.path, ".git", "opencode") - const exists = await Bun.file(cache).exists() + const cache = path.join(tmp, ".git", "opencode") + const exists = yield* Effect.promise(() => Bun.file(cache).exists()) expect(exists).toBe(true) - } finally { - await $`git worktree remove ${worktreePath}` - .cwd(tmp.path) - .quiet() - .catch(() => {}) - } - }) + }), + ) - test("separate clones of the same repo should share project ID", async () => { - await using tmp = await tmpdir({ git: true }) + it.live("separate clones of the same repo should share project ID", () => + Effect.gen(function* () { + const tmp = yield* gitTmpdir() // Create a bare remote, push, then clone into a second directory - const bare = tmp.path + "-bare" - const clone = tmp.path + "-clone" - try { - await $`git clone --bare ${tmp.path} ${bare}`.quiet() - await $`git clone ${bare} ${clone}`.quiet() + const bare = tmp + "-bare" + const clone = tmp + "-clone" + yield* Effect.addFinalizer(() => Effect.promise(() => $`rm -rf ${bare} ${clone}`.quiet().nothrow()).pipe(Effect.ignore)) + yield* Effect.promise(() => $`git clone --bare ${tmp} ${bare}`.quiet()) + yield* Effect.promise(() => $`git clone ${bare} ${clone}`.quiet()) - const { project: a } = await run((svc) => svc.fromDirectory(tmp.path)) - const { project: b } = await run((svc) => svc.fromDirectory(clone)) + const { project: a } = yield* run((svc) => svc.fromDirectory(tmp)) + const { project: b } = yield* run((svc) => svc.fromDirectory(clone)) expect(b.id).toBe(a.id) - } finally { - await $`rm -rf ${bare} ${clone}`.quiet().nothrow() - } - }) + }), + ) - test("should accumulate multiple worktrees in sandboxes", async () => { - await using tmp = await tmpdir({ git: true }) + it.live("should accumulate multiple worktrees in sandboxes", () => + Effect.gen(function* () { + const tmp = yield* gitTmpdir() - const worktree1 = path.join(tmp.path, "..", path.basename(tmp.path) + "-wt1") - const worktree2 = path.join(tmp.path, "..", path.basename(tmp.path) + "-wt2") - try { - await $`git worktree add ${worktree1} -b branch-${Date.now()}`.cwd(tmp.path).quiet() - await $`git worktree add ${worktree2} -b branch-${Date.now() + 1}`.cwd(tmp.path).quiet() + const worktree1 = path.join(tmp, "..", path.basename(tmp) + "-wt1") + const worktree2 = path.join(tmp, "..", path.basename(tmp) + "-wt2") + yield* Effect.addFinalizer(() => + Effect.gen(function* () { + yield* Effect.promise(() => $`git worktree remove ${worktree1}`.cwd(tmp).quiet().catch(() => {})) + yield* Effect.promise(() => $`git worktree remove ${worktree2}`.cwd(tmp).quiet().catch(() => {})) + }), + ) + yield* Effect.promise(() => $`git worktree add ${worktree1} -b branch-${Date.now()}`.cwd(tmp).quiet()) + yield* Effect.promise(() => $`git worktree add ${worktree2} -b branch-${Date.now() + 1}`.cwd(tmp).quiet()) - await run((svc) => svc.fromDirectory(worktree1)) - const { project } = await run((svc) => svc.fromDirectory(worktree2)) + yield* run((svc) => svc.fromDirectory(worktree1)) + const { project } = yield* run((svc) => svc.fromDirectory(worktree2)) - expect(project.worktree).toBe(tmp.path) + expect(project.worktree).toBe(tmp) expect(project.sandboxes).toContain(worktree1) expect(project.sandboxes).toContain(worktree2) - expect(project.sandboxes).not.toContain(tmp.path) - } finally { - await $`git worktree remove ${worktree1}` - .cwd(tmp.path) - .quiet() - .catch(() => {}) - await $`git worktree remove ${worktree2}` - .cwd(tmp.path) - .quiet() - .catch(() => {}) - } - }) + expect(project.sandboxes).not.toContain(tmp) + }), + ) }) describe("Project.discover", () => { - test("should discover favicon.png in root", async () => { - await using tmp = await tmpdir({ git: true }) - const { project } = await run((svc) => svc.fromDirectory(tmp.path)) + it.live("should discover favicon.png in root", () => + Effect.gen(function* () { + const tmp = yield* gitTmpdir() + const { project } = yield* run((svc) => svc.fromDirectory(tmp)) - const pngData = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]) - await Bun.write(path.join(tmp.path, "favicon.png"), pngData) + const pngData = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]) + yield* Effect.promise(() => Bun.write(path.join(tmp, "favicon.png"), pngData)) - await run((svc) => svc.discover(project)) + yield* run((svc) => svc.discover(project)) - const updated = Project.get(project.id) - expect(updated).toBeDefined() - expect(updated!.icon).toBeDefined() - expect(updated!.icon?.url).toStartWith("data:") - expect(updated!.icon?.url).toContain("base64") - expect(updated!.icon?.color).toBeUndefined() - }) + const updated = Project.get(project.id) + expect(updated).toBeDefined() + expect(updated!.icon).toBeDefined() + expect(updated!.icon?.url).toStartWith("data:") + expect(updated!.icon?.url).toContain("base64") + expect(updated!.icon?.color).toBeUndefined() + }), + ) - test("should not discover non-image files", async () => { - await using tmp = await tmpdir({ git: true }) - const { project } = await run((svc) => svc.fromDirectory(tmp.path)) + it.live("should not discover non-image files", () => + Effect.gen(function* () { + const tmp = yield* gitTmpdir() + const { project } = yield* run((svc) => svc.fromDirectory(tmp)) - await Bun.write(path.join(tmp.path, "favicon.txt"), "not an image") + yield* Effect.promise(() => Bun.write(path.join(tmp, "favicon.txt"), "not an image")) - await run((svc) => svc.discover(project)) + yield* run((svc) => svc.discover(project)) - const updated = Project.get(project.id) - expect(updated).toBeDefined() - expect(updated!.icon).toBeUndefined() - }) + const updated = Project.get(project.id) + expect(updated).toBeDefined() + expect(updated!.icon).toBeUndefined() + }), + ) - test("should not discover favicon when override is set", async () => { - await using tmp = await tmpdir({ git: true }) - const { project } = await run((svc) => svc.fromDirectory(tmp.path)) + it.live("should not discover favicon when override is set", () => + Effect.gen(function* () { + const tmp = yield* gitTmpdir() + const { project } = yield* run((svc) => svc.fromDirectory(tmp)) - await run((svc) => - svc.update({ - projectID: project.id, - icon: { override: "data:image/png;base64,override" }, - }), - ) + yield* run((svc) => + svc.update({ + projectID: project.id, + icon: { override: "data:image/png;base64,override" }, + }), + ) - const updatedProject = await run((svc) => svc.get(project.id)) - if (!updatedProject) throw new Error("Project not found") + const updatedProject = yield* run((svc) => svc.get(project.id)) + if (!updatedProject) throw new Error("Project not found") - const pngData = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]) - await Bun.write(path.join(tmp.path, "favicon.png"), pngData) + const pngData = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]) + yield* Effect.promise(() => Bun.write(path.join(tmp, "favicon.png"), pngData)) - await run((svc) => svc.discover(updatedProject)) + yield* run((svc) => svc.discover(updatedProject)) - const updated = Project.get(project.id) - expect(updated).toBeDefined() - expect(updated!.icon?.override).toBe("data:image/png;base64,override") - expect(updated!.icon?.url).toBeUndefined() - }) + const updated = Project.get(project.id) + expect(updated).toBeDefined() + expect(updated!.icon?.override).toBe("data:image/png;base64,override") + expect(updated!.icon?.url).toBeUndefined() + }), + ) }) describe("Project.update", () => { - test("should update name", async () => { - await using tmp = await tmpdir({ git: true }) - const { project } = await run((svc) => svc.fromDirectory(tmp.path)) + it.live("should update name", () => + Effect.gen(function* () { + const tmp = yield* gitTmpdir() + const { project } = yield* run((svc) => svc.fromDirectory(tmp)) - const updated = await run((svc) => - svc.update({ - projectID: project.id, - name: "New Project Name", - }), - ) + const updated = yield* run((svc) => + svc.update({ + projectID: project.id, + name: "New Project Name", + }), + ) - expect(updated.name).toBe("New Project Name") + expect(updated.name).toBe("New Project Name") - const fromDb = Project.get(project.id) - expect(fromDb?.name).toBe("New Project Name") - }) + const fromDb = Project.get(project.id) + expect(fromDb?.name).toBe("New Project Name") + }), + ) - test("should update icon url", async () => { - await using tmp = await tmpdir({ git: true }) - const { project } = await run((svc) => svc.fromDirectory(tmp.path)) + it.live("should update icon url", () => + Effect.gen(function* () { + const tmp = yield* gitTmpdir() + const { project } = yield* run((svc) => svc.fromDirectory(tmp)) - const updated = await run((svc) => - svc.update({ - projectID: project.id, - icon: { url: "https://example.com/icon.png" }, - }), - ) + const updated = yield* run((svc) => + svc.update({ + projectID: project.id, + icon: { url: "https://example.com/icon.png" }, + }), + ) - expect(updated.icon?.url).toBe("https://example.com/icon.png") + expect(updated.icon?.url).toBe("https://example.com/icon.png") - const fromDb = Project.get(project.id) - expect(fromDb?.icon?.url).toBe("https://example.com/icon.png") - }) + const fromDb = Project.get(project.id) + expect(fromDb?.icon?.url).toBe("https://example.com/icon.png") + }), + ) - test("should update icon color", async () => { - await using tmp = await tmpdir({ git: true }) - const { project } = await run((svc) => svc.fromDirectory(tmp.path)) + it.live("should update icon color", () => + Effect.gen(function* () { + const tmp = yield* gitTmpdir() + const { project } = yield* run((svc) => svc.fromDirectory(tmp)) - const updated = await run((svc) => - svc.update({ - projectID: project.id, - icon: { color: "#ff0000" }, - }), - ) + const updated = yield* run((svc) => + svc.update({ + projectID: project.id, + icon: { color: "#ff0000" }, + }), + ) - expect(updated.icon?.color).toBe("#ff0000") + expect(updated.icon?.color).toBe("#ff0000") - const fromDb = Project.get(project.id) - expect(fromDb?.icon?.color).toBe("#ff0000") - }) + const fromDb = Project.get(project.id) + expect(fromDb?.icon?.color).toBe("#ff0000") + }), + ) - test("should update icon override", async () => { - await using tmp = await tmpdir({ git: true }) - const { project } = await run((svc) => svc.fromDirectory(tmp.path)) + it.live("should update icon override", () => + Effect.gen(function* () { + const tmp = yield* gitTmpdir() + const { project } = yield* run((svc) => svc.fromDirectory(tmp)) - const updated = await run((svc) => - svc.update({ - projectID: project.id, - icon: { override: "data:image/png;base64,abc123" }, - }), - ) + const updated = yield* run((svc) => + svc.update({ + projectID: project.id, + icon: { override: "data:image/png;base64,abc123" }, + }), + ) - expect(updated.icon?.override).toBe("data:image/png;base64,abc123") + expect(updated.icon?.override).toBe("data:image/png;base64,abc123") - const fromDb = Project.get(project.id) - expect(fromDb?.icon?.override).toBe("data:image/png;base64,abc123") - }) + const fromDb = Project.get(project.id) + expect(fromDb?.icon?.override).toBe("data:image/png;base64,abc123") + }), + ) - test("should update commands", async () => { - await using tmp = await tmpdir({ git: true }) - const { project } = await run((svc) => svc.fromDirectory(tmp.path)) + it.live("should update commands", () => + Effect.gen(function* () { + const tmp = yield* gitTmpdir() + const { project } = yield* run((svc) => svc.fromDirectory(tmp)) - const updated = await run((svc) => - svc.update({ - projectID: project.id, - commands: { start: "npm run dev" }, - }), - ) + const updated = yield* run((svc) => + svc.update({ + projectID: project.id, + commands: { start: "npm run dev" }, + }), + ) - expect(updated.commands?.start).toBe("npm run dev") + expect(updated.commands?.start).toBe("npm run dev") - const fromDb = Project.get(project.id) - expect(fromDb?.commands?.start).toBe("npm run dev") - }) + const fromDb = Project.get(project.id) + expect(fromDb?.commands?.start).toBe("npm run dev") + }), + ) - test("should throw error when project not found", async () => { - await expect( - run((svc) => + it.live("should throw error when project not found", () => + Effect.gen(function* () { + const exit = yield* run((svc) => svc.update({ projectID: ProjectID.make("nonexistent-project-id"), name: "Should Fail", }), - ), - ).rejects.toThrow("Project not found: nonexistent-project-id") - }) + ).pipe(Effect.exit) + expect(Exit.isFailure(exit)).toBe(true) + if (Exit.isFailure(exit)) { + const error = Cause.squash(exit.cause) + expect(error instanceof Error ? error.message : String(error)).toContain("Project not found: nonexistent-project-id") + } + }), + ) - test("should emit GlobalBus event on update", async () => { - await using tmp = await tmpdir({ git: true }) - const { project } = await run((svc) => svc.fromDirectory(tmp.path)) + it.live("should emit GlobalBus event on update", () => + Effect.gen(function* () { + const tmp = yield* gitTmpdir() + const { project } = yield* run((svc) => svc.fromDirectory(tmp)) - let eventPayload: any = null - const on = (data: any) => { - eventPayload = data - } - GlobalBus.on("event", on) + let eventPayload: any = null + const on = (data: any) => { + eventPayload = data + } + GlobalBus.on("event", on) + yield* Effect.addFinalizer(() => Effect.sync(() => GlobalBus.off("event", on))) - try { - await run((svc) => svc.update({ projectID: project.id, name: "Updated Name" })) + yield* run((svc) => svc.update({ projectID: project.id, name: "Updated Name" })) expect(eventPayload).not.toBeNull() expect(eventPayload.payload.type).toBe("project.updated") expect(eventPayload.payload.properties.name).toBe("Updated Name") - } finally { - GlobalBus.off("event", on) - } - }) + }), + ) - test("should update multiple fields at once", async () => { - await using tmp = await tmpdir({ git: true }) - const { project } = await run((svc) => svc.fromDirectory(tmp.path)) + it.live("should update multiple fields at once", () => + Effect.gen(function* () { + const tmp = yield* gitTmpdir() + const { project } = yield* run((svc) => svc.fromDirectory(tmp)) - const updated = await run((svc) => - svc.update({ - projectID: project.id, - name: "Multi Update", - icon: { url: "https://example.com/favicon.ico", override: "data:image/png;base64,abc123", color: "#00ff00" }, - commands: { start: "make start" }, - }), - ) + const updated = yield* run((svc) => + svc.update({ + projectID: project.id, + name: "Multi Update", + icon: { url: "https://example.com/favicon.ico", override: "data:image/png;base64,abc123", color: "#00ff00" }, + commands: { start: "make start" }, + }), + ) - expect(updated.name).toBe("Multi Update") - expect(updated.icon?.url).toBe("https://example.com/favicon.ico") - expect(updated.icon?.override).toBe("data:image/png;base64,abc123") - expect(updated.icon?.color).toBe("#00ff00") - expect(updated.commands?.start).toBe("make start") - }) + expect(updated.name).toBe("Multi Update") + expect(updated.icon?.url).toBe("https://example.com/favicon.ico") + expect(updated.icon?.override).toBe("data:image/png;base64,abc123") + expect(updated.icon?.color).toBe("#00ff00") + expect(updated.commands?.start).toBe("make start") + }), + ) }) describe("Project.list and Project.get", () => { - test("list returns all projects", async () => { - await using tmp = await tmpdir({ git: true }) - const { project } = await run((svc) => svc.fromDirectory(tmp.path)) + it.live("list returns all projects", () => + Effect.gen(function* () { + const tmp = yield* gitTmpdir() + const { project } = yield* run((svc) => svc.fromDirectory(tmp)) - const all = Project.list() - expect(all.length).toBeGreaterThan(0) - expect(all.find((p) => p.id === project.id)).toBeDefined() - }) + const all = Project.list() + expect(all.length).toBeGreaterThan(0) + expect(all.find((p) => p.id === project.id)).toBeDefined() + }), + ) - test("get returns project by id", async () => { - await using tmp = await tmpdir({ git: true }) - const { project } = await run((svc) => svc.fromDirectory(tmp.path)) + it.live("get returns project by id", () => + Effect.gen(function* () { + const tmp = yield* gitTmpdir() + const { project } = yield* run((svc) => svc.fromDirectory(tmp)) - const found = Project.get(project.id) - expect(found).toBeDefined() - expect(found!.id).toBe(project.id) - }) + const found = Project.get(project.id) + expect(found).toBeDefined() + expect(found!.id).toBe(project.id) + }), + ) test("get returns undefined for unknown id", () => { const found = Project.get(ProjectID.make("nonexistent")) @@ -472,65 +517,72 @@ describe("Project.list and Project.get", () => { }) describe("Project.setInitialized", () => { - test("sets time_initialized on project", async () => { - await using tmp = await tmpdir({ git: true }) - const { project } = await run((svc) => svc.fromDirectory(tmp.path)) + it.live("sets time_initialized on project", () => + Effect.gen(function* () { + const tmp = yield* gitTmpdir() + const { project } = yield* run((svc) => svc.fromDirectory(tmp)) - expect(project.time.initialized).toBeUndefined() + expect(project.time.initialized).toBeUndefined() - Project.setInitialized(project.id) + Project.setInitialized(project.id) - const updated = Project.get(project.id) - expect(updated?.time.initialized).toBeDefined() - }) + const updated = Project.get(project.id) + expect(updated?.time.initialized).toBeDefined() + }), + ) }) describe("Project.addSandbox and Project.removeSandbox", () => { - test("addSandbox adds directory and removeSandbox removes it", async () => { - await using tmp = await tmpdir({ git: true }) - const { project } = await run((svc) => svc.fromDirectory(tmp.path)) - const sandboxDir = path.join(tmp.path, "sandbox-test") + it.live("addSandbox adds directory and removeSandbox removes it", () => + Effect.gen(function* () { + const tmp = yield* gitTmpdir() + const { project } = yield* run((svc) => svc.fromDirectory(tmp)) + const sandboxDir = path.join(tmp, "sandbox-test") - await run((svc) => svc.addSandbox(project.id, sandboxDir)) + yield* run((svc) => svc.addSandbox(project.id, sandboxDir)) - let found = Project.get(project.id) - expect(found?.sandboxes).toContain(sandboxDir) + let found = Project.get(project.id) + expect(found?.sandboxes).toContain(sandboxDir) - await run((svc) => svc.removeSandbox(project.id, sandboxDir)) + yield* run((svc) => svc.removeSandbox(project.id, sandboxDir)) - found = Project.get(project.id) - expect(found?.sandboxes).not.toContain(sandboxDir) - }) + found = Project.get(project.id) + expect(found?.sandboxes).not.toContain(sandboxDir) + }), + ) - test("addSandbox emits GlobalBus event", async () => { - await using tmp = await tmpdir({ git: true }) - const { project } = await run((svc) => svc.fromDirectory(tmp.path)) - const sandboxDir = path.join(tmp.path, "sandbox-event") + it.live("addSandbox emits GlobalBus event", () => + Effect.gen(function* () { + const tmp = yield* gitTmpdir() + const { project } = yield* run((svc) => svc.fromDirectory(tmp)) + const sandboxDir = path.join(tmp, "sandbox-event") - const events: any[] = [] - const on = (evt: any) => events.push(evt) - GlobalBus.on("event", on) + const events: any[] = [] + const on = (evt: any) => events.push(evt) + GlobalBus.on("event", on) + yield* Effect.addFinalizer(() => Effect.sync(() => GlobalBus.off("event", on))) - await run((svc) => svc.addSandbox(project.id, sandboxDir)) + yield* run((svc) => svc.addSandbox(project.id, sandboxDir)) - GlobalBus.off("event", on) - expect(events.some((e) => e.payload.type === Project.Event.Updated.type)).toBe(true) - }) + expect(events.some((e) => e.payload.type === Project.Event.Updated.type)).toBe(true) + }), + ) }) describe("Project.fromDirectory with bare repos", () => { - test("worktree from bare repo should cache in bare repo, not parent", async () => { - await using tmp = await tmpdir({ git: true }) + it.live("worktree from bare repo should cache in bare repo, not parent", () => + Effect.gen(function* () { + const tmp = yield* gitTmpdir() - const parentDir = path.dirname(tmp.path) - const barePath = path.join(parentDir, `bare-${Date.now()}.git`) - const worktreePath = path.join(parentDir, `worktree-${Date.now()}`) + const parentDir = path.dirname(tmp) + const barePath = path.join(parentDir, `bare-${Date.now()}.git`) + const worktreePath = path.join(parentDir, `worktree-${Date.now()}`) + yield* Effect.addFinalizer(() => Effect.promise(() => $`rm -rf ${barePath} ${worktreePath}`.quiet().nothrow()).pipe(Effect.ignore)) - try { - await $`git clone --bare ${tmp.path} ${barePath}`.quiet() - await $`git worktree add ${worktreePath} HEAD`.cwd(barePath).quiet() + yield* Effect.promise(() => $`git clone --bare ${tmp} ${barePath}`.quiet()) + yield* Effect.promise(() => $`git worktree add ${worktreePath} HEAD`.cwd(barePath).quiet()) - const { project } = await run((svc) => svc.fromDirectory(worktreePath)) + const { project } = yield* run((svc) => svc.fromDirectory(worktreePath)) expect(project.id).not.toBe(ProjectID.global) expect(project.worktree).toBe(barePath) @@ -538,31 +590,34 @@ describe("Project.fromDirectory with bare repos", () => { const correctCache = path.join(barePath, "opencode") const wrongCache = path.join(parentDir, ".git", "opencode") - expect(await Bun.file(correctCache).exists()).toBe(true) - expect(await Bun.file(wrongCache).exists()).toBe(false) - } finally { - await $`rm -rf ${barePath} ${worktreePath}`.quiet().nothrow() - } - }) + expect(yield* Effect.promise(() => Bun.file(correctCache).exists())).toBe(true) + expect(yield* Effect.promise(() => Bun.file(wrongCache).exists())).toBe(false) + }), + ) - test("different bare repos under same parent should not share project ID", async () => { - await using tmp1 = await tmpdir({ git: true }) - await using tmp2 = await tmpdir({ git: true }) + it.live("different bare repos under same parent should not share project ID", () => + Effect.gen(function* () { + const tmp1 = yield* gitTmpdir() + const tmp2 = yield* gitTmpdir() - const parentDir = path.dirname(tmp1.path) - const bareA = path.join(parentDir, `bare-a-${Date.now()}.git`) - const bareB = path.join(parentDir, `bare-b-${Date.now()}.git`) - const worktreeA = path.join(parentDir, `wt-a-${Date.now()}`) - const worktreeB = path.join(parentDir, `wt-b-${Date.now()}`) + const parentDir = path.dirname(tmp1) + const bareA = path.join(parentDir, `bare-a-${Date.now()}.git`) + const bareB = path.join(parentDir, `bare-b-${Date.now()}.git`) + const worktreeA = path.join(parentDir, `wt-a-${Date.now()}`) + const worktreeB = path.join(parentDir, `wt-b-${Date.now()}`) + yield* Effect.addFinalizer(() => + Effect.promise(() => $`rm -rf ${bareA} ${bareB} ${worktreeA} ${worktreeB}`.quiet().nothrow()).pipe( + Effect.ignore, + ), + ) - try { - await $`git clone --bare ${tmp1.path} ${bareA}`.quiet() - await $`git clone --bare ${tmp2.path} ${bareB}`.quiet() - await $`git worktree add ${worktreeA} HEAD`.cwd(bareA).quiet() - await $`git worktree add ${worktreeB} HEAD`.cwd(bareB).quiet() + yield* Effect.promise(() => $`git clone --bare ${tmp1} ${bareA}`.quiet()) + yield* Effect.promise(() => $`git clone --bare ${tmp2} ${bareB}`.quiet()) + yield* Effect.promise(() => $`git worktree add ${worktreeA} HEAD`.cwd(bareA).quiet()) + yield* Effect.promise(() => $`git worktree add ${worktreeB} HEAD`.cwd(bareB).quiet()) - const { project: projA } = await run((svc) => svc.fromDirectory(worktreeA)) - const { project: projB } = await run((svc) => svc.fromDirectory(worktreeB)) + const { project: projA } = yield* run((svc) => svc.fromDirectory(worktreeA)) + const { project: projB } = yield* run((svc) => svc.fromDirectory(worktreeB)) expect(projA.id).not.toBe(projB.id) @@ -570,34 +625,31 @@ describe("Project.fromDirectory with bare repos", () => { const cacheB = path.join(bareB, "opencode") const wrongCache = path.join(parentDir, ".git", "opencode") - expect(await Bun.file(cacheA).exists()).toBe(true) - expect(await Bun.file(cacheB).exists()).toBe(true) - expect(await Bun.file(wrongCache).exists()).toBe(false) - } finally { - await $`rm -rf ${bareA} ${bareB} ${worktreeA} ${worktreeB}`.quiet().nothrow() - } - }) + expect(yield* Effect.promise(() => Bun.file(cacheA).exists())).toBe(true) + expect(yield* Effect.promise(() => Bun.file(cacheB).exists())).toBe(true) + expect(yield* Effect.promise(() => Bun.file(wrongCache).exists())).toBe(false) + }), + ) - test("bare repo without .git suffix is still detected via core.bare", async () => { - await using tmp = await tmpdir({ git: true }) + it.live("bare repo without .git suffix is still detected via core.bare", () => + Effect.gen(function* () { + const tmp = yield* gitTmpdir() - const parentDir = path.dirname(tmp.path) - const barePath = path.join(parentDir, `bare-no-suffix-${Date.now()}`) - const worktreePath = path.join(parentDir, `worktree-${Date.now()}`) + const parentDir = path.dirname(tmp) + const barePath = path.join(parentDir, `bare-no-suffix-${Date.now()}`) + const worktreePath = path.join(parentDir, `worktree-${Date.now()}`) + yield* Effect.addFinalizer(() => Effect.promise(() => $`rm -rf ${barePath} ${worktreePath}`.quiet().nothrow()).pipe(Effect.ignore)) - try { - await $`git clone --bare ${tmp.path} ${barePath}`.quiet() - await $`git worktree add ${worktreePath} HEAD`.cwd(barePath).quiet() + yield* Effect.promise(() => $`git clone --bare ${tmp} ${barePath}`.quiet()) + yield* Effect.promise(() => $`git worktree add ${worktreePath} HEAD`.cwd(barePath).quiet()) - const { project } = await run((svc) => svc.fromDirectory(worktreePath)) + const { project } = yield* run((svc) => svc.fromDirectory(worktreePath)) expect(project.id).not.toBe(ProjectID.global) expect(project.worktree).toBe(barePath) const correctCache = path.join(barePath, "opencode") - expect(await Bun.file(correctCache).exists()).toBe(true) - } finally { - await $`rm -rf ${barePath} ${worktreePath}`.quiet().nothrow() - } - }) + expect(yield* Effect.promise(() => Bun.file(correctCache).exists())).toBe(true) + }), + ) }) From f7dbb4dac464aaf0c47dc222e314f68777ebdf06 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Tue, 12 May 2026 19:48:35 +0000 Subject: [PATCH 53/70] chore: generate --- .../opencode/test/project/project.test.ts | 53 +++++++++++++++---- 1 file changed, 43 insertions(+), 10 deletions(-) diff --git a/packages/opencode/test/project/project.test.ts b/packages/opencode/test/project/project.test.ts index 9ed1e60bbf..9b599c2d64 100644 --- a/packages/opencode/test/project/project.test.ts +++ b/packages/opencode/test/project/project.test.ts @@ -85,7 +85,8 @@ function projectLayerWithFailure(failArg: string) { ) } -const failureIt = (failArg: string) => testEffect(Layer.mergeAll(projectLayerWithFailure(failArg), CrossSpawnSpawner.defaultLayer)) +const failureIt = (failArg: string) => + testEffect(Layer.mergeAll(projectLayerWithFailure(failArg), CrossSpawnSpawner.defaultLayer)) describe("Project.fromDirectory", () => { it.live("should handle git repository with no commits", () => @@ -192,7 +193,14 @@ describe("Project.fromDirectory with worktrees", () => { const tmp = yield* gitTmpdir() const worktreePath = path.join(tmp, "..", path.basename(tmp) + "-worktree") - yield* Effect.addFinalizer(() => Effect.promise(() => $`git worktree remove ${worktreePath}`.cwd(tmp).quiet().catch(() => {}))) + yield* Effect.addFinalizer(() => + Effect.promise(() => + $`git worktree remove ${worktreePath}` + .cwd(tmp) + .quiet() + .catch(() => {}), + ), + ) yield* Effect.promise(() => $`git worktree add ${worktreePath} -b test-branch-${Date.now()}`.cwd(tmp).quiet()) const { project, sandbox } = yield* run((svc) => svc.fromDirectory(worktreePath)) @@ -211,7 +219,14 @@ describe("Project.fromDirectory with worktrees", () => { const { project: main } = yield* run((svc) => svc.fromDirectory(tmp)) const worktreePath = path.join(tmp, "..", path.basename(tmp) + "-wt-shared") - yield* Effect.addFinalizer(() => Effect.promise(() => $`git worktree remove ${worktreePath}`.cwd(tmp).quiet().catch(() => {}))) + yield* Effect.addFinalizer(() => + Effect.promise(() => + $`git worktree remove ${worktreePath}` + .cwd(tmp) + .quiet() + .catch(() => {}), + ), + ) yield* Effect.promise(() => $`git worktree add ${worktreePath} -b shared-${Date.now()}`.cwd(tmp).quiet()) const { project: wt } = yield* run((svc) => svc.fromDirectory(worktreePath)) @@ -229,10 +244,12 @@ describe("Project.fromDirectory with worktrees", () => { Effect.gen(function* () { const tmp = yield* gitTmpdir() - // Create a bare remote, push, then clone into a second directory + // Create a bare remote, push, then clone into a second directory const bare = tmp + "-bare" const clone = tmp + "-clone" - yield* Effect.addFinalizer(() => Effect.promise(() => $`rm -rf ${bare} ${clone}`.quiet().nothrow()).pipe(Effect.ignore)) + yield* Effect.addFinalizer(() => + Effect.promise(() => $`rm -rf ${bare} ${clone}`.quiet().nothrow()).pipe(Effect.ignore), + ) yield* Effect.promise(() => $`git clone --bare ${tmp} ${bare}`.quiet()) yield* Effect.promise(() => $`git clone ${bare} ${clone}`.quiet()) @@ -251,8 +268,18 @@ describe("Project.fromDirectory with worktrees", () => { const worktree2 = path.join(tmp, "..", path.basename(tmp) + "-wt2") yield* Effect.addFinalizer(() => Effect.gen(function* () { - yield* Effect.promise(() => $`git worktree remove ${worktree1}`.cwd(tmp).quiet().catch(() => {})) - yield* Effect.promise(() => $`git worktree remove ${worktree2}`.cwd(tmp).quiet().catch(() => {})) + yield* Effect.promise(() => + $`git worktree remove ${worktree1}` + .cwd(tmp) + .quiet() + .catch(() => {}), + ) + yield* Effect.promise(() => + $`git worktree remove ${worktree2}` + .cwd(tmp) + .quiet() + .catch(() => {}), + ) }), ) yield* Effect.promise(() => $`git worktree add ${worktree1} -b branch-${Date.now()}`.cwd(tmp).quiet()) @@ -439,7 +466,9 @@ describe("Project.update", () => { expect(Exit.isFailure(exit)).toBe(true) if (Exit.isFailure(exit)) { const error = Cause.squash(exit.cause) - expect(error instanceof Error ? error.message : String(error)).toContain("Project not found: nonexistent-project-id") + expect(error instanceof Error ? error.message : String(error)).toContain( + "Project not found: nonexistent-project-id", + ) } }), ) @@ -577,7 +606,9 @@ describe("Project.fromDirectory with bare repos", () => { const parentDir = path.dirname(tmp) const barePath = path.join(parentDir, `bare-${Date.now()}.git`) const worktreePath = path.join(parentDir, `worktree-${Date.now()}`) - yield* Effect.addFinalizer(() => Effect.promise(() => $`rm -rf ${barePath} ${worktreePath}`.quiet().nothrow()).pipe(Effect.ignore)) + yield* Effect.addFinalizer(() => + Effect.promise(() => $`rm -rf ${barePath} ${worktreePath}`.quiet().nothrow()).pipe(Effect.ignore), + ) yield* Effect.promise(() => $`git clone --bare ${tmp} ${barePath}`.quiet()) yield* Effect.promise(() => $`git worktree add ${worktreePath} HEAD`.cwd(barePath).quiet()) @@ -638,7 +669,9 @@ describe("Project.fromDirectory with bare repos", () => { const parentDir = path.dirname(tmp) const barePath = path.join(parentDir, `bare-no-suffix-${Date.now()}`) const worktreePath = path.join(parentDir, `worktree-${Date.now()}`) - yield* Effect.addFinalizer(() => Effect.promise(() => $`rm -rf ${barePath} ${worktreePath}`.quiet().nothrow()).pipe(Effect.ignore)) + yield* Effect.addFinalizer(() => + Effect.promise(() => $`rm -rf ${barePath} ${worktreePath}`.quiet().nothrow()).pipe(Effect.ignore), + ) yield* Effect.promise(() => $`git clone --bare ${tmp} ${barePath}`.quiet()) yield* Effect.promise(() => $`git worktree add ${worktreePath} HEAD`.cwd(barePath).quiet()) From e0d0fe1ff79b2e68fbf496f3957b63aa5bcd53a8 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 12 May 2026 15:58:54 -0400 Subject: [PATCH 54/70] test(bus): migrate integration tests to Effect runner (#27132) --- .../opencode/test/bus/bus-integration.test.ts | 124 +++++++++--------- 1 file changed, 62 insertions(+), 62 deletions(-) diff --git a/packages/opencode/test/bus/bus-integration.test.ts b/packages/opencode/test/bus/bus-integration.test.ts index 3e3d7a3e90..645a94fb3b 100644 --- a/packages/opencode/test/bus/bus-integration.test.ts +++ b/packages/opencode/test/bus/bus-integration.test.ts @@ -1,88 +1,88 @@ -import { afterEach, describe, expect, test } from "bun:test" -import { Schema } from "effect" +import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" +import { afterEach, describe, expect } from "bun:test" +import { Deferred, Effect, Layer, Schema } from "effect" import { Bus } from "../../src/bus" import { BusEvent } from "../../src/bus/bus-event" -import { Instance } from "../../src/project/instance" -import { WithInstance } from "../../src/project/with-instance" -import { disposeAllInstances, tmpdir } from "../fixture/fixture" +import { disposeAllInstances, provideInstance, tmpdirScoped } from "../fixture/fixture" +import { testEffect } from "../lib/effect" const TestEvent = BusEvent.define("test.integration", Schema.Struct({ value: Schema.Number })) - -function withInstance(directory: string, fn: () => Promise) { - return WithInstance.provide({ directory, fn }) -} +const it = testEffect(Layer.mergeAll(Bus.layer, CrossSpawnSpawner.defaultLayer)) describe("Bus integration: acquireRelease subscriber pattern", () => { afterEach(() => disposeAllInstances()) - test("subscriber via callback facade receives events and cleans up on unsub", async () => { - await using tmp = await tmpdir() - const received: number[] = [] + it.instance("subscriber via callback facade receives events and cleans up on unsub", () => + Effect.gen(function* () { + const bus = yield* Bus.Service + const received: number[] = [] + const receivedTwo = yield* Deferred.make() - await withInstance(tmp.path, async () => { - const unsub = Bus.subscribe(TestEvent, (evt) => { + const unsub = yield* bus.subscribeCallback(TestEvent, (evt) => { received.push(evt.properties.value) + if (received.length === 2) Deferred.doneUnsafe(receivedTwo, Effect.void) }) - await Bun.sleep(10) - await Bus.publish(TestEvent, { value: 1 }) - await Bus.publish(TestEvent, { value: 2 }) - await Bun.sleep(10) + yield* bus.publish(TestEvent, { value: 1 }) + yield* bus.publish(TestEvent, { value: 2 }) + yield* Deferred.await(receivedTwo).pipe(Effect.timeout("2 seconds")) expect(received).toEqual([1, 2]) - unsub() - await Bun.sleep(10) - await Bus.publish(TestEvent, { value: 3 }) - await Bun.sleep(10) + yield* Effect.sync(unsub) + yield* bus.publish(TestEvent, { value: 3 }) + yield* Effect.sleep("10 millis") expect(received).toEqual([1, 2]) - }) - }) + }), + ) - test("subscribeAll receives events from multiple types", async () => { - await using tmp = await tmpdir() - const received: Array<{ type: string; value?: number }> = [] + it.instance("subscribeAll receives events from multiple types", () => + Effect.gen(function* () { + const bus = yield* Bus.Service + const received: Array<{ type: string; value?: number }> = [] + const OtherEvent = BusEvent.define("test.other", Schema.Struct({ value: Schema.Number })) + const receivedTwo = yield* Deferred.make() - const OtherEvent = BusEvent.define("test.other", Schema.Struct({ value: Schema.Number })) - - await withInstance(tmp.path, async () => { - Bus.subscribeAll((evt) => { + yield* bus.subscribeAllCallback((evt) => { received.push({ type: evt.type, value: evt.properties.value }) + if (received.length === 2) Deferred.doneUnsafe(receivedTwo, Effect.void) }) - await Bun.sleep(10) - await Bus.publish(TestEvent, { value: 10 }) - await Bus.publish(OtherEvent, { value: 20 }) - await Bun.sleep(10) - }) + yield* bus.publish(TestEvent, { value: 10 }) + yield* bus.publish(OtherEvent, { value: 20 }) + yield* Deferred.await(receivedTwo).pipe(Effect.timeout("2 seconds")) - expect(received).toEqual([ - { type: "test.integration", value: 10 }, - { type: "test.other", value: 20 }, - ]) - }) + expect(received).toEqual([ + { type: "test.integration", value: 10 }, + { type: "test.other", value: 20 }, + ]) + }), + ) - test("subscriber cleanup on instance disposal interrupts the stream", async () => { - await using tmp = await tmpdir() - const received: number[] = [] - let disposed = false + it.live("subscriber cleanup on instance disposal interrupts the stream", () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped() + const received: number[] = [] + const seen = yield* Deferred.make() + const disposed = yield* Deferred.make() - await withInstance(tmp.path, async () => { - Bus.subscribeAll((evt) => { - if (evt.type === Bus.InstanceDisposed.type) { - disposed = true - return - } - received.push(evt.properties.value) - }) - await Bun.sleep(10) - await Bus.publish(TestEvent, { value: 1 }) - await Bun.sleep(10) - }) + yield* Effect.gen(function* () { + const bus = yield* Bus.Service + yield* bus.subscribeAllCallback((evt) => { + if (evt.type === Bus.InstanceDisposed.type) { + Deferred.doneUnsafe(disposed, Effect.void) + return + } + received.push(evt.properties.value) + Deferred.doneUnsafe(seen, Effect.void) + }) + yield* bus.publish(TestEvent, { value: 1 }) + yield* Deferred.await(seen).pipe(Effect.timeout("2 seconds")) + }).pipe(provideInstance(dir)) - await disposeAllInstances() - await Bun.sleep(50) + yield* Effect.promise(() => disposeAllInstances()) + yield* Deferred.await(disposed).pipe(Effect.timeout("2 seconds")) - expect(received).toEqual([1]) - expect(disposed).toBe(true) - }) + expect(received).toEqual([1]) + }), + ) }) From 3f74abc6cd8a24eaa99d5a8ab00b1c905b9eff20 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 12 May 2026 16:00:54 -0400 Subject: [PATCH 55/70] test: simplify Effect migration follow-ups (#27136) --- packages/opencode/test/fixture/fixture.ts | 2 +- .../opencode/test/project/project.test.ts | 69 ++++++++----------- .../opencode/test/question/question.test.ts | 3 +- 3 files changed, 31 insertions(+), 43 deletions(-) diff --git a/packages/opencode/test/fixture/fixture.ts b/packages/opencode/test/fixture/fixture.ts index d47620f623..fedbc246bc 100644 --- a/packages/opencode/test/fixture/fixture.ts +++ b/packages/opencode/test/fixture/fixture.ts @@ -135,7 +135,7 @@ export function tmpdirScoped(options?: { git?: boolean; config?: Partial(fn: (svc: Project.Interface) => Effect.Effect) { }) } -function gitTmpdir() { - return Effect.gen(function* () { - const tmp = yield* tmpdirScoped() - yield* Effect.promise(() => $`git init`.cwd(tmp).quiet()) - yield* Effect.promise(() => $`git config core.fsmonitor false`.cwd(tmp).quiet()) - yield* Effect.promise(() => $`git config commit.gpgsign false`.cwd(tmp).quiet()) - yield* Effect.promise(() => $`git config user.email "test@opencode.test"`.cwd(tmp).quiet()) - yield* Effect.promise(() => $`git config user.name "Test"`.cwd(tmp).quiet()) - yield* Effect.promise(() => $`git commit --allow-empty -m ${`root commit ${tmp}`}`.cwd(tmp).quiet()) - return tmp - }) -} - /** * Creates a mock ChildProcessSpawner layer that intercepts git subcommands * matching `failArg` and returns exit code 128, while delegating everything @@ -108,7 +95,7 @@ describe("Project.fromDirectory", () => { it.live("should handle git repository with commits", () => Effect.gen(function* () { - const tmp = yield* gitTmpdir() + const tmp = yield* tmpdirScoped({ git: true }) const { project } = yield* run((svc) => svc.fromDirectory(tmp)) @@ -132,7 +119,7 @@ describe("Project.fromDirectory", () => { it.live("derives stable project ID from root commit", () => Effect.gen(function* () { - const tmp = yield* gitTmpdir() + const tmp = yield* tmpdirScoped({ git: true }) const { project: a } = yield* run((svc) => svc.fromDirectory(tmp)) const { project: b } = yield* run((svc) => svc.fromDirectory(tmp)) expect(b.id).toBe(a.id) @@ -156,7 +143,7 @@ describe("Project.fromDirectory git failure paths", () => { failureIt("--show-toplevel").live("handles show-toplevel failure gracefully", () => Effect.gen(function* () { - const tmp = yield* gitTmpdir() + const tmp = yield* tmpdirScoped({ git: true }) const { project, sandbox } = yield* run((svc) => svc.fromDirectory(tmp)) expect(project.worktree).toBe(tmp) @@ -166,7 +153,7 @@ describe("Project.fromDirectory git failure paths", () => { failureIt("--git-common-dir").live("handles git-common-dir failure gracefully", () => Effect.gen(function* () { - const tmp = yield* gitTmpdir() + const tmp = yield* tmpdirScoped({ git: true }) const { project, sandbox } = yield* run((svc) => svc.fromDirectory(tmp)) expect(project.worktree).toBe(tmp) @@ -178,7 +165,7 @@ describe("Project.fromDirectory git failure paths", () => { describe("Project.fromDirectory with worktrees", () => { it.live("should set worktree to root when called from root", () => Effect.gen(function* () { - const tmp = yield* gitTmpdir() + const tmp = yield* tmpdirScoped({ git: true }) const { project, sandbox } = yield* run((svc) => svc.fromDirectory(tmp)) @@ -190,7 +177,7 @@ describe("Project.fromDirectory with worktrees", () => { it.live("should set worktree to root when called from a worktree", () => Effect.gen(function* () { - const tmp = yield* gitTmpdir() + const tmp = yield* tmpdirScoped({ git: true }) const worktreePath = path.join(tmp, "..", path.basename(tmp) + "-worktree") yield* Effect.addFinalizer(() => @@ -214,7 +201,7 @@ describe("Project.fromDirectory with worktrees", () => { it.live("worktree should share project ID with main repo", () => Effect.gen(function* () { - const tmp = yield* gitTmpdir() + const tmp = yield* tmpdirScoped({ git: true }) const { project: main } = yield* run((svc) => svc.fromDirectory(tmp)) @@ -242,7 +229,7 @@ describe("Project.fromDirectory with worktrees", () => { it.live("separate clones of the same repo should share project ID", () => Effect.gen(function* () { - const tmp = yield* gitTmpdir() + const tmp = yield* tmpdirScoped({ git: true }) // Create a bare remote, push, then clone into a second directory const bare = tmp + "-bare" @@ -262,7 +249,7 @@ describe("Project.fromDirectory with worktrees", () => { it.live("should accumulate multiple worktrees in sandboxes", () => Effect.gen(function* () { - const tmp = yield* gitTmpdir() + const tmp = yield* tmpdirScoped({ git: true }) const worktree1 = path.join(tmp, "..", path.basename(tmp) + "-wt1") const worktree2 = path.join(tmp, "..", path.basename(tmp) + "-wt2") @@ -299,7 +286,7 @@ describe("Project.fromDirectory with worktrees", () => { describe("Project.discover", () => { it.live("should discover favicon.png in root", () => Effect.gen(function* () { - const tmp = yield* gitTmpdir() + const tmp = yield* tmpdirScoped({ git: true }) const { project } = yield* run((svc) => svc.fromDirectory(tmp)) const pngData = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]) @@ -318,7 +305,7 @@ describe("Project.discover", () => { it.live("should not discover non-image files", () => Effect.gen(function* () { - const tmp = yield* gitTmpdir() + const tmp = yield* tmpdirScoped({ git: true }) const { project } = yield* run((svc) => svc.fromDirectory(tmp)) yield* Effect.promise(() => Bun.write(path.join(tmp, "favicon.txt"), "not an image")) @@ -333,7 +320,7 @@ describe("Project.discover", () => { it.live("should not discover favicon when override is set", () => Effect.gen(function* () { - const tmp = yield* gitTmpdir() + const tmp = yield* tmpdirScoped({ git: true }) const { project } = yield* run((svc) => svc.fromDirectory(tmp)) yield* run((svc) => @@ -362,7 +349,7 @@ describe("Project.discover", () => { describe("Project.update", () => { it.live("should update name", () => Effect.gen(function* () { - const tmp = yield* gitTmpdir() + const tmp = yield* tmpdirScoped({ git: true }) const { project } = yield* run((svc) => svc.fromDirectory(tmp)) const updated = yield* run((svc) => @@ -381,7 +368,7 @@ describe("Project.update", () => { it.live("should update icon url", () => Effect.gen(function* () { - const tmp = yield* gitTmpdir() + const tmp = yield* tmpdirScoped({ git: true }) const { project } = yield* run((svc) => svc.fromDirectory(tmp)) const updated = yield* run((svc) => @@ -400,7 +387,7 @@ describe("Project.update", () => { it.live("should update icon color", () => Effect.gen(function* () { - const tmp = yield* gitTmpdir() + const tmp = yield* tmpdirScoped({ git: true }) const { project } = yield* run((svc) => svc.fromDirectory(tmp)) const updated = yield* run((svc) => @@ -419,7 +406,7 @@ describe("Project.update", () => { it.live("should update icon override", () => Effect.gen(function* () { - const tmp = yield* gitTmpdir() + const tmp = yield* tmpdirScoped({ git: true }) const { project } = yield* run((svc) => svc.fromDirectory(tmp)) const updated = yield* run((svc) => @@ -438,7 +425,7 @@ describe("Project.update", () => { it.live("should update commands", () => Effect.gen(function* () { - const tmp = yield* gitTmpdir() + const tmp = yield* tmpdirScoped({ git: true }) const { project } = yield* run((svc) => svc.fromDirectory(tmp)) const updated = yield* run((svc) => @@ -475,7 +462,7 @@ describe("Project.update", () => { it.live("should emit GlobalBus event on update", () => Effect.gen(function* () { - const tmp = yield* gitTmpdir() + const tmp = yield* tmpdirScoped({ git: true }) const { project } = yield* run((svc) => svc.fromDirectory(tmp)) let eventPayload: any = null @@ -495,7 +482,7 @@ describe("Project.update", () => { it.live("should update multiple fields at once", () => Effect.gen(function* () { - const tmp = yield* gitTmpdir() + const tmp = yield* tmpdirScoped({ git: true }) const { project } = yield* run((svc) => svc.fromDirectory(tmp)) const updated = yield* run((svc) => @@ -519,7 +506,7 @@ describe("Project.update", () => { describe("Project.list and Project.get", () => { it.live("list returns all projects", () => Effect.gen(function* () { - const tmp = yield* gitTmpdir() + const tmp = yield* tmpdirScoped({ git: true }) const { project } = yield* run((svc) => svc.fromDirectory(tmp)) const all = Project.list() @@ -530,7 +517,7 @@ describe("Project.list and Project.get", () => { it.live("get returns project by id", () => Effect.gen(function* () { - const tmp = yield* gitTmpdir() + const tmp = yield* tmpdirScoped({ git: true }) const { project } = yield* run((svc) => svc.fromDirectory(tmp)) const found = Project.get(project.id) @@ -548,7 +535,7 @@ describe("Project.list and Project.get", () => { describe("Project.setInitialized", () => { it.live("sets time_initialized on project", () => Effect.gen(function* () { - const tmp = yield* gitTmpdir() + const tmp = yield* tmpdirScoped({ git: true }) const { project } = yield* run((svc) => svc.fromDirectory(tmp)) expect(project.time.initialized).toBeUndefined() @@ -564,7 +551,7 @@ describe("Project.setInitialized", () => { describe("Project.addSandbox and Project.removeSandbox", () => { it.live("addSandbox adds directory and removeSandbox removes it", () => Effect.gen(function* () { - const tmp = yield* gitTmpdir() + const tmp = yield* tmpdirScoped({ git: true }) const { project } = yield* run((svc) => svc.fromDirectory(tmp)) const sandboxDir = path.join(tmp, "sandbox-test") @@ -582,7 +569,7 @@ describe("Project.addSandbox and Project.removeSandbox", () => { it.live("addSandbox emits GlobalBus event", () => Effect.gen(function* () { - const tmp = yield* gitTmpdir() + const tmp = yield* tmpdirScoped({ git: true }) const { project } = yield* run((svc) => svc.fromDirectory(tmp)) const sandboxDir = path.join(tmp, "sandbox-event") @@ -601,7 +588,7 @@ describe("Project.addSandbox and Project.removeSandbox", () => { describe("Project.fromDirectory with bare repos", () => { it.live("worktree from bare repo should cache in bare repo, not parent", () => Effect.gen(function* () { - const tmp = yield* gitTmpdir() + const tmp = yield* tmpdirScoped({ git: true }) const parentDir = path.dirname(tmp) const barePath = path.join(parentDir, `bare-${Date.now()}.git`) @@ -628,8 +615,8 @@ describe("Project.fromDirectory with bare repos", () => { it.live("different bare repos under same parent should not share project ID", () => Effect.gen(function* () { - const tmp1 = yield* gitTmpdir() - const tmp2 = yield* gitTmpdir() + const tmp1 = yield* tmpdirScoped({ git: true }) + const tmp2 = yield* tmpdirScoped({ git: true }) const parentDir = path.dirname(tmp1) const bareA = path.join(parentDir, `bare-a-${Date.now()}.git`) @@ -664,7 +651,7 @@ describe("Project.fromDirectory with bare repos", () => { it.live("bare repo without .git suffix is still detected via core.bare", () => Effect.gen(function* () { - const tmp = yield* gitTmpdir() + const tmp = yield* tmpdirScoped({ git: true }) const parentDir = path.dirname(tmp) const barePath = path.join(parentDir, `bare-no-suffix-${Date.now()}`) diff --git a/packages/opencode/test/question/question.test.ts b/packages/opencode/test/question/question.test.ts index a87907c57e..3e970b63fa 100644 --- a/packages/opencode/test/question/question.test.ts +++ b/packages/opencode/test/question/question.test.ts @@ -397,7 +397,8 @@ it.live("pending question rejects on instance dispose", () => }).pipe(provideInstance(dir), Effect.forkScoped) expect(yield* waitForPending(1).pipe(provideInstance(dir))).toHaveLength(1) - yield* Effect.promise(() => InstanceRuntime.disposeInstance(Instance.current)).pipe(provideInstance(dir)) + const ctx = yield* Effect.sync(() => Instance.current).pipe(provideInstance(dir)) + yield* Effect.promise(() => InstanceRuntime.disposeInstance(ctx)) const exit = yield* Fiber.await(fiber) expect(Exit.isFailure(exit)).toBe(true) From b9e7cbf13cdbce6434cf3152236b312db75afd95 Mon Sep 17 00:00:00 2001 From: Frank Date: Tue, 12 May 2026 16:06:16 -0400 Subject: [PATCH 56/70] sync --- packages/console/app/src/routes/zen/util/handler.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/console/app/src/routes/zen/util/handler.ts b/packages/console/app/src/routes/zen/util/handler.ts index 2e46df0366..540dfe7e87 100644 --- a/packages/console/app/src/routes/zen/util/handler.ts +++ b/packages/console/app/src/routes/zen/util/handler.ts @@ -299,7 +299,6 @@ export async function handler( let buffer = "" let responseLength = 0 let timestampFirstByte = 0 - let timestampLastByte = 0 function pump(): Promise { return ( From dd14413a642759dfdecb2adee42f56b4b314d08f Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 12 May 2026 16:16:58 -0400 Subject: [PATCH 57/70] Preserve native LLM tool context (#27116) --- packages/llm/example/tutorial.ts | 4 +- packages/llm/src/index.ts | 1 + .../llm/src/protocols/openai-responses.ts | 2 +- packages/llm/src/protocols/utils/lifecycle.ts | 2 +- packages/llm/src/schema/events.ts | 44 +++++---- packages/llm/src/tool-runtime.ts | 96 ++++++++++++++++--- packages/llm/src/tool.ts | 11 ++- packages/llm/test/adapter.test.ts | 6 +- packages/llm/test/llm.test.ts | 2 +- .../test/provider/anthropic-messages.test.ts | 6 +- .../test/provider/bedrock-converse.test.ts | 8 +- packages/llm/test/provider/gemini.test.ts | 14 +-- .../llm/test/provider/openai-chat.test.ts | 4 +- .../provider/openai-compatible-chat.test.ts | 2 +- .../test/provider/openai-responses.test.ts | 4 +- packages/llm/test/recorded-scenarios.ts | 16 ++-- packages/llm/test/schema.test.ts | 5 + packages/llm/test/tool-runtime.test.ts | 92 ++++++++++++++++-- 18 files changed, 244 insertions(+), 75 deletions(-) diff --git a/packages/llm/example/tutorial.ts b/packages/llm/example/tutorial.ts index a9adecf369..429ac4824b 100644 --- a/packages/llm/example/tutorial.ts +++ b/packages/llm/example/tutorial.ts @@ -78,7 +78,7 @@ const streamText = LLM.stream(request).pipe( Stream.tap((event) => Effect.sync(() => { if (event.type === "text-delta") process.stdout.write(`\ntext: ${event.text}`) - if (event.type === "request-finish") process.stdout.write(`\nfinish: ${event.reason}\n`) + if (event.type === "finish") process.stdout.write(`\nfinish: ${event.reason}\n`) }), ), Stream.runDrain, @@ -185,7 +185,7 @@ const FakeProtocol = Protocol.make({ event: Schema.String, initial: () => undefined, step: (_, frame) => Effect.succeed([undefined, [{ type: "text-delta", id: "text-0", text: frame }]] as const), - onHalt: () => [{ type: "request-finish", reason: "stop" }], + onHalt: () => [{ type: "finish", reason: "stop" }], }, }) diff --git a/packages/llm/src/index.ts b/packages/llm/src/index.ts index f4adf4859a..acf73b360e 100644 --- a/packages/llm/src/index.ts +++ b/packages/llm/src/index.ts @@ -17,6 +17,7 @@ export type { ExecutableTools, Tool as ToolShape, ToolExecute, + ToolExecuteContext, Tools, ToolSchema, } from "./tool" diff --git a/packages/llm/src/protocols/openai-responses.ts b/packages/llm/src/protocols/openai-responses.ts index e31a42cd5a..7cf734f027 100644 --- a/packages/llm/src/protocols/openai-responses.ts +++ b/packages/llm/src/protocols/openai-responses.ts @@ -380,7 +380,7 @@ type StepResult = readonly [ParserState, ReadonlyArray] const NO_EVENTS: StepResult["1"] = [] // `response.completed` / `response.incomplete` are clean finishes that emit a -// `request-finish` event; `response.failed` is a hard failure that emits a +// `finish` event; `response.failed` is a hard failure that emits a // `provider-error`. All three end the stream — kept in one set so `step` and // the protocol's `terminal` predicate stay in sync. const TERMINAL_TYPES = new Set(["response.completed", "response.incomplete", "response.failed"]) diff --git a/packages/llm/src/protocols/utils/lifecycle.ts b/packages/llm/src/protocols/utils/lifecycle.ts index 67039b137a..c249d75cee 100644 --- a/packages/llm/src/protocols/utils/lifecycle.ts +++ b/packages/llm/src/protocols/utils/lifecycle.ts @@ -80,7 +80,7 @@ export const finish = ( usage: input.usage, providerMetadata: input.providerMetadata, }), - LLMEvent.requestFinish(input), + LLMEvent.finish(input), ) return { ...stepped, stepStarted: false } } diff --git a/packages/llm/src/schema/events.ts b/packages/llm/src/schema/events.ts index 6e6bb1541b..6a088dc873 100644 --- a/packages/llm/src/schema/events.ts +++ b/packages/llm/src/schema/events.ts @@ -1,5 +1,5 @@ import { Schema } from "effect" -import { ContentBlockID, FinishReason, ProtocolID, ProviderMetadata, ResponseID, RouteID, ToolCallID } from "./ids" +import { ContentBlockID, FinishReason, ProtocolID, ProviderMetadata, RouteID, ToolCallID } from "./ids" import { ModelRef } from "./options" import { ToolResultValue } from "./messages" @@ -66,14 +66,13 @@ export class Usage extends Schema.Class("LLM.Usage")({ get visibleOutputTokens() { return Math.max(0, (this.outputTokens ?? 0) - (this.reasoningTokens ?? 0)) } + + static from(input: UsageInput) { + return input instanceof Usage ? input : new Usage(input) + } } -export const RequestStart = Schema.Struct({ - type: Schema.tag("request-start"), - id: ResponseID, - model: ModelRef, -}).annotate({ identifier: "LLM.Event.RequestStart" }) -export type RequestStart = Schema.Schema.Type +export type UsageInput = Usage | ConstructorParameters[0] export const StepStart = Schema.Struct({ type: Schema.tag("step-start"), @@ -185,13 +184,13 @@ export const StepFinish = Schema.Struct({ }).annotate({ identifier: "LLM.Event.StepFinish" }) export type StepFinish = Schema.Schema.Type -export const RequestFinish = Schema.Struct({ - type: Schema.tag("request-finish"), +export const Finish = Schema.Struct({ + type: Schema.tag("finish"), reason: FinishReason, usage: Schema.optional(Usage), providerMetadata: Schema.optional(ProviderMetadata), -}).annotate({ identifier: "LLM.Event.RequestFinish" }) -export type RequestFinish = Schema.Schema.Type +}).annotate({ identifier: "LLM.Event.Finish" }) +export type Finish = Schema.Schema.Type export const ProviderErrorEvent = Schema.Struct({ type: Schema.tag("provider-error"), @@ -202,7 +201,6 @@ export const ProviderErrorEvent = Schema.Struct({ export type ProviderErrorEvent = Schema.Schema.Type const llmEventTagged = Schema.Union([ - RequestStart, StepStart, TextStart, TextDelta, @@ -217,13 +215,15 @@ const llmEventTagged = Schema.Union([ ToolResult, ToolError, StepFinish, - RequestFinish, + Finish, ProviderErrorEvent, ]).pipe(Schema.toTaggedUnion("type")) type WithID = Omit & { readonly id: ID | string } +type WithUsage = Omit & { + readonly usage?: UsageInput +} -const responseID = (value: ResponseID | string) => ResponseID.make(value) const contentBlockID = (value: ContentBlockID | string) => ContentBlockID.make(value) const toolCallID = (value: ToolCallID | string) => ToolCallID.make(value) @@ -233,7 +233,6 @@ const toolCallID = (value: ToolCallID | string) => ToolCallID.make(value) * `events.filter(LLMEvent.guards["tool-call"])`. */ export const LLMEvent = Object.assign(llmEventTagged, { - requestStart: (input: WithID) => RequestStart.make({ ...input, id: responseID(input.id) }), stepStart: StepStart.make, textStart: (input: WithID) => TextStart.make({ ...input, id: contentBlockID(input.id) }), textDelta: (input: WithID) => TextDelta.make({ ...input, id: contentBlockID(input.id) }), @@ -252,11 +251,18 @@ export const LLMEvent = Object.assign(llmEventTagged, { toolCall: (input: WithID) => ToolCall.make({ ...input, id: toolCallID(input.id) }), toolResult: (input: WithID) => ToolResult.make({ ...input, id: toolCallID(input.id) }), toolError: (input: WithID) => ToolError.make({ ...input, id: toolCallID(input.id) }), - stepFinish: StepFinish.make, - requestFinish: RequestFinish.make, + stepFinish: (input: WithUsage) => + StepFinish.make({ + ...input, + usage: input.usage === undefined ? undefined : Usage.from(input.usage), + }), + finish: (input: WithUsage) => + Finish.make({ + ...input, + usage: input.usage === undefined ? undefined : Usage.from(input.usage), + }), providerError: ProviderErrorEvent.make, is: { - requestStart: llmEventTagged.guards["request-start"], stepStart: llmEventTagged.guards["step-start"], textStart: llmEventTagged.guards["text-start"], textDelta: llmEventTagged.guards["text-delta"], @@ -271,7 +277,7 @@ export const LLMEvent = Object.assign(llmEventTagged, { toolResult: llmEventTagged.guards["tool-result"], toolError: llmEventTagged.guards["tool-error"], stepFinish: llmEventTagged.guards["step-finish"], - requestFinish: llmEventTagged.guards["request-finish"], + finish: llmEventTagged.guards.finish, providerError: llmEventTagged.guards["provider-error"], }, }) diff --git a/packages/llm/src/tool-runtime.ts b/packages/llm/src/tool-runtime.ts index f464525827..d83dcc67ad 100644 --- a/packages/llm/src/tool-runtime.ts +++ b/packages/llm/src/tool-runtime.ts @@ -12,6 +12,7 @@ import { ToolFailure, ToolResultPart, type ToolResultValue, + Usage, } from "./schema" import { type AnyTool, type ExecutableTools, type Tools, toDefinitions } from "./tool" @@ -72,19 +73,42 @@ export const stream = (options: StreamOptions): Stream.Strea tools: [...options.request.tools.filter((tool) => !runtimeToolNames.has(tool.name)), ...runtimeTools], }) - const loop = (request: LLMRequest, step: number): Stream.Stream => + const loop = ( + request: LLMRequest, + step: number, + usage: Usage | undefined, + providerMetadata: ProviderMetadata | undefined, + ): Stream.Stream => Stream.unwrap( Effect.gen(function* () { - const state: StepState = { assistantContent: [], toolCalls: [], finishReason: undefined } + const state: StepState = { + assistantContent: [], + toolCalls: [], + finishReason: undefined, + usage: undefined, + providerMetadata: undefined, + } const modelStream = options .stream(request) + .pipe(Stream.map((event) => indexStep(event, step))) .pipe(Stream.tap((event) => Effect.sync(() => accumulate(state, event)))) + .pipe(Stream.filter((event) => event.type !== "finish")) const continuation = Stream.unwrap( Effect.gen(function* () { - if (state.finishReason !== "tool-calls" || state.toolCalls.length === 0) return Stream.empty - if (options.toolExecution === "none") return Stream.empty + const totalUsage = addUsage(usage, state.usage) + const totalProviderMetadata = mergeProviderMetadata(providerMetadata, state.providerMetadata) + const finishStream = Stream.fromIterable([ + LLMEvent.finish({ + reason: state.finishReason ?? "unknown", + usage: totalUsage, + providerMetadata: totalProviderMetadata, + }), + ]) + + if (state.finishReason !== "tool-calls" || state.toolCalls.length === 0) return finishStream + if (options.toolExecution === "none") return finishStream const dispatched = yield* Effect.forEach( state.toolCalls, @@ -93,10 +117,14 @@ export const stream = (options: StreamOptions): Stream.Strea ) const resultStream = Stream.fromIterable(dispatched.flatMap(([call, result]) => emitEvents(call, result))) - if (!options.stopWhen) return resultStream - if (options.stopWhen({ step, request })) return resultStream + if (!options.stopWhen) return resultStream.pipe(Stream.concat(finishStream)) + if (options.stopWhen({ step, request })) return resultStream.pipe(Stream.concat(finishStream)) - return resultStream.pipe(Stream.concat(loop(followUpRequest(request, state, dispatched), step + 1))) + return resultStream.pipe( + Stream.concat( + loop(followUpRequest(request, state, dispatched), step + 1, totalUsage, totalProviderMetadata), + ), + ) }), ) @@ -104,13 +132,21 @@ export const stream = (options: StreamOptions): Stream.Strea }), ) - return loop(initialRequest, 0) + return loop(initialRequest, 0, undefined, undefined) +} + +const indexStep = (event: LLMEvent, index: number): LLMEvent => { + if (event.type === "step-start") return LLMEvent.stepStart({ index }) + if (event.type === "step-finish") return LLMEvent.stepFinish({ ...event, index }) + return event } interface StepState { assistantContent: ContentPart[] toolCalls: ToolCallPart[] finishReason: FinishReason | undefined + usage: Usage | undefined + providerMetadata: ProviderMetadata | undefined } const accumulate = (state: StepState, event: LLMEvent) => { @@ -154,9 +190,43 @@ const accumulate = (state: StepState, event: LLMEvent) => { ) return } - if (event.type === "step-finish" || event.type === "request-finish") { + if (event.type === "step-finish") { state.finishReason = event.reason === "stop" && state.toolCalls.length > 0 ? "tool-calls" : event.reason + state.usage = addUsage(state.usage, event.usage) + state.providerMetadata = mergeProviderMetadata(state.providerMetadata, event.providerMetadata) + return } + if (event.type === "finish") { + state.finishReason ??= event.reason + state.usage ??= event.usage + state.providerMetadata = mergeProviderMetadata(state.providerMetadata, event.providerMetadata) + } +} + +const addUsage = (left: Usage | undefined, right: Usage | undefined) => { + if (!left) return right + if (!right) return left + type UsageKey = + | "inputTokens" + | "outputTokens" + | "nonCachedInputTokens" + | "cacheReadInputTokens" + | "cacheWriteInputTokens" + | "reasoningTokens" + | "totalTokens" + const sum = (key: UsageKey) => + left[key] === undefined && right[key] === undefined ? undefined : Number(left[key] ?? 0) + Number(right[key] ?? 0) + + return new Usage({ + inputTokens: sum("inputTokens"), + outputTokens: sum("outputTokens"), + nonCachedInputTokens: sum("nonCachedInputTokens"), + cacheReadInputTokens: sum("cacheReadInputTokens"), + cacheWriteInputTokens: sum("cacheWriteInputTokens"), + reasoningTokens: sum("reasoningTokens"), + totalTokens: sum("totalTokens"), + providerMetadata: mergeProviderMetadata(left.providerMetadata, right.providerMetadata), + }) } const sameProviderMetadata = (left: ProviderMetadata | undefined, right: ProviderMetadata | undefined) => @@ -200,17 +270,17 @@ const dispatch = (tools: Tools, call: ToolCallPart): Effect.Effect Effect.succeed({ type: "error" as const, value: failure.message } satisfies ToolResultValue), ), ) } -const decodeAndExecute = (tool: AnyTool, input: unknown): Effect.Effect => - tool._decode(input).pipe( +const decodeAndExecute = (tool: AnyTool, call: ToolCallPart): Effect.Effect => + tool._decode(call.input).pipe( Effect.mapError((error) => new ToolFailure({ message: `Invalid tool input: ${error.message}` })), - Effect.flatMap((decoded) => tool.execute!(decoded)), + Effect.flatMap((decoded) => tool.execute!(decoded, { id: call.id, name: call.name })), Effect.flatMap((value) => tool._encode(value).pipe( Effect.mapError( diff --git a/packages/llm/src/tool.ts b/packages/llm/src/tool.ts index 311c8798b6..df0a1cd3d3 100644 --- a/packages/llm/src/tool.ts +++ b/packages/llm/src/tool.ts @@ -1,5 +1,5 @@ import { Effect, JsonSchema, Schema } from "effect" -import type { ToolDefinition as ToolDefinitionClass } from "./schema" +import type { ToolCallPart, ToolDefinition as ToolDefinitionClass } from "./schema" import { ToolDefinition, ToolFailure } from "./schema" /** @@ -8,9 +8,14 @@ import { ToolDefinition, ToolFailure } from "./schema" * beyond pure data conversion belongs in the handler closure. */ export type ToolSchema = Schema.Codec +export interface ToolExecuteContext { + readonly id: ToolCallPart["id"] + readonly name: ToolCallPart["name"] +} export type ToolExecute, Success extends ToolSchema> = ( params: Schema.Schema.Type, + context?: ToolExecuteContext, ) => Effect.Effect, ToolFailure> /** @@ -61,7 +66,7 @@ type TypedToolConfig = { type DynamicToolConfig = { readonly description: string readonly jsonSchema: JsonSchema.JsonSchema - readonly execute?: (params: unknown) => Effect.Effect + readonly execute?: (params: unknown, context?: ToolExecuteContext) => Effect.Effect } /** @@ -110,7 +115,7 @@ export function make, Success extends ToolSch export function make(config: { readonly description: string readonly jsonSchema: JsonSchema.JsonSchema - readonly execute: (params: unknown) => Effect.Effect + readonly execute: (params: unknown, context?: ToolExecuteContext) => Effect.Effect }): AnyExecutableTool export function make(config: { readonly description: string diff --git a/packages/llm/test/adapter.test.ts b/packages/llm/test/adapter.test.ts index 5ac8b9d818..80349a5ae5 100644 --- a/packages/llm/test/adapter.test.ts +++ b/packages/llm/test/adapter.test.ts @@ -51,7 +51,7 @@ const request = LLM.request({ const raiseEvent = (event: FakeEvent): import("../src/schema").LLMEvent => event.type === "finish" - ? { type: "request-finish", reason: event.reason } + ? { type: "finish", reason: event.reason } : { type: "text-delta", id: "text-0", text: event.text } const fakeProtocol = Protocol.make({ @@ -112,8 +112,8 @@ describe("llm route", () => { const events = Array.from(yield* llm.stream(request).pipe(Stream.runCollect)) const response = yield* llm.generate(request) - expect(events.map((event) => event.type)).toEqual(["text-delta", "request-finish"]) - expect(response.events.map((event) => event.type)).toEqual(["text-delta", "request-finish"]) + expect(events.map((event) => event.type)).toEqual(["text-delta", "finish"]) + expect(response.events.map((event) => event.type)).toEqual(["text-delta", "finish"]) }), ) diff --git a/packages/llm/test/llm.test.ts b/packages/llm/test/llm.test.ts index c01fe33b29..a20c48411e 100644 --- a/packages/llm/test/llm.test.ts +++ b/packages/llm/test/llm.test.ts @@ -127,7 +127,7 @@ describe("llm constructors", () => { LLMResponse.text({ events: [ { type: "text-delta", id: "text-0", text: "hi" }, - { type: "request-finish", reason: "stop" }, + { type: "finish", reason: "stop" }, ], }), ).toBe("hi") diff --git a/packages/llm/test/provider/anthropic-messages.test.ts b/packages/llm/test/provider/anthropic-messages.test.ts index 6417f73c2b..71204bcd63 100644 --- a/packages/llm/test/provider/anthropic-messages.test.ts +++ b/packages/llm/test/provider/anthropic-messages.test.ts @@ -124,7 +124,7 @@ describe("Anthropic Messages route", () => { providerMetadata: { anthropic: { signature: "sig_1" } }, }) expect(response.events.at(-1)).toMatchObject({ - type: "request-finish", + type: "finish", reason: "stop", providerMetadata: { anthropic: { stopSequence: "\n\nHuman:" } }, }) @@ -182,7 +182,7 @@ describe("Anthropic Messages route", () => { }, { type: "step-finish", index: 0, reason: "tool-calls", usage, providerMetadata: undefined }, { - type: "request-finish", + type: "finish", reason: "tool-calls", providerMetadata: undefined, usage, @@ -275,7 +275,7 @@ describe("Anthropic Messages route", () => { providerMetadata: { anthropic: { blockType: "web_search_tool_result" } }, }) expect(response.text).toBe("Found it.") - expect(response.events.at(-1)).toMatchObject({ type: "request-finish", reason: "stop" }) + expect(response.events.at(-1)).toMatchObject({ type: "finish", reason: "stop" }) }), ) diff --git a/packages/llm/test/provider/bedrock-converse.test.ts b/packages/llm/test/provider/bedrock-converse.test.ts index 7d1ad3f309..ffdd6e8008 100644 --- a/packages/llm/test/provider/bedrock-converse.test.ts +++ b/packages/llm/test/provider/bedrock-converse.test.ts @@ -169,12 +169,12 @@ describe("Bedrock Converse route", () => { const response = yield* LLMClient.generate(baseRequest).pipe(Effect.provide(fixedBytes(body))) expect(response.text).toBe("Hello!") - const finishes = response.events.filter((event) => event.type === "request-finish") + const finishes = response.events.filter((event) => event.type === "finish") // Bedrock splits the finish across `messageStop` (carries reason) and // `metadata` (carries usage). We consolidate them into a single - // terminal `request-finish` event with both. + // terminal `finish` event with both. expect(finishes).toHaveLength(1) - expect(finishes[0]).toMatchObject({ type: "request-finish", reason: "stop" }) + expect(finishes[0]).toMatchObject({ type: "finish", reason: "stop" }) expect(response.usage).toMatchObject({ inputTokens: 5, outputTokens: 2, @@ -213,7 +213,7 @@ describe("Bedrock Converse route", () => { { type: "tool-input-delta", id: "tool_1", name: "lookup", text: '{"query"' }, { type: "tool-input-delta", id: "tool_1", name: "lookup", text: ':"weather"}' }, ]) - expect(response.events.at(-1)).toMatchObject({ type: "request-finish", reason: "tool-calls" }) + expect(response.events.at(-1)).toMatchObject({ type: "finish", reason: "tool-calls" }) }), ) diff --git a/packages/llm/test/provider/gemini.test.ts b/packages/llm/test/provider/gemini.test.ts index 80c32c58b3..7e6bbc8466 100644 --- a/packages/llm/test/provider/gemini.test.ts +++ b/packages/llm/test/provider/gemini.test.ts @@ -232,7 +232,7 @@ describe("Gemini route", () => { { type: "text-end", id: "text-0" }, { type: "step-finish", index: 0, reason: "stop", usage, providerMetadata: undefined }, { - type: "request-finish", + type: "finish", reason: "stop", usage, }, @@ -291,7 +291,7 @@ describe("Gemini route", () => { }, { type: "step-finish", index: 0, reason: "tool-calls", usage, providerMetadata: undefined }, { - type: "request-finish", + type: "finish", reason: "tool-calls", usage, }, @@ -325,7 +325,7 @@ describe("Gemini route", () => { { type: "tool-call", id: "tool_0", name: "lookup", input: { query: "weather" } }, { type: "tool-call", id: "tool_1", name: "lookup", input: { query: "news" } }, ]) - expect(response.events.at(-1)).toMatchObject({ type: "request-finish", reason: "tool-calls" }) + expect(response.events.at(-1)).toMatchObject({ type: "finish", reason: "tool-calls" }) }), ) @@ -344,10 +344,10 @@ describe("Gemini route", () => { ), ) - expect(length.events.map((event) => event.type)).toEqual(["step-start", "step-finish", "request-finish"]) - expect(length.events.at(-1)).toMatchObject({ type: "request-finish", reason: "length" }) - expect(filtered.events.map((event) => event.type)).toEqual(["step-start", "step-finish", "request-finish"]) - expect(filtered.events.at(-1)).toMatchObject({ type: "request-finish", reason: "content-filter" }) + expect(length.events.map((event) => event.type)).toEqual(["step-start", "step-finish", "finish"]) + expect(length.events.at(-1)).toMatchObject({ type: "finish", reason: "length" }) + expect(filtered.events.map((event) => event.type)).toEqual(["step-start", "step-finish", "finish"]) + expect(filtered.events.at(-1)).toMatchObject({ type: "finish", reason: "content-filter" }) }), ) diff --git a/packages/llm/test/provider/openai-chat.test.ts b/packages/llm/test/provider/openai-chat.test.ts index 115c58849c..4303a69ffa 100644 --- a/packages/llm/test/provider/openai-chat.test.ts +++ b/packages/llm/test/provider/openai-chat.test.ts @@ -249,7 +249,7 @@ describe("OpenAI Chat route", () => { { type: "text-end", id: "text-0" }, { type: "step-finish", index: 0, reason: "stop", usage, providerMetadata: undefined }, { - type: "request-finish", + type: "finish", reason: "stop", usage, }, @@ -288,7 +288,7 @@ describe("OpenAI Chat route", () => { providerMetadata: undefined, }, { type: "step-finish", index: 0, reason: "tool-calls", usage: undefined, providerMetadata: undefined }, - { type: "request-finish", reason: "tool-calls", usage: undefined }, + { type: "finish", reason: "tool-calls", usage: undefined }, ]) }), ) diff --git a/packages/llm/test/provider/openai-compatible-chat.test.ts b/packages/llm/test/provider/openai-compatible-chat.test.ts index 7759ff7202..50aac41091 100644 --- a/packages/llm/test/provider/openai-compatible-chat.test.ts +++ b/packages/llm/test/provider/openai-compatible-chat.test.ts @@ -231,7 +231,7 @@ describe("OpenAI-compatible Chat route", () => { expect(response.text).toBe("Hello!") expect(response.usage).toMatchObject({ inputTokens: 5, outputTokens: 2, totalTokens: 7 }) - expect(response.events.at(-1)).toMatchObject({ type: "request-finish", reason: "stop" }) + expect(response.events.at(-1)).toMatchObject({ type: "finish", reason: "stop" }) }), ) }) diff --git a/packages/llm/test/provider/openai-responses.test.ts b/packages/llm/test/provider/openai-responses.test.ts index 8b4469f4ed..63452f61b0 100644 --- a/packages/llm/test/provider/openai-responses.test.ts +++ b/packages/llm/test/provider/openai-responses.test.ts @@ -366,7 +366,7 @@ describe("OpenAI Responses route", () => { usage, }, { - type: "request-finish", + type: "finish", reason: "stop", providerMetadata: { openai: { responseId: "resp_1", serviceTier: "default" } }, usage, @@ -447,7 +447,7 @@ describe("OpenAI Responses route", () => { }, { type: "step-finish", index: 0, reason: "tool-calls", usage, providerMetadata: undefined }, { - type: "request-finish", + type: "finish", reason: "tool-calls", providerMetadata: undefined, usage, diff --git a/packages/llm/test/recorded-scenarios.ts b/packages/llm/test/recorded-scenarios.ts index bdba8580fd..3af7a77608 100644 --- a/packages/llm/test/recorded-scenarios.ts +++ b/packages/llm/test/recorded-scenarios.ts @@ -120,8 +120,8 @@ export const runWeatherToolLoop = (request: LLMRequest) => export const expectFinish = ( events: ReadonlyArray, - reason: Extract["reason"], -) => expect(events.at(-1)).toMatchObject({ type: "request-finish", reason }) + reason: Extract["reason"], +) => expect(events.at(-1)).toMatchObject({ type: "finish", reason }) export const expectWeatherToolCall = (response: LLMResponse) => expect(response.toolCalls).toMatchObject([ @@ -129,10 +129,12 @@ export const expectWeatherToolCall = (response: LLMResponse) => ]) export const expectWeatherToolLoop = (events: ReadonlyArray) => { - const finishes = events.filter(LLMEvent.is.requestFinish) - expect(finishes).toHaveLength(2) - expect(finishes[0]?.reason).toBe("tool-calls") - expect(finishes.at(-1)?.reason).toBe("stop") + const finishes = events.filter(LLMEvent.is.finish) + expect(finishes).toHaveLength(1) + expect(finishes[0]?.reason).toBe("stop") + + const stepFinishes = events.filter(LLMEvent.is.stepFinish) + expect(stepFinishes.map((event) => event.reason)).toEqual(["tool-calls", "stop"]) const toolCalls = events.filter(LLMEvent.is.toolCall) expect(toolCalls).toHaveLength(1) @@ -272,7 +274,7 @@ export const eventSummary = (events: ReadonlyArray) => { summary.push({ type: "tool-error", name: event.name, message: event.message }) continue } - if (event.type === "request-finish") { + if (event.type === "finish") { summary.push({ type: "finish", reason: event.reason, usage: usageSummary(event.usage) }) } } diff --git a/packages/llm/test/schema.test.ts b/packages/llm/test/schema.test.ts index 23bd9fd9bb..01d6fadd9f 100644 --- a/packages/llm/test/schema.test.ts +++ b/packages/llm/test/schema.test.ts @@ -44,6 +44,11 @@ describe("llm schema", () => { expect(() => Schema.decodeUnknownSync(LLMEvent)({ type: "bogus" })).toThrow() }) + test("finish constructors accept usage input", () => { + expect(LLMEvent.stepFinish({ index: 0, reason: "stop", usage: { inputTokens: 1 } }).usage).toBeInstanceOf(Usage) + expect(LLMEvent.finish({ reason: "stop", usage: { outputTokens: 2 } }).usage).toBeInstanceOf(Usage) + }) + test("content part tagged union exposes guards", () => { expect(ContentPart.guards.text({ type: "text", text: "hi" })).toBe(true) expect(ContentPart.guards.media({ type: "text", text: "hi" })).toBe(false) diff --git a/packages/llm/test/tool-runtime.test.ts b/packages/llm/test/tool-runtime.test.ts index 040a11fb68..573021c4c2 100644 --- a/packages/llm/test/tool-runtime.test.ts +++ b/packages/llm/test/tool-runtime.test.ts @@ -4,7 +4,8 @@ import { GenerationOptions, LLM, LLMEvent, LLMRequest, LLMResponse, ToolChoice } import { LLMClient } from "../src/route" import * as AnthropicMessages from "../src/protocols/anthropic-messages" import * as OpenAIChat from "../src/protocols/openai-chat" -import { tool, ToolFailure } from "../src/tool" +import { tool, ToolFailure, type ToolExecuteContext } from "../src/tool" +import { ToolRuntime } from "../src/tool-runtime" import { it } from "./lib/effect" import * as TestToolRuntime from "./lib/tool-runtime" import { dynamicResponse, scriptedResponses } from "./lib/http" @@ -129,7 +130,7 @@ describe("LLMClient tools", () => { name: "get_weather", result: { type: "json", value: { temperature: 22, condition: "sunny" } }, }) - expect(events.at(-1)?.type).toBe("request-finish") + expect(events.at(-1)?.type).toBe("finish") expect(LLMResponse.text({ events })).toBe("It's sunny in Paris.") }), ) @@ -148,11 +149,40 @@ describe("LLMClient tools", () => { ), ) - expect(events.filter(LLMEvent.is.requestFinish)).toHaveLength(1) + expect(events.filter(LLMEvent.is.finish)).toHaveLength(1) expect(events.find(LLMEvent.is.toolResult)).toMatchObject({ type: "tool-result", id: "call_1" }) }), ) + it.effect("passes tool call context to execute", () => + Effect.gen(function* () { + let context: ToolExecuteContext | undefined + const contextual = tool({ + description: "Capture tool context.", + parameters: Schema.Struct({ value: Schema.String }), + success: Schema.Struct({ ok: Schema.Boolean }), + execute: (_params, ctx) => + Effect.sync(() => { + context = ctx + return { ok: true } + }), + }) + const events = Array.from( + yield* TestToolRuntime.runTools({ request: baseRequest, tools: { contextual } }).pipe( + Stream.runCollect, + Effect.provide( + scriptedResponses([ + sseEvents(toolCallChunk("call_ctx", "contextual", '{"value":"x"}'), finishChunk("tool_calls")), + ]), + ), + ), + ) + + expect(events.some(LLMEvent.is.toolResult)).toBe(true) + expect(context).toEqual({ id: "call_ctx", name: "contextual" }) + }), + ) + it.effect("can expose tool schemas without executing tool calls", () => Effect.gen(function* () { const layer = scriptedResponses([ @@ -319,7 +349,7 @@ describe("LLMClient tools", () => { "text-delta", "text-end", "step-finish", - "request-finish", + "finish", ]) expect(LLMResponse.text({ events })).toBe("Done.") }), @@ -343,7 +373,57 @@ describe("LLMClient tools", () => { ), ) - expect(events.filter(LLMEvent.is.requestFinish)).toHaveLength(2) + expect(events.filter(LLMEvent.is.finish)).toHaveLength(1) + expect(events.filter(LLMEvent.is.stepStart).map((event) => event.index)).toEqual([0, 1]) + expect(events.filter(LLMEvent.is.stepFinish).map((event) => event.index)).toEqual([0, 1]) + }), + ) + + it.effect("emits one final finish with aggregate usage", () => + Effect.gen(function* () { + let calls = 0 + const events = Array.from( + yield* ToolRuntime.stream({ + request: baseRequest, + tools: { get_weather }, + stopWhen: ToolRuntime.stepCountIs(2), + stream: () => + Stream.fromIterable( + calls++ === 0 + ? [ + LLMEvent.stepStart({ index: 0 }), + LLMEvent.toolCall({ id: "call_1", name: "get_weather", input: { city: "Paris" } }), + LLMEvent.stepFinish({ + index: 0, + reason: "tool-calls", + usage: { inputTokens: 1, outputTokens: 2, totalTokens: 3 }, + }), + LLMEvent.finish({ + reason: "tool-calls", + usage: { inputTokens: 1, outputTokens: 2, totalTokens: 3 }, + }), + ] + : [ + LLMEvent.stepStart({ index: 0 }), + LLMEvent.textDelta({ id: "text_1", text: "Done." }), + LLMEvent.stepFinish({ + index: 0, + reason: "stop", + usage: { inputTokens: 4, outputTokens: 5, totalTokens: 9 }, + }), + LLMEvent.finish({ reason: "stop", usage: { inputTokens: 4, outputTokens: 5, totalTokens: 9 } }), + ], + ), + }).pipe(Stream.runCollect), + ) + + expect(events.filter(LLMEvent.is.stepFinish).map((event) => event.index)).toEqual([0, 1]) + expect(events.filter(LLMEvent.is.finish)).toHaveLength(1) + expect(events.find(LLMEvent.is.finish)?.usage).toMatchObject({ + inputTokens: 5, + outputTokens: 7, + totalTokens: 12, + }) }), ) @@ -362,7 +442,7 @@ describe("LLMClient tools", () => { }).pipe(Stream.runCollect, Effect.provide(layer)), ) - expect(events.filter(LLMEvent.is.requestFinish)).toHaveLength(1) + expect(events.filter(LLMEvent.is.finish)).toHaveLength(1) expect(events.find(LLMEvent.is.toolResult)).toMatchObject({ type: "tool-result", id: "call_1" }) }), ) From af4ab017cbf603be0778071a152f818d4836ec0b Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 12 May 2026 16:30:35 -0400 Subject: [PATCH 58/70] test(session): migrate structured output integration test (#27143) --- .../structured-output-integration.test.ts | 359 ++++++++---------- 1 file changed, 164 insertions(+), 195 deletions(-) diff --git a/packages/opencode/test/session/structured-output-integration.test.ts b/packages/opencode/test/session/structured-output-integration.test.ts index da2ffb7937..125c63c0f9 100644 --- a/packages/opencode/test/session/structured-output-integration.test.ts +++ b/packages/opencode/test/session/structured-output-integration.test.ts @@ -1,250 +1,219 @@ import { describe, expect, test } from "bun:test" -import path from "path" import { Effect, Layer } from "effect" import { Session } from "@/session/session" import { SessionPrompt } from "../../src/session/prompt" import * as Log from "@opencode-ai/core/util/log" -import { Instance } from "../../src/project/instance" -import { WithInstance } from "../../src/project/with-instance" import { MessageV2 } from "../../src/session/message-v2" +import { testEffect } from "../lib/effect" -const projectRoot = path.join(__dirname, "../..") void Log.init({ print: false }) // Skip tests if no API key is available const hasApiKey = !!process.env.ANTHROPIC_API_KEY - -// Helper to run test within Instance context -async function withInstance(fn: () => Promise): Promise { - return WithInstance.provide({ - directory: projectRoot, - fn, - }) -} - -function run(fx: Effect.Effect) { - return Effect.runPromise( - fx.pipe(Effect.scoped, Effect.provide(Layer.mergeAll(SessionPrompt.defaultLayer, Session.defaultLayer))), - ) -} +const it = testEffect(Layer.mergeAll(SessionPrompt.defaultLayer, Session.defaultLayer)) +const live = hasApiKey ? it.instance : it.instance.skip describe("StructuredOutput Integration", () => { - test.skipIf(!hasApiKey)( + live( "produces structured output with simple schema", - async () => { - await withInstance(() => - run( - Effect.gen(function* () { - const prompt = yield* SessionPrompt.Service - const sessions = yield* Session.Service - const session = yield* sessions.create({ title: "Structured Output Test" }) + () => + Effect.gen(function* () { + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const session = yield* sessions.create({ title: "Structured Output Test" }) - const result = yield* prompt.prompt({ - sessionID: session.id, - parts: [ - { - type: "text", - text: "What is 2 + 2? Provide a simple answer.", - }, - ], - format: { - type: "json_schema", - schema: { - type: "object", - properties: { - answer: { type: "number", description: "The numerical answer" }, - explanation: { type: "string", description: "Brief explanation" }, - }, - required: ["answer"], - }, - retryCount: 0, + const result = yield* prompt.prompt({ + sessionID: session.id, + parts: [ + { + type: "text", + text: "What is 2 + 2? Provide a simple answer.", + }, + ], + format: { + type: "json_schema", + schema: { + type: "object", + properties: { + answer: { type: "number", description: "The numerical answer" }, + explanation: { type: "string", description: "Brief explanation" }, }, - }) + required: ["answer"], + }, + retryCount: 0, + }, + }) - // Verify structured output was captured (only on assistant messages) - expect(result.info.role).toBe("assistant") - if (result.info.role === "assistant") { - expect(result.info.structured).toBeDefined() - expect(typeof result.info.structured).toBe("object") + // Verify structured output was captured (only on assistant messages) + expect(result.info.role).toBe("assistant") + if (result.info.role === "assistant") { + expect(result.info.structured).toBeDefined() + expect(typeof result.info.structured).toBe("object") - const output = result.info.structured as any - expect(output.answer).toBe(4) + const output = result.info.structured as any + expect(output.answer).toBe(4) - // Verify no error was set - expect(result.info.error).toBeUndefined() - } + // Verify no error was set + expect(result.info.error).toBeUndefined() + } - // Clean up - // Note: Not removing session to avoid race with background SessionSummary.summarize - }), - ), - ) - }, + // Clean up + // Note: Not removing session to avoid race with background SessionSummary.summarize + }), + { git: true }, 60000, ) - test.skipIf(!hasApiKey)( + live( "produces structured output with nested objects", - async () => { - await withInstance(() => - run( - Effect.gen(function* () { - const prompt = yield* SessionPrompt.Service - const sessions = yield* Session.Service - const session = yield* sessions.create({ title: "Nested Schema Test" }) + () => + Effect.gen(function* () { + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const session = yield* sessions.create({ title: "Nested Schema Test" }) - const result = yield* prompt.prompt({ - sessionID: session.id, - parts: [ - { - type: "text", - text: "Tell me about Anthropic company in a structured format.", - }, - ], - format: { - type: "json_schema", - schema: { + const result = yield* prompt.prompt({ + sessionID: session.id, + parts: [ + { + type: "text", + text: "Tell me about Anthropic company in a structured format.", + }, + ], + format: { + type: "json_schema", + schema: { + type: "object", + properties: { + company: { type: "object", properties: { - company: { - type: "object", - properties: { - name: { type: "string" }, - founded: { type: "number" }, - }, - required: ["name", "founded"], - }, - products: { - type: "array", - items: { type: "string" }, - }, + name: { type: "string" }, + founded: { type: "number" }, }, - required: ["company"], + required: ["name", "founded"], + }, + products: { + type: "array", + items: { type: "string" }, }, - retryCount: 0, }, - }) + required: ["company"], + }, + retryCount: 0, + }, + }) - // Verify structured output was captured (only on assistant messages) - expect(result.info.role).toBe("assistant") - if (result.info.role === "assistant") { - expect(result.info.structured).toBeDefined() - const output = result.info.structured as any + // Verify structured output was captured (only on assistant messages) + expect(result.info.role).toBe("assistant") + if (result.info.role === "assistant") { + expect(result.info.structured).toBeDefined() + const output = result.info.structured as any - expect(output.company).toBeDefined() - expect(output.company.name).toBe("Anthropic") - expect(typeof output.company.founded).toBe("number") + expect(output.company).toBeDefined() + expect(output.company.name).toBe("Anthropic") + expect(typeof output.company.founded).toBe("number") - if (output.products) { - expect(Array.isArray(output.products)).toBe(true) - } + if (output.products) { + expect(Array.isArray(output.products)).toBe(true) + } - // Verify no error was set - expect(result.info.error).toBeUndefined() - } + // Verify no error was set + expect(result.info.error).toBeUndefined() + } - // Clean up - // Note: Not removing session to avoid race with background SessionSummary.summarize - }), - ), - ) - }, + // Clean up + // Note: Not removing session to avoid race with background SessionSummary.summarize + }), + { git: true }, 60000, ) - test.skipIf(!hasApiKey)( + live( "works with text outputFormat (default)", - async () => { - await withInstance(() => - run( - Effect.gen(function* () { - const prompt = yield* SessionPrompt.Service - const sessions = yield* Session.Service - const session = yield* sessions.create({ title: "Text Output Test" }) + () => + Effect.gen(function* () { + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const session = yield* sessions.create({ title: "Text Output Test" }) - const result = yield* prompt.prompt({ - sessionID: session.id, - parts: [ - { - type: "text", - text: "Say hello.", - }, - ], - format: { - type: "text", - }, - }) + const result = yield* prompt.prompt({ + sessionID: session.id, + parts: [ + { + type: "text", + text: "Say hello.", + }, + ], + format: { + type: "text", + }, + }) - // Verify no structured output (text mode) and no error - expect(result.info.role).toBe("assistant") - if (result.info.role === "assistant") { - expect(result.info.structured).toBeUndefined() - expect(result.info.error).toBeUndefined() - } + // Verify no structured output (text mode) and no error + expect(result.info.role).toBe("assistant") + if (result.info.role === "assistant") { + expect(result.info.structured).toBeUndefined() + expect(result.info.error).toBeUndefined() + } - // Verify we got a response with parts - expect(result.parts.length).toBeGreaterThan(0) + // Verify we got a response with parts + expect(result.parts.length).toBeGreaterThan(0) - // Clean up - // Note: Not removing session to avoid race with background SessionSummary.summarize - }), - ), - ) - }, + // Clean up + // Note: Not removing session to avoid race with background SessionSummary.summarize + }), + { git: true }, 60000, ) - test.skipIf(!hasApiKey)( + live( "stores outputFormat on user message", - async () => { - await withInstance(() => - run( - Effect.gen(function* () { - const prompt = yield* SessionPrompt.Service - const sessions = yield* Session.Service - const session = yield* sessions.create({ title: "OutputFormat Storage Test" }) + () => + Effect.gen(function* () { + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const session = yield* sessions.create({ title: "OutputFormat Storage Test" }) - yield* prompt.prompt({ - sessionID: session.id, - parts: [ - { - type: "text", - text: "What is 1 + 1?", - }, - ], - format: { - type: "json_schema", - schema: { - type: "object", - properties: { - result: { type: "number" }, - }, - required: ["result"], - }, - retryCount: 3, + yield* prompt.prompt({ + sessionID: session.id, + parts: [ + { + type: "text", + text: "What is 1 + 1?", + }, + ], + format: { + type: "json_schema", + schema: { + type: "object", + properties: { + result: { type: "number" }, }, - }) + required: ["result"], + }, + retryCount: 3, + }, + }) - // Get all messages from session - const messages = yield* sessions.messages({ sessionID: session.id }) - const userMessage = messages.find((m) => m.info.role === "user") + // Get all messages from session + const messages = yield* sessions.messages({ sessionID: session.id }) + const userMessage = messages.find((m) => m.info.role === "user") - // Verify outputFormat was stored on user message - expect(userMessage).toBeDefined() - if (userMessage?.info.role === "user") { - expect(userMessage.info.format).toBeDefined() - expect(userMessage.info.format?.type).toBe("json_schema") - if (userMessage.info.format?.type === "json_schema") { - expect(userMessage.info.format.retryCount).toBe(3) - } - } + // Verify outputFormat was stored on user message + expect(userMessage).toBeDefined() + if (userMessage?.info.role === "user") { + expect(userMessage.info.format).toBeDefined() + expect(userMessage.info.format?.type).toBe("json_schema") + if (userMessage.info.format?.type === "json_schema") { + expect(userMessage.info.format.retryCount).toBe(3) + } + } - // Clean up - // Note: Not removing session to avoid race with background SessionSummary.summarize - }), - ), - ) - }, + // Clean up + // Note: Not removing session to avoid race with background SessionSummary.summarize + }), + { git: true }, 60000, ) From dc9d6a08cbeac02ea02197eb85f6aa409bffa0c4 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 12 May 2026 16:31:38 -0400 Subject: [PATCH 59/70] test: migrate agent color config tests (#27139) --- .../opencode/test/config/agent-color.test.ts | 68 ++++++++----------- 1 file changed, 30 insertions(+), 38 deletions(-) diff --git a/packages/opencode/test/config/agent-color.test.ts b/packages/opencode/test/config/agent-color.test.ts index 49509156ab..369b3a1fd1 100644 --- a/packages/opencode/test/config/agent-color.test.ts +++ b/packages/opencode/test/config/agent-color.test.ts @@ -1,58 +1,50 @@ import { test, expect } from "bun:test" import { Effect, Layer } from "effect" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" -import path from "path" -import { provideInstance, tmpdirScoped } from "../fixture/fixture" import { Config } from "@/config/config" import { Agent as AgentSvc } from "../../src/agent/agent" import { Color } from "@/util/color" -import { AppRuntime } from "../../src/effect/app-runtime" import { testEffect } from "../lib/effect" -const it = testEffect(Layer.mergeAll(AgentSvc.defaultLayer, CrossSpawnSpawner.defaultLayer)) +const it = testEffect(Layer.mergeAll(Config.defaultLayer, AgentSvc.defaultLayer, CrossSpawnSpawner.defaultLayer)) -const writeConfig = (dir: string, agent: Config.Info["agent"]) => - Effect.promise(() => - Bun.write( - path.join(dir, "opencode.json"), - JSON.stringify({ - $schema: "https://opencode.ai/config.json", - agent, - }), - ), - ) - -it.live("agent color parsed from project config", () => - Effect.gen(function* () { - const dir = yield* tmpdirScoped() - yield* writeConfig(dir, { - build: { color: "#FFA500" }, - plan: { color: "primary" }, - }) - - yield* Effect.gen(function* () { - const cfg = yield* Effect.promise(() => AppRuntime.runPromise(Config.Service.use((svc) => svc.get()))) +it.instance( + "agent color parsed from project config", + () => + Effect.gen(function* () { + const cfg = yield* Config.Service.use((svc) => svc.get()) expect(cfg.agent?.["build"]?.color).toBe("#FFA500") expect(cfg.agent?.["plan"]?.color).toBe("primary") - }).pipe(provideInstance(dir)) - }), + }), + { + git: true, + config: { + agent: { + build: { color: "#FFA500" }, + plan: { color: "primary" }, + }, + }, + }, ) -it.live("Agent.get includes color from config", () => - Effect.gen(function* () { - const dir = yield* tmpdirScoped() - yield* writeConfig(dir, { - plan: { color: "#A855F7" }, - build: { color: "accent" }, - }) - - yield* Effect.gen(function* () { +it.instance( + "Agent.get includes color from config", + () => + Effect.gen(function* () { const plan = yield* AgentSvc.Service.use((svc) => svc.get("plan")) expect(plan?.color).toBe("#A855F7") const build = yield* AgentSvc.Service.use((svc) => svc.get("build")) expect(build?.color).toBe("accent") - }).pipe(provideInstance(dir)) - }), + }), + { + git: true, + config: { + agent: { + plan: { color: "#A855F7" }, + build: { color: "accent" }, + }, + }, + }, ) test("Color.hexToAnsiBold converts valid hex to ANSI", () => { From 2017dc165c7c2bf07c3578d1405caf1c82d66598 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 12 May 2026 16:32:23 -0400 Subject: [PATCH 60/70] test: migrate negative tokens regression to Effect runner (#27141) --- .../server/negative-tokens-regression.test.ts | 124 ++++++++---------- 1 file changed, 54 insertions(+), 70 deletions(-) diff --git a/packages/opencode/test/server/negative-tokens-regression.test.ts b/packages/opencode/test/server/negative-tokens-regression.test.ts index 77ad1bc279..290023ead7 100644 --- a/packages/opencode/test/server/negative-tokens-regression.test.ts +++ b/packages/opencode/test/server/negative-tokens-regression.test.ts @@ -5,11 +5,10 @@ // negative. The pre-fix `safe()` clamp only guarded against non-finite. The // strict `NonNegativeInt` schema then made every load of the message list // fail to encode, killing Desktop boot for every user with such a row. -import { afterEach, describe, expect } from "bun:test" +import { describe, expect } from "bun:test" import { Effect } from "effect" import { eq } from "drizzle-orm" import { ModelID, ProviderID } from "../../src/provider/schema" -import { WithInstance } from "../../src/project/with-instance" import { Server } from "../../src/server/server" import { SessionPaths } from "../../src/server/routes/instance/httpapi/groups/session" import { Session } from "@/session/session" @@ -17,81 +16,66 @@ import { MessageID, PartID } from "../../src/session/schema" import * as Database from "@/storage/db" import { PartTable } from "@/session/session.sql" import { resetDatabase } from "../fixture/db" -import { disposeAllInstances, tmpdir } from "../fixture/fixture" -import { it } from "../lib/effect" +import { TestInstance } from "../fixture/fixture" +import { testEffect } from "../lib/effect" -afterEach(async () => { - await disposeAllInstances() - await resetDatabase() -}) +const it = testEffect(Session.defaultLayer) -function seedNegativeTokenSession(directory: string) { - return Effect.promise(async () => - WithInstance.provide({ - directory, - fn: () => - Effect.runPromise( - Effect.gen(function* () { - const session = yield* Session.Service - const info = yield* session.create({}) - const message = yield* session.updateMessage({ - id: MessageID.ascending(), - role: "user", - sessionID: info.id, - agent: "build", - model: { providerID: ProviderID.make("test"), modelID: ModelID.make("test") }, - time: { created: Date.now() }, - }) - const partID = PartID.ascending() - yield* session.updatePart({ - id: partID, - sessionID: info.id, - messageID: message.id, - type: "step-finish", - reason: "stop", - cost: 0, - tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, - }) +function seedNegativeTokenSession() { + return Effect.gen(function* () { + const session = yield* Session.Service + const info = yield* session.create({}) + const message = yield* session.updateMessage({ + id: MessageID.ascending(), + role: "user", + sessionID: info.id, + agent: "build", + model: { providerID: ProviderID.make("test"), modelID: ModelID.make("test") }, + time: { created: Date.now() }, + }) + const partID = PartID.ascending() + yield* session.updatePart({ + id: partID, + sessionID: info.id, + messageID: message.id, + type: "step-finish", + reason: "stop", + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + }) - // Bypass the schema with a direct SQL update to install the - // negative `output` value we want to test loading. - Database.use((db) => - db - .update(PartTable) - .set({ - data: { - type: "step-finish", - reason: "stop", - cost: 0, - tokens: { input: 0, output: -42, reasoning: 0, cache: { read: 0, write: 0 } }, - } as never, - }) - .where(eq(PartTable.id, partID)) - .run(), - ) + // Bypass the schema with a direct SQL update to install the + // negative `output` value we want to test loading. + Database.use((db) => + db + .update(PartTable) + .set({ + data: { + type: "step-finish", + reason: "stop", + cost: 0, + tokens: { input: 0, output: -42, reasoning: 0, cache: { read: 0, write: 0 } }, + } as never, + }) + .where(eq(PartTable.id, partID)) + .run(), + ) - return info.id - }).pipe(Effect.provide(Session.defaultLayer)), - ), - }), - ) + return info.id + }) } describe("messages endpoint tolerates legacy negative token counts", () => { - it.live( + it.instance( "returns 200 even when a step-finish part has tokens.output < 0", - Effect.acquireRelease( - Effect.promise(() => tmpdir({ config: { formatter: false, lsp: false } })), - (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), - ).pipe( - Effect.flatMap((tmp) => - Effect.gen(function* () { - const sessionID = yield* seedNegativeTokenSession(tmp.path) - const url = `${SessionPaths.messages.replace(":sessionID", sessionID)}?limit=80&directory=${encodeURIComponent(tmp.path)}` - const res = yield* Effect.promise(async () => Server.Default().app.request(url)) - expect(res.status, "messages endpoint 400'd on legacy negative tokens").not.toBe(400) - }), - ), - ), + Effect.gen(function* () { + yield* Effect.addFinalizer(() => Effect.promise(() => resetDatabase())) + const test = yield* TestInstance + const sessionID = yield* seedNegativeTokenSession() + const url = `${SessionPaths.messages.replace(":sessionID", sessionID)}?limit=80&directory=${encodeURIComponent(test.directory)}` + const res = yield* Effect.promise(async () => Server.Default().app.request(url)) + expect(res.status, "messages endpoint 400'd on legacy negative tokens").not.toBe(400) + }), + { git: true, config: { formatter: false, lsp: false } }, ) }) From ca28dd02ec8eb98a7ad8afea83fdbc3105c48453 Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Tue, 12 May 2026 15:33:16 -0500 Subject: [PATCH 61/70] fix(compaction): restore tail turns after summarization (#27145) --- packages/opencode/src/config/config.ts | 4 +- packages/opencode/src/session/compaction.ts | 60 ++++++++----------- packages/opencode/src/session/message-v2.ts | 44 ++++++++++++-- .../opencode/test/session/compaction.test.ts | 50 +++++++--------- .../test/session/messages-pagination.test.ts | 16 ++--- 5 files changed, 96 insertions(+), 78 deletions(-) diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index d00c97f463..545e48e64d 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -261,10 +261,10 @@ export const Info = Schema.Struct({ }), tail_turns: Schema.optional(NonNegativeInt).annotate({ description: - "Number of recent user turns, including their following assistant/tool responses, to serialize into the compaction summary (default: 2)", + "Number of recent user turns, including their following assistant/tool responses, to keep verbatim during compaction (default: 2)", }), preserve_recent_tokens: Schema.optional(NonNegativeInt).annotate({ - description: "Maximum number of tokens from recent turns to serialize into the compaction summary", + description: "Maximum number of tokens from recent turns to preserve verbatim after compaction", }), reserved: Schema.optional(NonNegativeInt).annotate({ description: "Token buffer for compaction. Leaves enough window to avoid overflow during compaction.", diff --git a/packages/opencode/src/session/compaction.ts b/packages/opencode/src/session/compaction.ts index 3ca4f074f9..4eafbdf749 100644 --- a/packages/opencode/src/session/compaction.ts +++ b/packages/opencode/src/session/compaction.ts @@ -79,10 +79,12 @@ Rules: type Turn = { start: number end: number + id: MessageID } type Tail = { start: number + id: MessageID } type CompletedCompaction = { @@ -119,41 +121,19 @@ function completedCompactions(messages: MessageV2.WithParts[]) { }) } -function buildPrompt(input: { previousSummary?: string; context: string[]; tail?: string }) { - const source = input.tail - ? "the conversation history above and the serialized recent conversation tail below" - : "the conversation history above" +function buildPrompt(input: { previousSummary?: string; context: string[] }) { const anchor = input.previousSummary ? [ - `Update the anchored summary below using ${source}.`, + "Update the anchored summary below using the conversation history above.", "Preserve still-true details, remove stale details, and merge in the new facts.", "", input.previousSummary, "", ].join("\n") - : `Create a new anchored summary from ${source}.` - const tail = input.tail - ? [ - "Fold this serialized recent conversation tail into the summary; it is not provider message history.", - "", - input.tail, - "", - ].join("\n") - : undefined - return [anchor, ...(tail ? [tail] : []), SUMMARY_TEMPLATE, ...input.context].join("\n\n") + : "Create a new anchored summary from the conversation history above." + return [anchor, SUMMARY_TEMPLATE, ...input.context].join("\n\n") } -const serialize = Effect.fn("SessionCompaction.serialize")(function* (input: { - messages: MessageV2.WithParts[] - model: Provider.Model -}) { - const messages = yield* MessageV2.toModelMessagesEffect(input.messages, input.model, { - stripMedia: true, - toolOutputMaxChars: TOOL_OUTPUT_MAX_CHARS, - }) - return messages.length ? JSON.stringify(messages, null, 2) : undefined -}) - function preserveRecentBudget(input: { cfg: Config.Info; model: Provider.Model }) { return ( input.cfg.compaction?.preserve_recent_tokens ?? @@ -170,6 +150,7 @@ function turns(messages: MessageV2.WithParts[]) { result.push({ start: i, end: messages.length, + id: msg.info.id, }) } for (let i = 0; i < result.length - 1; i++) { @@ -196,6 +177,7 @@ function splitTurn(input: { if (size > input.budget) continue return { start, + id: input.messages[start]!.info.id, } satisfies Tail } return undefined @@ -262,7 +244,8 @@ export const layer: Layer.Layer< messages: MessageV2.WithParts[] model: Provider.Model }) { - return Token.estimate((yield* serialize(input)) ?? "") + const msgs = yield* MessageV2.toModelMessagesEffect(input.messages, input.model) + return Token.estimate(JSON.stringify(msgs)) }) const select = Effect.fn("SessionCompaction.select")(function* (input: { @@ -271,10 +254,10 @@ export const layer: Layer.Layer< model: Provider.Model }) { const limit = input.cfg.compaction?.tail_turns ?? DEFAULT_TAIL_TURNS - if (limit <= 0) return { head: input.messages, tail: [] } + if (limit <= 0) return { head: input.messages, tail_start_id: undefined } const budget = preserveRecentBudget({ cfg: input.cfg, model: input.model }) const all = turns(input.messages) - if (!all.length) return { head: input.messages, tail: [] } + if (!all.length) return { head: input.messages, tail_start_id: undefined } const recent = all.slice(-limit) const sizes = yield* Effect.forEach( recent, @@ -293,7 +276,7 @@ export const layer: Layer.Layer< const size = sizes[i] if (total + size <= budget) { total += size - keep = { start: turn.start } + keep = { start: turn.start, id: turn.id } continue } const remaining = budget - total @@ -309,10 +292,10 @@ export const layer: Layer.Layer< break } - if (!keep) return { head: input.messages, tail: [] } + if (!keep || keep.start === 0) return { head: input.messages, tail_start_id: undefined } return { head: input.messages.slice(0, keep.start), - tail: input.messages.slice(keep.start), + tail_start_id: keep.id, } }) @@ -423,10 +406,7 @@ export const layer: Layer.Layer< { sessionID: input.sessionID }, { context: [], prompt: undefined }, ) - const tailMessages = structuredClone(selected.tail) - yield* plugin.trigger("experimental.chat.messages.transform", {}, { messages: tailMessages }) - const tail = yield* serialize({ messages: tailMessages, model }) - const nextPrompt = compacting.prompt ?? buildPrompt({ previousSummary, context: compacting.context, tail }) + const nextPrompt = compacting.prompt ?? buildPrompt({ previousSummary, context: compacting.context }) const msgs = structuredClone(selected.head) yield* plugin.trigger("experimental.chat.messages.transform", {}, { messages: msgs }) const modelMessages = yield* MessageV2.toModelMessagesEffect(msgs, model, { @@ -493,6 +473,13 @@ export const layer: Layer.Layer< return "stop" } + if (compactionPart && selected.tail_start_id && compactionPart.tail_start_id !== selected.tail_start_id) { + yield* session.updatePart({ + ...compactionPart, + tail_start_id: selected.tail_start_id, + }) + } + if (result === "continue" && input.auto) { if (replay) { const original = replay.info @@ -588,6 +575,7 @@ export const layer: Layer.Layer< sessionID: input.sessionID, timestamp: DateTime.makeUnsafe(Date.now()), text: summary ?? "", + include: selected.tail_start_id, }) } yield* bus.publish(Event.Compacted, { sessionID: input.sessionID }) diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index 626261d0f6..e6ee40e953 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -772,13 +772,12 @@ export const toModelMessagesEffect = Effect.fnUntraced(function* ( return part.metadata?.anthropic?.signature != null }) for (const part of msg.parts) { - if (msg.info.summary && part.type !== "text") continue if (part.type === "text") { const text = part.text === "" && hasSignedReasoning ? " " : part.text assistantMessage.parts.push({ type: "text", text, - ...(differentModel || msg.info.summary ? {} : { providerMetadata: part.metadata }), + ...(differentModel ? {} : { providerMetadata: part.metadata }), }) } if (part.type === "step-start") @@ -1004,16 +1003,53 @@ export function get(input: { sessionID: SessionID; messageID: MessageID }): With export function filterCompacted(msgs: Iterable) { const result = [] as WithParts[] const completed = new Set() + let retain: MessageID | undefined for (const msg of msgs) { result.push(msg) - if (msg.info.role === "user" && completed.has(msg.info.id)) { - if (msg.parts.some((item): item is CompactionPart => item.type === "compaction")) break + if (retain) { + if (msg.info.id === retain) break continue } + if (msg.info.role === "user" && completed.has(msg.info.id)) { + const part = msg.parts.find((item): item is CompactionPart => item.type === "compaction") + if (!part) continue + if (!part.tail_start_id) break + retain = part.tail_start_id + if (msg.info.id === retain) break + continue + } + if (msg.info.role === "user" && completed.has(msg.info.id) && msg.parts.some((part) => part.type === "compaction")) + break if (msg.info.role === "assistant" && msg.info.summary && msg.info.finish && !msg.info.error) completed.add(msg.info.parentID) } result.reverse() + const compactionIndex = result.findLastIndex( + (msg) => + msg.info.role === "user" && + msg.parts.some((item): item is CompactionPart => item.type === "compaction" && item.tail_start_id !== undefined), + ) + const compaction = result[compactionIndex] + const part = compaction?.parts.find( + (item): item is CompactionPart => item.type === "compaction" && item.tail_start_id !== undefined, + ) + const summaryIndex = compaction + ? result.findIndex( + (msg, index) => + index > compactionIndex && + msg.info.role === "assistant" && + msg.info.summary && + msg.info.parentID === compaction.info.id, + ) + : -1 + const tailIndex = part?.tail_start_id ? result.findIndex((msg) => msg.info.id === part.tail_start_id) : -1 + if (tailIndex >= 0 && tailIndex < compactionIndex && summaryIndex > compactionIndex) { + return [ + ...result.slice(compactionIndex, summaryIndex + 1), + ...result.slice(tailIndex, compactionIndex), + ...result.slice(summaryIndex + 1), + ] + } return result } diff --git a/packages/opencode/test/session/compaction.test.ts b/packages/opencode/test/session/compaction.test.ts index c7f349d5ce..1d329699f6 100644 --- a/packages/opencode/test/session/compaction.test.ts +++ b/packages/opencode/test/session/compaction.test.ts @@ -926,12 +926,12 @@ describe("session.compaction.process", () => { ) itCompaction.instance( - "does not persist tail_start_id for serialized recent turns", + "persists tail_start_id for retained recent turns", Effect.gen(function* () { const ssn = yield* SessionNs.Service const session = yield* ssn.create({}) yield* createUserMessage(session.id, "first") - yield* createUserMessage(session.id, "second") + const keep = yield* createUserMessage(session.id, "second") yield* createUserMessage(session.id, "third") yield* createSummaryCompaction(session.id) @@ -947,18 +947,18 @@ describe("session.compaction.process", () => { const part = yield* readCompactionPart(session.id) expect(part?.type).toBe("compaction") - expect(part?.tail_start_id).toBeUndefined() + expect(part?.tail_start_id).toBe(keep.id) }).pipe(withCompaction({ config: cfg({ tail_turns: 2, preserve_recent_tokens: 10_000 }) })), ) itCompaction.instance( - "does not persist tail_start_id when shrinking serialized tail", + "shrinks retained tail to fit preserve token budget", Effect.gen(function* () { const ssn = yield* SessionNs.Service const session = yield* ssn.create({}) yield* createUserMessage(session.id, "first") yield* createUserMessage(session.id, "x".repeat(2_000)) - yield* createUserMessage(session.id, "tiny") + const keep = yield* createUserMessage(session.id, "tiny") yield* createSummaryCompaction(session.id) const msgs = yield* ssn.messages({ sessionID: session.id }) @@ -973,7 +973,7 @@ describe("session.compaction.process", () => { const part = yield* readCompactionPart(session.id) expect(part?.type).toBe("compaction") - expect(part?.tail_start_id).toBeUndefined() + expect(part?.tail_start_id).toBe(keep.id) }).pipe(withCompaction({ config: cfg({ tail_turns: 2, preserve_recent_tokens: 100 }) })), ) @@ -1005,7 +1005,7 @@ describe("session.compaction.process", () => { ) itCompaction.instance( - "serializes retained tail media as text in the summary input", + "falls back to full summary when retained tail media exceeds preserve token budget", () => { const stub = llm() let captured = "" @@ -1078,16 +1078,15 @@ describe("session.compaction.process", () => { const part = yield* readCompactionPart(session.id) expect(part?.type).toBe("compaction") - expect(part?.tail_start_id).toBeUndefined() + expect(part?.tail_start_id).toBe(keep.id) expect(captured).toContain("zzzz") - expect(captured).toContain("keep tail") + expect(captured).not.toContain("keep tail") const filtered = MessageV2.filterCompacted(MessageV2.stream(session.id)) - expect(filtered.map((msg) => msg.info.id)).toEqual([parent!, expect.any(String)]) + expect(filtered.map((msg) => msg.info.id).slice(0, 3)).toEqual([parent!, expect.any(String), keep.id]) expect(filtered[1]?.info.role).toBe("assistant") expect(filtered[1]?.info.role === "assistant" ? filtered[1].info.summary : false).toBe(true) expect(filtered.map((msg) => msg.info.id)).not.toContain(large.id) - expect(filtered.map((msg) => msg.info.id)).not.toContain(keep.id) }).pipe(withCompaction({ llm: stub.layer, config: cfg({ tail_turns: 1, preserve_recent_tokens: 100 }) })) }, { git: true }, @@ -1354,13 +1353,13 @@ describe("session.compaction.process", () => { ) itCompaction.instance( - "summarizes the head while serializing recent tail into summary input", + "summarizes only the head while keeping recent tail out of summary input", () => { const stub = llm() - let captured: LLM.StreamInput["messages"] = [] + let captured = "" stub.push( reply("summary", (input) => { - captured = input.messages + captured = JSON.stringify(input.messages) }), ) return Effect.gen(function* () { @@ -1381,15 +1380,10 @@ describe("session.compaction.process", () => { auto: false, }) - const head = JSON.stringify(captured.slice(0, -1)) - const prompt = JSON.stringify(captured.at(-1)) - expect(head).toContain("older context") - expect(head).not.toContain("keep this turn") - expect(head).not.toContain("and this one too") - expect(prompt).toContain("keep this turn") - expect(prompt).toContain("and this one too") - expect(prompt).toContain("recent-conversation-tail") - expect(prompt).not.toContain("What did we do so far?") + expect(captured).toContain("older context") + expect(captured).not.toContain("keep this turn") + expect(captured).not.toContain("and this one too") + expect(captured).not.toContain("What did we do so far?") }).pipe(withCompaction({ llm: stub.layer })) }, { git: true }, @@ -1437,7 +1431,7 @@ describe("session.compaction.process", () => { { git: true }, ) - itCompaction.instance("does not replay recent pre-compaction turns across repeated compactions", () => { + itCompaction.instance("keeps recent pre-compaction turns across repeated compactions", () => { const stub = llm() stub.push(reply("summary one")) stub.push(reply("summary two")) @@ -1468,8 +1462,8 @@ describe("session.compaction.process", () => { expect(ids).not.toContain(u1.id) expect(ids).not.toContain(u2.id) - expect(ids).not.toContain(u3.id) - expect(ids).not.toContain(u4.id) + expect(ids).toContain(u3.id) + expect(ids).toContain(u4.id) expect(filtered.some((msg) => msg.info.role === "assistant" && msg.info.summary)).toBe(true) expect( filtered.some((msg) => msg.info.role === "user" && msg.parts.some((part) => part.type === "compaction")), @@ -1478,7 +1472,7 @@ describe("session.compaction.process", () => { }) itCompaction.instance( - "ignores previous summaries when sizing the serialized tail", + "ignores previous summaries when sizing the retained tail", Effect.gen(function* () { const ssn = yield* SessionNs.Service const test = yield* TestInstance @@ -1517,7 +1511,7 @@ describe("session.compaction.process", () => { const part = yield* readCompactionPart(session.id) expect(part?.type).toBe("compaction") - expect(part?.tail_start_id).toBeUndefined() + expect(part?.tail_start_id).toBe(keep.id) }).pipe(withCompaction({ config: cfg({ tail_turns: 2, preserve_recent_tokens: 500 }) })), ) }) diff --git a/packages/opencode/test/session/messages-pagination.test.ts b/packages/opencode/test/session/messages-pagination.test.ts index e1714a9015..09e8d7b429 100644 --- a/packages/opencode/test/session/messages-pagination.test.ts +++ b/packages/opencode/test/session/messages-pagination.test.ts @@ -650,7 +650,7 @@ describe("MessageV2.filterCompacted", () => { ), ) - it.instance("ignores original tail when compaction stores tail_start_id", () => + it.instance("retains original tail when compaction stores tail_start_id", () => withSession(({ session, sessionID }) => Effect.gen(function* () { const u1 = yield* addUser(sessionID, "first") @@ -696,12 +696,12 @@ describe("MessageV2.filterCompacted", () => { const result = MessageV2.filterCompacted(MessageV2.stream(sessionID)) - expect(result.map((item) => item.info.id)).toEqual([c1, s1, u3, a3]) + expect(result.map((item) => item.info.id)).toEqual([c1, s1, u2, a2, u3, a3]) }), ), ) - it.instance("fork keeps legacy tail_start_id without replaying the tail", () => + it.instance("fork remaps compaction tail_start_id for filterCompacted", () => Effect.gen(function* () { const session = yield* SessionNs.Service const created = yield* session.create({}) @@ -748,7 +748,7 @@ describe("MessageV2.filterCompacted", () => { }) const parentFiltered = MessageV2.filterCompacted(MessageV2.stream(created.id)) - expect(parentFiltered.map((item) => item.info.id)).toEqual([c1, s1, u3, a3]) + expect(parentFiltered.map((item) => item.info.id)).toEqual([c1, s1, u2, a2, u3, a3]) const forked = yield* session.fork({ sessionID: created.id }) const childFiltered = MessageV2.filterCompacted(MessageV2.stream(forked.id)) @@ -758,14 +758,14 @@ describe("MessageV2.filterCompacted", () => { expect(tailPart?.type).toBe("compaction") if (!tailPart || tailPart.type !== "compaction") throw new Error("Expected forked compaction part") expect(tailPart.tail_start_id).toBeDefined() - expect(childFiltered.some((m) => m.info.id === tailPart.tail_start_id)).toBe(false) + expect(childFiltered.some((m) => m.info.id === tailPart.tail_start_id)).toBe(true) yield* session.remove(forked.id) yield* session.remove(created.id) }), ) - it.instance("does not replay an assistant tail when compaction starts inside a turn", () => + it.instance("retains an assistant tail when compaction starts inside a turn", () => withSession(({ session, sessionID }) => Effect.gen(function* () { const u1 = yield* addUser(sessionID, "first") @@ -819,7 +819,7 @@ describe("MessageV2.filterCompacted", () => { const result = MessageV2.filterCompacted(MessageV2.stream(sessionID)) - expect(result.map((item) => item.info.id)).toEqual([c1, s1, u3, a4]) + expect(result.map((item) => item.info.id)).toEqual([c1, s1, a3, u3, a4]) }), ), ) @@ -891,7 +891,7 @@ describe("MessageV2.filterCompacted", () => { const result = MessageV2.filterCompacted(MessageV2.stream(sessionID)) - expect(result.map((item) => item.info.id)).toEqual([c2, s2, u4, a4]) + expect(result.map((item) => item.info.id)).toEqual([c2, s2, u3, a3, u4, a4]) }), ), ) From 4cf088ae84813bd724352c37803708de854eaf11 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 12 May 2026 16:34:12 -0400 Subject: [PATCH 62/70] test: migrate instance bootstrap to Effect runner (#27144) --- .../test/project/instance-bootstrap.test.ts | 115 ++++++++++-------- 1 file changed, 63 insertions(+), 52 deletions(-) diff --git a/packages/opencode/test/project/instance-bootstrap.test.ts b/packages/opencode/test/project/instance-bootstrap.test.ts index 71521a765a..4be2a76113 100644 --- a/packages/opencode/test/project/instance-bootstrap.test.ts +++ b/packages/opencode/test/project/instance-bootstrap.test.ts @@ -1,11 +1,16 @@ -import { afterEach, expect, test } from "bun:test" +import { afterEach, expect } from "bun:test" import { existsSync } from "node:fs" import path from "node:path" import { pathToFileURL } from "node:url" +import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" +import { Effect, Layer } from "effect" import { bootstrap as cliBootstrap } from "../../src/cli/bootstrap" -import { WithInstance } from "../../src/project/with-instance" -import { InstanceRuntime } from "../../src/project/instance-runtime" -import { disposeAllInstances, tmpdir } from "../fixture/fixture" +import { InstanceLayer } from "../../src/project/instance-layer" +import { InstanceStore } from "../../src/project/instance-store" +import { disposeAllInstances, tmpdirScoped } from "../fixture/fixture" +import { testEffect } from "../lib/effect" + +const it = testEffect(Layer.mergeAll(InstanceLayer.layer, CrossSpawnSpawner.defaultLayer)) // InstanceBootstrap must run before any code touches the instance — // originally tracked by PRs #25389 and #25449, now a permanent @@ -19,58 +24,64 @@ afterEach(async () => { await disposeAllInstances() }) -async function bootstrapFixture() { - return tmpdir({ - init: async (dir) => { - const marker = path.join(dir, "config-hook-fired") - const pluginFile = path.join(dir, "plugin.ts") - await Bun.write( - pluginFile, - [ - `const MARKER = ${JSON.stringify(marker)}`, - "export default async () => ({", - " config: async () => {", - ' await Bun.write(MARKER, "ran")', - " },", - "})", - "", - ].join("\n"), - ) - await Bun.write( - path.join(dir, "opencode.json"), - JSON.stringify({ - $schema: "https://opencode.ai/config.json", - plugin: [pathToFileURL(pluginFile).href], - }), - ) - return marker - }, - }) -} - -test("WithInstance.provide runs InstanceBootstrap before fn", async () => { - await using tmp = await bootstrapFixture() - - await WithInstance.provide({ - directory: tmp.path, - fn: async () => "ok", - }) - - expect(existsSync(tmp.extra)).toBe(true) +const bootstrapFixture = Effect.gen(function* () { + const dir = yield* tmpdirScoped({ git: true }) + const marker = path.join(dir, "config-hook-fired") + const pluginFile = path.join(dir, "plugin.ts") + yield* Effect.promise(() => + Bun.write( + pluginFile, + [ + `const MARKER = ${JSON.stringify(marker)}`, + "export default async () => ({", + " config: async () => {", + ' await Bun.write(MARKER, "ran")', + " },", + "})", + "", + ].join("\n"), + ), + ) + yield* Effect.promise(() => + Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + plugin: [pathToFileURL(pluginFile).href], + }), + ), + ) + return { directory: dir, marker } }) -test("CLI bootstrap runs InstanceBootstrap before callback", async () => { - await using tmp = await bootstrapFixture() +it.live("InstanceStore.provide runs InstanceBootstrap before effect", () => + Effect.gen(function* () { + const tmp = yield* bootstrapFixture + const store = yield* InstanceStore.Service - await cliBootstrap(tmp.path, async () => "ok") + yield* store.provide({ directory: tmp.directory }, Effect.succeed("ok")) - expect(existsSync(tmp.extra)).toBe(true) -}) + expect(existsSync(tmp.marker)).toBe(true) + }), +) -test("InstanceRuntime.reloadInstance runs InstanceBootstrap", async () => { - await using tmp = await bootstrapFixture() +it.live("CLI bootstrap runs InstanceBootstrap before callback", () => + Effect.gen(function* () { + const tmp = yield* bootstrapFixture - await InstanceRuntime.reloadInstance({ directory: tmp.path }) + yield* Effect.promise(() => cliBootstrap(tmp.directory, async () => "ok")) - expect(existsSync(tmp.extra)).toBe(true) -}) + expect(existsSync(tmp.marker)).toBe(true) + }), +) + +it.live("InstanceStore.reload runs InstanceBootstrap", () => + Effect.gen(function* () { + const tmp = yield* bootstrapFixture + const store = yield* InstanceStore.Service + + yield* store.reload({ directory: tmp.directory }) + + expect(existsSync(tmp.marker)).toBe(true) + }), +) From 3c34f6704b15ae491750fe6b78d244cfc24d4a7a Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 12 May 2026 16:41:34 -0400 Subject: [PATCH 63/70] test: migrate auth override plugin test (#27140) --- .../test/plugin/auth-override.test.ts | 67 +++++++++---------- 1 file changed, 31 insertions(+), 36 deletions(-) diff --git a/packages/opencode/test/plugin/auth-override.test.ts b/packages/opencode/test/plugin/auth-override.test.ts index c77c0ca1c0..402d755da7 100644 --- a/packages/opencode/test/plugin/auth-override.test.ts +++ b/packages/opencode/test/plugin/auth-override.test.ts @@ -1,15 +1,19 @@ import { describe, expect, test } from "bun:test" import path from "path" -import fs from "fs/promises" import { pathToFileURL } from "url" import { Effect, Layer } from "effect" -import { provideTestInstance, tmpdir } from "../fixture/fixture" +import { AppFileSystem } from "@opencode-ai/core/filesystem" +import { provideInstance, TestInstance, tmpdirScoped } from "../fixture/fixture" import { ProviderAuth } from "@/provider/auth" import { ProviderID } from "../../src/provider/schema" import { Plugin } from "@/plugin" import { Auth } from "@/auth" import { Bus } from "@/bus" import { TestConfig } from "../fixture/config" +import { testEffect } from "../lib/effect" +import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" + +const it = testEffect(Layer.mergeAll(CrossSpawnSpawner.defaultLayer, AppFileSystem.defaultLayer)) function layer(directory: string, plugins: string[]) { return ProviderAuth.layer.pipe( @@ -37,13 +41,15 @@ function layer(directory: string, plugins: string[]) { } describe("plugin.auth-override", () => { - test("user plugin overrides built-in github-copilot auth", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { - const pluginDir = path.join(dir, ".opencode", "plugin") - await fs.mkdir(pluginDir, { recursive: true }) + it.instance( + "user plugin overrides built-in github-copilot auth", + () => + Effect.gen(function* () { + const tmp = yield* TestInstance + const fs = yield* AppFileSystem.Service + const pluginDir = path.join(tmp.directory, ".opencode", "plugin") - await Bun.write( + yield* fs.writeWithDirs( path.join(pluginDir, "custom-copilot-auth.ts"), [ "export default {", @@ -61,37 +67,26 @@ describe("plugin.auth-override", () => { "", ].join("\n"), ) - }, - }) - await using plain = await tmpdir() + const plain = yield* tmpdirScoped({ git: true }) + const plugin = pathToFileURL(path.join(pluginDir, "custom-copilot-auth.ts")).href + const methods = yield* ProviderAuth.Service.use((svc) => svc.methods()).pipe( + Effect.provide(layer(tmp.directory, [plugin])), + ) + const plainMethods = yield* ProviderAuth.Service.use((svc) => svc.methods()).pipe( + Effect.provide(layer(plain, [])), + provideInstance(plain), + ) - const plugin = pathToFileURL(path.join(tmp.path, ".opencode", "plugin", "custom-copilot-auth.ts")).href - const [methods, plainMethods] = await Promise.all([ - provideTestInstance({ - directory: tmp.path, - fn: async () => { - return Effect.runPromise( - ProviderAuth.Service.use((svc) => svc.methods()).pipe(Effect.provide(layer(tmp.path, [plugin]))), - ) - }, + const copilot = methods[ProviderID.make("github-copilot")] + expect(copilot).toBeDefined() + expect(copilot.length).toBe(1) + expect(copilot[0].label).toBe("Test Override Auth") + expect(plainMethods[ProviderID.make("github-copilot")][0].label).not.toBe("Test Override Auth") }), - provideTestInstance({ - directory: plain.path, - fn: async () => { - return Effect.runPromise( - ProviderAuth.Service.use((svc) => svc.methods()).pipe(Effect.provide(layer(plain.path, []))), - ) - }, - }), - ]) - - const copilot = methods[ProviderID.make("github-copilot")] - expect(copilot).toBeDefined() - expect(copilot.length).toBe(1) - expect(copilot[0].label).toBe("Test Override Auth") - expect(plainMethods[ProviderID.make("github-copilot")][0].label).not.toBe("Test Override Auth") - }, 30000) + { git: true }, + 30000, + ) }) const file = path.join(import.meta.dir, "../../src/plugin/index.ts") From 0fb55b4f1a0330d531427a517824ca3714ae8307 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 12 May 2026 16:45:39 -0400 Subject: [PATCH 64/70] test(project): migrate global project tests to Effect runner (#27142) --- .../test/project/migrate-global.test.ts | 168 +++++++++--------- 1 file changed, 87 insertions(+), 81 deletions(-) diff --git a/packages/opencode/test/project/migrate-global.test.ts b/packages/opencode/test/project/migrate-global.test.ts index c476c108b4..6efd670c5c 100644 --- a/packages/opencode/test/project/migrate-global.test.ts +++ b/packages/opencode/test/project/migrate-global.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, test } from "bun:test" +import { describe, expect } from "bun:test" import { Project } from "@/project/project" import { Database } from "@/storage/db" import { eq } from "drizzle-orm" @@ -8,19 +8,14 @@ import { ProjectID } from "../../src/project/schema" import { SessionID } from "../../src/session/schema" import * as Log from "@opencode-ai/core/util/log" import { $ } from "bun" -import { tmpdir } from "../fixture/fixture" -import { Effect } from "effect" +import { tmpdirScoped } from "../fixture/fixture" +import { Effect, Layer } from "effect" +import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" +import { testEffect } from "../lib/effect" -Log.init({ print: false }) +void Log.init({ print: false }) -function run(fn: (svc: Project.Interface) => Effect.Effect) { - return Effect.runPromise( - Effect.gen(function* () { - const svc = yield* Project.Service - return yield* fn(svc) - }).pipe(Effect.provide(Project.defaultLayer)), - ) -} +const it = testEffect(Layer.mergeAll(Project.defaultLayer, CrossSpawnSpawner.defaultLayer)) function legacySessionID() { // Global-session migration covers persisted IDs from before prefixed session IDs. @@ -63,91 +58,102 @@ function ensureGlobal() { } describe("migrateFromGlobal", () => { - test("migrates global sessions on first project creation", async () => { - // 1. Start with git init but no commits — creates "global" project row - await using tmp = await tmpdir() - await $`git init`.cwd(tmp.path).quiet() - await $`git config user.name "Test"`.cwd(tmp.path).quiet() - await $`git config user.email "test@opencode.test"`.cwd(tmp.path).quiet() - await $`git config commit.gpgsign false`.cwd(tmp.path).quiet() - const { project: pre } = await run((svc) => svc.fromDirectory(tmp.path)) - expect(pre.id).toBe(ProjectID.global) + it.live("migrates global sessions on first project creation", () => + Effect.gen(function* () { + // 1. Start with git init but no commits — creates "global" project row + const tmp = yield* tmpdirScoped() + yield* Effect.promise(() => $`git init`.cwd(tmp).quiet()) + yield* Effect.promise(() => $`git config user.name "Test"`.cwd(tmp).quiet()) + yield* Effect.promise(() => $`git config user.email "test@opencode.test"`.cwd(tmp).quiet()) + yield* Effect.promise(() => $`git config commit.gpgsign false`.cwd(tmp).quiet()) + const projects = yield* Project.Service + const { project: pre } = yield* projects.fromDirectory(tmp) + expect(pre.id).toBe(ProjectID.global) - // 2. Seed a session under "global" with matching directory - const id = legacySessionID() - seed({ id, dir: tmp.path, project: ProjectID.global }) + // 2. Seed a session under "global" with matching directory + const id = legacySessionID() + yield* Effect.sync(() => seed({ id, dir: tmp, project: ProjectID.global })) - // 3. Make a commit so the project gets a real ID - await $`git commit --allow-empty -m "root"`.cwd(tmp.path).quiet() + // 3. Make a commit so the project gets a real ID + yield* Effect.promise(() => $`git commit --allow-empty -m "root"`.cwd(tmp).quiet()) - const { project: real } = await run((svc) => svc.fromDirectory(tmp.path)) - expect(real.id).not.toBe(ProjectID.global) + const { project: real } = yield* projects.fromDirectory(tmp) + expect(real.id).not.toBe(ProjectID.global) - // 4. The session should have been migrated to the real project ID - const row = Database.use((db) => db.select().from(SessionTable).where(eq(SessionTable.id, id)).get()) - expect(row).toBeDefined() - expect(row!.project_id).toBe(real.id) - }) + // 4. The session should have been migrated to the real project ID + const row = Database.use((db) => db.select().from(SessionTable).where(eq(SessionTable.id, id)).get()) + expect(row).toBeDefined() + expect(row!.project_id).toBe(real.id) + }), + ) - test("migrates global sessions even when project row already exists", async () => { - // 1. Create a repo with a commit — real project ID created immediately - await using tmp = await tmpdir({ git: true }) - const { project } = await run((svc) => svc.fromDirectory(tmp.path)) - expect(project.id).not.toBe(ProjectID.global) + it.live("migrates global sessions even when project row already exists", () => + Effect.gen(function* () { + // 1. Create a repo with a commit — real project ID created immediately + const tmp = yield* tmpdirScoped({ git: true }) + const projects = yield* Project.Service + const { project } = yield* projects.fromDirectory(tmp) + expect(project.id).not.toBe(ProjectID.global) - // 2. Ensure "global" project row exists (as it would from a prior no-git session) - ensureGlobal() + // 2. Ensure "global" project row exists (as it would from a prior no-git session) + yield* Effect.sync(() => ensureGlobal()) - // 3. Seed a session under "global" with matching directory. - // This simulates a session created before git init that wasn't - // present when the real project row was first created. - const id = legacySessionID() - seed({ id, dir: tmp.path, project: ProjectID.global }) + // 3. Seed a session under "global" with matching directory. + // This simulates a session created before git init that wasn't + // present when the real project row was first created. + const id = legacySessionID() + yield* Effect.sync(() => seed({ id, dir: tmp, project: ProjectID.global })) - // 4. Call fromDirectory again — project row already exists, - // so the current code skips migration entirely. This is the bug. - await run((svc) => svc.fromDirectory(tmp.path)) + // 4. Call fromDirectory again — project row already exists, + // so the current code skips migration entirely. This is the bug. + yield* projects.fromDirectory(tmp) - const row = Database.use((db) => db.select().from(SessionTable).where(eq(SessionTable.id, id)).get()) - expect(row).toBeDefined() - expect(row!.project_id).toBe(project.id) - }) + const row = Database.use((db) => db.select().from(SessionTable).where(eq(SessionTable.id, id)).get()) + expect(row).toBeDefined() + expect(row!.project_id).toBe(project.id) + }), + ) - test("does not claim sessions with empty directory", async () => { - await using tmp = await tmpdir({ git: true }) - const { project } = await run((svc) => svc.fromDirectory(tmp.path)) - expect(project.id).not.toBe(ProjectID.global) + it.live("does not claim sessions with empty directory", () => + Effect.gen(function* () { + const tmp = yield* tmpdirScoped({ git: true }) + const projects = yield* Project.Service + const { project } = yield* projects.fromDirectory(tmp) + expect(project.id).not.toBe(ProjectID.global) - ensureGlobal() + yield* Effect.sync(() => ensureGlobal()) - // Legacy sessions may lack a directory value. - // Without a matching origin directory, they should remain global. - const id = legacySessionID() - seed({ id, dir: "", project: ProjectID.global }) + // Legacy sessions may lack a directory value. + // Without a matching origin directory, they should remain global. + const id = legacySessionID() + yield* Effect.sync(() => seed({ id, dir: "", project: ProjectID.global })) - await run((svc) => svc.fromDirectory(tmp.path)) + yield* projects.fromDirectory(tmp) - const row = Database.use((db) => db.select().from(SessionTable).where(eq(SessionTable.id, id)).get()) - expect(row).toBeDefined() - expect(row!.project_id).toBe(ProjectID.global) - }) + const row = Database.use((db) => db.select().from(SessionTable).where(eq(SessionTable.id, id)).get()) + expect(row).toBeDefined() + expect(row!.project_id).toBe(ProjectID.global) + }), + ) - test("does not steal sessions from unrelated directories", async () => { - await using tmp = await tmpdir({ git: true }) - const { project } = await run((svc) => svc.fromDirectory(tmp.path)) - expect(project.id).not.toBe(ProjectID.global) + it.live("does not steal sessions from unrelated directories", () => + Effect.gen(function* () { + const tmp = yield* tmpdirScoped({ git: true }) + const projects = yield* Project.Service + const { project } = yield* projects.fromDirectory(tmp) + expect(project.id).not.toBe(ProjectID.global) - ensureGlobal() + yield* Effect.sync(() => ensureGlobal()) - // Seed a session under "global" but for a DIFFERENT directory - const id = legacySessionID() - seed({ id, dir: "/some/other/dir", project: ProjectID.global }) + // Seed a session under "global" but for a DIFFERENT directory + const id = legacySessionID() + yield* Effect.sync(() => seed({ id, dir: "/some/other/dir", project: ProjectID.global })) - await run((svc) => svc.fromDirectory(tmp.path)) - - const row = Database.use((db) => db.select().from(SessionTable).where(eq(SessionTable.id, id)).get()) - expect(row).toBeDefined() - // Should remain under "global" — not stolen - expect(row!.project_id).toBe(ProjectID.global) - }) + yield* projects.fromDirectory(tmp) + const row = Database.use((db) => db.select().from(SessionTable).where(eq(SessionTable.id, id)).get()) + expect(row).toBeDefined() + // Should remain under "global" — not stolen + expect(row!.project_id).toBe(ProjectID.global) + }), + ) }) From d9f9f1553b439bd3c811322484387479b6b26a2d Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 12 May 2026 16:48:07 -0400 Subject: [PATCH 65/70] test: use Effect file services in migrated tests (#27154) --- .../opencode/test/skill/discovery.test.ts | 38 +++++++++++-------- .../opencode/test/snapshot/snapshot.test.ts | 24 +++++------- 2 files changed, 32 insertions(+), 30 deletions(-) diff --git a/packages/opencode/test/skill/discovery.test.ts b/packages/opencode/test/skill/discovery.test.ts index 0b07d4df0f..074992c56c 100644 --- a/packages/opencode/test/skill/discovery.test.ts +++ b/packages/opencode/test/skill/discovery.test.ts @@ -1,5 +1,6 @@ import { describe, expect, beforeAll, afterAll } from "bun:test" -import { Effect } from "effect" +import { AppFileSystem } from "@opencode-ai/core/filesystem" +import { Effect, Layer } from "effect" import { Discovery } from "../../src/skill/discovery" import { Global } from "@opencode-ai/core/global" import { Filesystem } from "@/util/filesystem" @@ -13,7 +14,7 @@ let downloadCount = 0 const fixturePath = path.join(import.meta.dir, "../fixture/skills") const cacheDir = path.join(Global.Path.cache, "skills") -const it = testEffect(Discovery.defaultLayer) +const it = testEffect(Layer.mergeAll(Discovery.defaultLayer, AppFileSystem.defaultLayer)) beforeAll(async () => { await rm(cacheDir, { recursive: true, force: true }) @@ -49,36 +50,37 @@ afterAll(async () => { }) describe("Discovery.pull", () => { - const pull = Effect.fn("DiscoveryTest.pull")(function* (url: string) { - return yield* Discovery.Service.use((s) => s.pull(url)) - }) - it.live("downloads skills from cloudflare url", () => Effect.gen(function* () { - const dirs = yield* pull(CLOUDFLARE_SKILLS_URL) + const fsys = yield* AppFileSystem.Service + const discovery = yield* Discovery.Service + const dirs = yield* discovery.pull(CLOUDFLARE_SKILLS_URL) expect(dirs.length).toBeGreaterThan(0) for (const dir of dirs) { expect(dir).toStartWith(cacheDir) const md = path.join(dir, "SKILL.md") - expect(yield* Effect.promise(() => Filesystem.exists(md))).toBe(true) + expect(yield* fsys.existsSafe(md)).toBe(true) } }), ) it.live("url without trailing slash works", () => Effect.gen(function* () { - const dirs = yield* pull(CLOUDFLARE_SKILLS_URL.replace(/\/$/, "")) + const fsys = yield* AppFileSystem.Service + const discovery = yield* Discovery.Service + const dirs = yield* discovery.pull(CLOUDFLARE_SKILLS_URL.replace(/\/$/, "")) expect(dirs.length).toBeGreaterThan(0) for (const dir of dirs) { const md = path.join(dir, "SKILL.md") - expect(yield* Effect.promise(() => Filesystem.exists(md))).toBe(true) + expect(yield* fsys.existsSafe(md)).toBe(true) } }), ) it.live("returns empty array for invalid url", () => Effect.gen(function* () { - const dirs = yield* pull(`http://localhost:${server.port}/invalid-url/`) + const discovery = yield* Discovery.Service + const dirs = yield* discovery.pull(`http://localhost:${server.port}/invalid-url/`) expect(dirs).toEqual([]) }), ) @@ -86,20 +88,23 @@ describe("Discovery.pull", () => { it.live("returns empty array for non-json response", () => Effect.gen(function* () { // any url not explicitly handled in server returns 404 text "Not Found" - const dirs = yield* pull(`http://localhost:${server.port}/some-other-path/`) + const discovery = yield* Discovery.Service + const dirs = yield* discovery.pull(`http://localhost:${server.port}/some-other-path/`) expect(dirs).toEqual([]) }), ) it.live("downloads reference files alongside SKILL.md", () => Effect.gen(function* () { - const dirs = yield* pull(CLOUDFLARE_SKILLS_URL) + const fsys = yield* AppFileSystem.Service + const discovery = yield* Discovery.Service + const dirs = yield* discovery.pull(CLOUDFLARE_SKILLS_URL) // find a skill dir that should have reference files (e.g. agents-sdk) const agentsSdk = dirs.find((d) => d.endsWith(path.sep + "agents-sdk")) expect(agentsSdk).toBeDefined() if (agentsSdk) { const refs = path.join(agentsSdk, "references") - expect(yield* Effect.promise(() => Filesystem.exists(path.join(agentsSdk, "SKILL.md")))).toBe(true) + expect(yield* fsys.existsSafe(path.join(agentsSdk, "SKILL.md"))).toBe(true) // agents-sdk has reference files per the index const refDir = yield* Effect.promise(() => Array.fromAsync(new Bun.Glob("**/*.md").scan({ cwd: refs, onlyFiles: true })), @@ -114,15 +119,16 @@ describe("Discovery.pull", () => { // clear dir and downloadCount yield* Effect.promise(() => rm(cacheDir, { recursive: true, force: true })) downloadCount = 0 + const discovery = yield* Discovery.Service // first pull to populate cache - const first = yield* pull(CLOUDFLARE_SKILLS_URL) + const first = yield* discovery.pull(CLOUDFLARE_SKILLS_URL) expect(first.length).toBeGreaterThan(0) const firstCount = downloadCount expect(firstCount).toBeGreaterThan(0) // second pull should return same results from cache - const second = yield* pull(CLOUDFLARE_SKILLS_URL) + const second = yield* discovery.pull(CLOUDFLARE_SKILLS_URL) expect(second.length).toBe(first.length) expect(second.sort()).toEqual(first.sort()) diff --git a/packages/opencode/test/snapshot/snapshot.test.ts b/packages/opencode/test/snapshot/snapshot.test.ts index fa167281b9..de60d58b2d 100644 --- a/packages/opencode/test/snapshot/snapshot.test.ts +++ b/packages/opencode/test/snapshot/snapshot.test.ts @@ -1,15 +1,15 @@ import { afterEach, expect } from "bun:test" import { $ } from "bun" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" +import { AppFileSystem } from "@opencode-ai/core/filesystem" import fs from "fs/promises" import path from "path" -import { Effect, Fiber } from "effect" +import { Effect, Fiber, Layer } from "effect" import { Snapshot } from "../../src/snapshot" -import { Filesystem } from "@/util/filesystem" import { disposeAllInstances, provideInstance, TestInstance, tmpdirScoped } from "../fixture/fixture" import { testEffect } from "../lib/effect" -const it = testEffect(Snapshot.defaultLayer) +const it = testEffect(Layer.mergeAll(Snapshot.defaultLayer, AppFileSystem.defaultLayer)) // Git always outputs /-separated paths internally. Snapshot.patch() joins them // with path.join (which produces \ on Windows) then normalizes back to /. @@ -27,17 +27,13 @@ const exec = (cwd: string, command: string[]) => if (code !== 0) throw new Error(`${command.join(" ")} failed: ${await new Response(proc.stderr).text()}`) }) -const write = (file: string, content: string | Uint8Array) => Effect.promise(() => Filesystem.write(file, content)) -const readText = (file: string) => Effect.promise(() => fs.readFile(file, "utf-8")) -const exists = (file: string) => - Effect.promise(() => - fs - .access(file) - .then(() => true) - .catch(() => false), - ) -const mkdirp = (dir: string) => Effect.promise(() => fs.mkdir(dir, { recursive: true })) -const rm = (file: string) => Effect.promise(() => fs.rm(file, { recursive: true, force: true })) +const write = (file: string, content: string | Uint8Array) => + AppFileSystem.Service.use((fs) => fs.writeWithDirs(file, content)) +const readText = (file: string) => AppFileSystem.Service.use((fs) => fs.readFileString(file)) +const exists = (file: string) => AppFileSystem.Service.use((fs) => fs.existsSafe(file)) +const mkdirp = (dir: string) => AppFileSystem.Service.use((fs) => fs.ensureDir(dir)) +const rm = (file: string) => + AppFileSystem.Service.use((fs) => fs.remove(file, { recursive: true, force: true }).pipe(Effect.ignore)) const initialize = Effect.fn("SnapshotTest.initialize")(function* (dir: string) { const unique = Math.random().toString(36).slice(2) From 159964b1724b4424a914ea49000314ae978bf2d3 Mon Sep 17 00:00:00 2001 From: Musa Date: Tue, 12 May 2026 14:22:40 -0700 Subject: [PATCH 66/70] feat(plugin): add DigitalOcean OAuth + Inference Routers (#26095) --- packages/opencode/src/cli/cmd/providers.ts | 5 +- packages/opencode/src/plugin/digitalocean.ts | 407 ++++++++++++++++++ packages/opencode/src/plugin/index.ts | 2 + packages/opencode/src/provider/auth.ts | 1 + .../test/provider/digitalocean.test.ts | 144 +++++++ .../test/tool/fixtures/models-api.json | 24 ++ packages/plugin/src/index.ts | 8 +- packages/sdk/js/src/gen/types.gen.ts | 3 + .../assets/icons/provider/digitalocean.svg | 6 + .../src/components/provider-icons/sprite.svg | 14 + .../ui/src/components/provider-icons/types.ts | 1 + packages/web/src/content/docs/providers.mdx | 82 ++++ 12 files changed, 692 insertions(+), 5 deletions(-) create mode 100644 packages/opencode/src/plugin/digitalocean.ts create mode 100644 packages/opencode/test/provider/digitalocean.test.ts create mode 100644 packages/ui/src/assets/icons/provider/digitalocean.svg diff --git a/packages/opencode/src/cli/cmd/providers.ts b/packages/opencode/src/cli/cmd/providers.ts index 749139e2dc..426ea89fc5 100644 --- a/packages/opencode/src/cli/cmd/providers.ts +++ b/packages/opencode/src/cli/cmd/providers.ts @@ -124,6 +124,7 @@ const handlePluginAuth = Effect.fn("Cli.providers.pluginAuth")(function* ( yield* put(saveProvider, { type: "api", key: result.key, + ...(result.metadata ? { metadata: result.metadata } : {}), }) } yield* spinner.stop("Login successful") @@ -156,6 +157,7 @@ const handlePluginAuth = Effect.fn("Cli.providers.pluginAuth")(function* ( yield* put(saveProvider, { type: "api", key: result.key, + ...(result.metadata ? { metadata: result.metadata } : {}), }) } yield* Prompt.log.success("Login successful") @@ -191,10 +193,11 @@ const handlePluginAuth = Effect.fn("Cli.providers.pluginAuth")(function* ( } if (result.type === "success") { const saveProvider = result.provider ?? provider + const merged = { ...(metadata.metadata ?? {}), ...(result.metadata ?? {}) } yield* put(saveProvider, { type: "api", key: result.key ?? apiKey, - ...metadata, + ...(Object.keys(merged).length ? { metadata: merged } : {}), }) yield* Prompt.log.success("Login successful") } diff --git a/packages/opencode/src/plugin/digitalocean.ts b/packages/opencode/src/plugin/digitalocean.ts new file mode 100644 index 0000000000..31656656f1 --- /dev/null +++ b/packages/opencode/src/plugin/digitalocean.ts @@ -0,0 +1,407 @@ +import type { Hooks, PluginInput } from "@opencode-ai/plugin" +import type { Model } from "@opencode-ai/sdk/v2" +import * as Log from "@opencode-ai/core/util/log" +import { InstallationVersion } from "@opencode-ai/core/installation/version" +import { createServer } from "http" + +const log = Log.create({ service: "plugin.digitalocean" }) + +const DO_OAUTH_CLIENT_ID = "b1a6c5158156caac821fd1b30253ca8acb52454a48fa744420e41889cb589f82" +const DO_AUTHORIZE_URL = "https://cloud.digitalocean.com/v1/oauth/authorize" +const DO_API_BASE = "https://api.digitalocean.com" +const DO_INFERENCE_BASE = "https://inference.do-ai.run/v1" +const OAUTH_PORT = 1456 +const OAUTH_REDIRECT_PATH = "/auth/callback" +const OAUTH_TOKEN_PATH = "/auth/token" +const ROUTER_REFRESH_INTERVAL_MS = 5 * 60 * 1000 +const MAK_NAME_PREFIX = "opencode-oauth" + +interface ImplicitTokenPayload { + access_token: string + expires_in: number + state: string +} + +interface PendingOAuth { + state: string + resolve: (tokens: ImplicitTokenPayload) => void + reject: (error: Error) => void +} + +interface ApiKeyInfo { + uuid: string + name: string + secret_key: string +} + +interface RouterEntry { + name: string + uuid?: string + description?: string +} + +let oauthServer: ReturnType | undefined +let pendingOAuth: PendingOAuth | undefined + +function generateState(): string { + const bytes = crypto.getRandomValues(new Uint8Array(32)) + return Array.from(bytes) + .map((b) => b.toString(16).padStart(2, "0")) + .join("") +} + +function redirectUri(): string { + return `http://localhost:${OAUTH_PORT}${OAUTH_REDIRECT_PATH}` +} + +function buildAuthorizeUrl(state: string): string { + const params = new URLSearchParams({ + response_type: "token", + client_id: DO_OAUTH_CLIENT_ID, + redirect_uri: redirectUri(), + scope: "read write", + state, + }) + return `${DO_AUTHORIZE_URL}?${params.toString()}` +} + +const HTML_CALLBACK = ` + + + + OpenCode - DigitalOcean Authorization + + + +
+

Finishing sign-in...

+

You can close this window once it says you're signed in.

+
+ + +` + +async function startOAuthServer(): Promise { + if (oauthServer) return + oauthServer = createServer((req, res) => { + const url = new URL(req.url || "/", `http://localhost:${OAUTH_PORT}`) + + if (req.method === "GET" && url.pathname === OAUTH_REDIRECT_PATH) { + res.writeHead(200, { "Content-Type": "text/html" }) + res.end(HTML_CALLBACK) + return + } + + if (req.method === "POST" && url.pathname === OAUTH_TOKEN_PATH) { + const chunks: Buffer[] = [] + req.on("data", (chunk: Buffer) => chunks.push(chunk)) + req.on("end", () => { + const raw = Buffer.concat(chunks).toString("utf8") + let body: Record = {} + try { + body = raw ? JSON.parse(raw) : {} + } catch { + body = {} + } + if (!pendingOAuth) { + res.writeHead(409, { "Content-Type": "application/json" }) + res.end(JSON.stringify({ error: "no_pending_oauth" })) + return + } + if (body.error) { + const message = body.error_description || body.error || "OAuth error" + pendingOAuth.reject(new Error(String(message))) + pendingOAuth = undefined + res.writeHead(200, { "Content-Type": "application/json" }) + res.end(JSON.stringify({ ok: true })) + return + } + if (!body.access_token) { + pendingOAuth.reject(new Error("Missing access_token in callback")) + pendingOAuth = undefined + res.writeHead(400, { "Content-Type": "application/json" }) + res.end(JSON.stringify({ error: "missing_access_token" })) + return + } + if (body.state !== pendingOAuth.state) { + pendingOAuth.reject(new Error("Invalid state - potential CSRF attack")) + pendingOAuth = undefined + res.writeHead(400, { "Content-Type": "application/json" }) + res.end(JSON.stringify({ error: "invalid_state" })) + return + } + const expires = parseInt(body.expires_in || "0", 10) + pendingOAuth.resolve({ + access_token: body.access_token, + expires_in: Number.isFinite(expires) && expires > 0 ? expires : 60 * 60 * 24 * 30, + state: body.state, + }) + pendingOAuth = undefined + res.writeHead(200, { "Content-Type": "application/json" }) + res.end(JSON.stringify({ ok: true })) + }) + return + } + + res.writeHead(404) + res.end("Not found") + }) + + await new Promise((resolve, reject) => { + oauthServer!.listen(OAUTH_PORT, () => { + log.info("digitalocean oauth server started", { port: OAUTH_PORT }) + resolve() + }) + oauthServer!.on("error", reject) + }) +} + +function stopOAuthServer() { + if (!oauthServer) return + oauthServer.close(() => log.info("digitalocean oauth server stopped")) + oauthServer = undefined +} + +function waitForOAuthCallback(state: string): Promise { + return new Promise((resolve, reject) => { + const timeout = setTimeout( + () => { + if (pendingOAuth) { + pendingOAuth = undefined + reject(new Error("OAuth callback timeout - authorization took too long")) + } + }, + 5 * 60 * 1000, + ) + pendingOAuth = { + state, + resolve: (tokens) => { + clearTimeout(timeout) + resolve(tokens) + }, + reject: (error) => { + clearTimeout(timeout) + reject(error) + }, + } + }) +} + +async function createModelAccessKey(bearer: string): Promise { + // Suffix-on-collision strategy keeps re-`/connect` non-destructive. + const name = `${MAK_NAME_PREFIX}-${Math.floor(Date.now() / 1000)}` + const res = await fetch(`${DO_API_BASE}/v2/gen-ai/models/api_keys`, { + method: "POST", + headers: { + Authorization: `Bearer ${bearer}`, + "Content-Type": "application/json", + "User-Agent": `opencode/${InstallationVersion}`, + }, + body: JSON.stringify({ name }), + }) + if (!res.ok) { + const body = await res.text().catch(() => "") + throw new Error(`Failed to create Model Access Key (${res.status}): ${body}`) + } + const data = (await res.json()) as { api_key_info?: ApiKeyInfo } + if (!data.api_key_info?.secret_key) throw new Error("Model Access Key response missing secret_key") + return data.api_key_info +} + +async function listRouters(bearer: string): Promise<{ ok: true; routers: RouterEntry[] } | { ok: false; status: number }> { + const res = await fetch(`${DO_API_BASE}/v2/gen-ai/models/routers`, { + headers: { + Authorization: `Bearer ${bearer}`, + Accept: "application/json", + "User-Agent": `opencode/${InstallationVersion}`, + }, + signal: AbortSignal.timeout(10_000), + }).catch(() => undefined) + if (!res) return { ok: false, status: 0 } + if (!res.ok) return { ok: false, status: res.status } + const body = (await res.json().catch(() => undefined)) as { model_routers?: RouterEntry[] } | undefined + return { ok: true, routers: body?.model_routers ?? [] } +} + +function routerModel(router: RouterEntry, providerID: string): Model { + const id = `router:${router.name}` + return { + id, + providerID, + name: router.name, + family: "digitalocean-inference-routers", + api: { id, url: DO_INFERENCE_BASE, npm: "@ai-sdk/openai-compatible" }, + status: "active", + headers: {}, + options: {}, + cost: { input: 0, output: 0, cache: { read: 0, write: 0 } }, + limit: { context: 128_000, output: 8_192 }, + capabilities: { + temperature: true, + reasoning: false, + attachment: false, + toolcall: true, + input: { text: true, audio: false, image: false, video: false, pdf: false }, + output: { text: true, audio: false, image: false, video: false, pdf: false }, + interleaved: false, + }, + release_date: "", + variants: {}, + } +} + +function parseRoutersJSON(raw: string | undefined): RouterEntry[] { + if (!raw) return [] + try { + const parsed = JSON.parse(raw) + if (!Array.isArray(parsed)) return [] + return parsed.flatMap((r) => (r && typeof r.name === "string" ? [{ name: r.name, uuid: r.uuid, description: r.description }] : [])) + } catch { + return [] + } +} + +export async function DigitalOceanAuthPlugin(input: PluginInput): Promise { + return { + provider: { + id: "digitalocean", + async models(provider, ctx) { + const baseModels = provider.models + if (ctx.auth?.type !== "api") return baseModels + + const metadata = ctx.auth.metadata ?? {} + const oauthAccess = metadata["oauth_access"] + const oauthExpires = parseInt(metadata["oauth_expires"] || "0", 10) + const fetchedAt = parseInt(metadata["routers_fetched_at"] || "0", 10) + const cached = parseRoutersJSON(metadata["routers"]) + + let routers = cached + const stale = Date.now() - fetchedAt > ROUTER_REFRESH_INTERVAL_MS + const bearerValid = oauthAccess && oauthExpires > Date.now() + + if (bearerValid && stale) { + const result = await listRouters(oauthAccess) + if (result.ok) { + routers = result.routers + const updated: Record = { + ...metadata, + routers: JSON.stringify(routers.map((r) => ({ name: r.name, uuid: r.uuid, description: r.description }))), + routers_fetched_at: String(Date.now()), + } + await input.client.auth + .set({ + path: { id: "digitalocean" }, + body: { type: "api", key: ctx.auth.key, metadata: updated }, + }) + .catch((err) => log.warn("failed to persist refreshed routers", { error: err })) + } else if (result.status === 401 || result.status === 403) { + log.warn("digitalocean oauth bearer rejected; using cached routers", { status: result.status }) + } else if (result.status !== 0) { + log.warn("digitalocean router refresh failed", { status: result.status }) + } + } + + const merged: Record = { ...baseModels } + for (const router of routers) { + const id = `router:${router.name}` + if (merged[id]) continue + merged[id] = routerModel(router, "digitalocean") + } + return merged + }, + }, + auth: { + provider: "digitalocean", + methods: [ + { + type: "oauth", + label: "Login with DigitalOcean", + async authorize() { + await startOAuthServer() + const state = generateState() + const callbackPromise = waitForOAuthCallback(state) + return { + url: buildAuthorizeUrl(state), + instructions: + "Sign in to DigitalOcean in your browser. OpenCode will create a Model Access Key named opencode-oauth-* and load your Inference Routers. Re-run /connect to refresh routers later.", + method: "auto" as const, + async callback() { + try { + const tokens = await callbackPromise + const apiKeyInfo = await createModelAccessKey(tokens.access_token) + const routerResult = await listRouters(tokens.access_token) + const routers = routerResult.ok ? routerResult.routers : [] + if (!routerResult.ok) { + log.warn("digitalocean initial router fetch failed", { status: routerResult.status }) + } + return { + type: "success" as const, + provider: "digitalocean", + key: apiKeyInfo.secret_key, + metadata: { + mak_uuid: apiKeyInfo.uuid, + mak_name: apiKeyInfo.name, + oauth_access: tokens.access_token, + oauth_expires: String(Date.now() + tokens.expires_in * 1000), + routers: JSON.stringify( + routers.map((r) => ({ name: r.name, uuid: r.uuid, description: r.description })), + ), + routers_fetched_at: String(Date.now()), + }, + } + } catch (err) { + log.error("digitalocean oauth callback failed", { error: err }) + return { type: "failed" as const } + } finally { + stopOAuthServer() + } + }, + } + }, + }, + { + type: "api", + label: "Paste Model Access Key", + }, + ], + }, + } +} diff --git a/packages/opencode/src/plugin/index.ts b/packages/opencode/src/plugin/index.ts index 7a7f260df8..68d47916cc 100644 --- a/packages/opencode/src/plugin/index.ts +++ b/packages/opencode/src/plugin/index.ts @@ -19,6 +19,7 @@ import { gitlabAuthPlugin as GitlabAuthPlugin } from "opencode-gitlab-auth" import { PoeAuthPlugin } from "opencode-poe-auth" import { CloudflareAIGatewayAuthPlugin, CloudflareWorkersAuthPlugin } from "./cloudflare" import { AzureAuthPlugin } from "./azure" +import { DigitalOceanAuthPlugin } from "./digitalocean" import { Effect, Layer, Context, Stream } from "effect" import { EffectBridge } from "@/effect/bridge" import { InstanceState } from "@/effect/instance-state" @@ -64,6 +65,7 @@ const INTERNAL_PLUGINS: PluginInstance[] = [ CloudflareWorkersAuthPlugin, CloudflareAIGatewayAuthPlugin, AzureAuthPlugin, + DigitalOceanAuthPlugin, ] function isServerPlugin(value: unknown): value is PluginInstance { diff --git a/packages/opencode/src/provider/auth.ts b/packages/opencode/src/provider/auth.ts index ba2a8c7446..b63e1eaf44 100644 --- a/packages/opencode/src/provider/auth.ts +++ b/packages/opencode/src/provider/auth.ts @@ -197,6 +197,7 @@ export const layer: Layer.Layer = yield* auth.set(input.providerID, { type: "api", key: result.key, + ...(result.metadata ? { metadata: result.metadata } : {}), }) } diff --git a/packages/opencode/test/provider/digitalocean.test.ts b/packages/opencode/test/provider/digitalocean.test.ts new file mode 100644 index 0000000000..6515ea9701 --- /dev/null +++ b/packages/opencode/test/provider/digitalocean.test.ts @@ -0,0 +1,144 @@ +import { test, expect, afterEach } from "bun:test" +import path from "path" + +import { tmpdir } from "../fixture/fixture" +import { WithInstance } from "../../src/project/with-instance" +import { Provider } from "../../src/provider/provider" +import { ProviderID } from "../../src/provider/schema" +import { Env } from "../../src/env" +import { Effect } from "effect" +import { AppRuntime } from "../../src/effect/app-runtime" +import { makeRuntime } from "../../src/effect/run-service" + +const envRuntime = makeRuntime(Env.Service, Env.defaultLayer) +const set = (k: string, v: string) => envRuntime.runSync((svc) => svc.set(k, v)) + +async function list() { + return AppRuntime.runPromise( + Effect.gen(function* () { + const provider = yield* Provider.Service + return yield* provider.list() + }), + ) +} + +const DIGITALOCEAN = ProviderID.make("digitalocean") + +const originalAuthContent = process.env.OPENCODE_AUTH_CONTENT +afterEach(() => { + if (originalAuthContent === undefined) delete process.env.OPENCODE_AUTH_CONTENT + else process.env.OPENCODE_AUTH_CONTENT = originalAuthContent +}) + +function injectAuth(metadata: Record | undefined) { + process.env.OPENCODE_AUTH_CONTENT = JSON.stringify({ + digitalocean: { + type: "api", + key: "sk_do_test", + ...(metadata ? { metadata } : {}), + }, + }) +} + +test("digitalocean provider autoloads from DIGITALOCEAN_ACCESS_TOKEN", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ $schema: "https://opencode.ai/config.json" }), + ) + }, + }) + await WithInstance.provide({ + directory: tmp.path, + fn: async () => { + set("DIGITALOCEAN_ACCESS_TOKEN", "test-token") + const providers = await list() + expect(providers[DIGITALOCEAN]).toBeDefined() + expect(providers[DIGITALOCEAN].source).toBe("env") + const baseModel = Object.values(providers[DIGITALOCEAN].models)[0] + expect(baseModel.api.url).toBe("https://inference.do-ai.run/v1") + expect(baseModel.api.npm).toBe("@ai-sdk/openai-compatible") + const routerEntries = Object.keys(providers[DIGITALOCEAN].models).filter((id) => id.startsWith("router:")) + expect(routerEntries.length).toBe(0) + }, + }) +}) + +test("digitalocean provider.models surfaces cached routers from auth metadata", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ $schema: "https://opencode.ai/config.json" }), + ) + }, + }) + injectAuth({ + routers: JSON.stringify([ + { name: "my-router", uuid: "11f1499a-aaaa-bbbb-cccc-4e013e2ddde4" }, + { name: "other-router", uuid: "22f1499a-aaaa-bbbb-cccc-4e013e2ddde4" }, + ]), + routers_fetched_at: String(Date.now()), + oauth_access: "doo_v1_test", + oauth_expires: String(Date.now() + 60 * 60 * 1000), + }) + await WithInstance.provide({ + directory: tmp.path, + fn: async () => { + const providers = await list() + const models = providers[DIGITALOCEAN].models + expect(models["router:my-router"]).toBeDefined() + expect(models["router:my-router"].api.id).toBe("router:my-router") + expect(models["router:my-router"].api.url).toBe("https://inference.do-ai.run/v1") + expect(models["router:my-router"].api.npm).toBe("@ai-sdk/openai-compatible") + expect(models["router:other-router"]).toBeDefined() + }, + }) +}) + +test("digitalocean provider.models skips refresh when oauth bearer is expired", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ $schema: "https://opencode.ai/config.json" }), + ) + }, + }) + injectAuth({ + routers: JSON.stringify([{ name: "stale-router", uuid: "stale" }]), + routers_fetched_at: "0", + oauth_access: "doo_v1_expired", + oauth_expires: "1", + }) + await WithInstance.provide({ + directory: tmp.path, + fn: async () => { + const providers = await list() + const models = providers[DIGITALOCEAN].models + expect(models["router:stale-router"]).toBeDefined() + }, + }) +}) + +test("digitalocean provider.models passes through base models when no auth metadata", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ $schema: "https://opencode.ai/config.json" }), + ) + }, + }) + await WithInstance.provide({ + directory: tmp.path, + fn: async () => { + set("DIGITALOCEAN_ACCESS_TOKEN", "test-token") + const providers = await list() + const models = providers[DIGITALOCEAN].models + expect(Object.keys(models).length).toBeGreaterThan(0) + expect(Object.keys(models).filter((id) => id.startsWith("router:")).length).toBe(0) + }, + }) +}) diff --git a/packages/opencode/test/tool/fixtures/models-api.json b/packages/opencode/test/tool/fixtures/models-api.json index 5a3eb7e801..7ced5ca5d3 100644 --- a/packages/opencode/test/tool/fixtures/models-api.json +++ b/packages/opencode/test/tool/fixtures/models-api.json @@ -1,4 +1,28 @@ { + "digitalocean": { + "id": "digitalocean", + "env": ["DIGITALOCEAN_ACCESS_TOKEN"], + "npm": "@ai-sdk/openai-compatible", + "api": "https://inference.do-ai.run/v1", + "name": "DigitalOcean", + "doc": "https://docs.digitalocean.com/products/genai-platform/", + "models": { + "openai-gpt-oss-120b": { + "id": "openai-gpt-oss-120b", + "name": "GPT OSS 120B", + "attachment": false, + "reasoning": false, + "tool_call": true, + "temperature": true, + "release_date": "2025-08-05", + "last_updated": "2025-08-05", + "modalities": { "input": ["text"], "output": ["text"] }, + "open_weights": false, + "cost": { "input": 0.35, "output": 0.75 }, + "limit": { "context": 128000, "output": 16384 } + } + } + }, "ollama-cloud": { "id": "ollama-cloud", "env": ["OLLAMA_API_KEY"], diff --git a/packages/plugin/src/index.ts b/packages/plugin/src/index.ts index 2e96dd9801..6156477be2 100644 --- a/packages/plugin/src/index.ts +++ b/packages/plugin/src/index.ts @@ -8,10 +8,9 @@ import type { UserMessage, Message, Part, - Auth, Config as SDKConfig, } from "@opencode-ai/sdk" -import type { Provider as ProviderV2, Model as ModelV2 } from "@opencode-ai/sdk/v2" +import type { Provider as ProviderV2, Model as ModelV2, Auth } from "@opencode-ai/sdk/v2" import type { BunShell } from "./shell.js" import { type ToolDefinition } from "./tool.js" @@ -153,6 +152,7 @@ export type AuthHook = { type: "success" key: string provider?: string + metadata?: Record } | { type: "failed" @@ -177,7 +177,7 @@ export type AuthOAuthResult = { url: string; instructions: string } & ( accountId?: string enterpriseUrl?: string } - | { key: string } + | { key: string; metadata?: Record } )) | { type: "failed" @@ -198,7 +198,7 @@ export type AuthOAuthResult = { url: string; instructions: string } & ( accountId?: string enterpriseUrl?: string } - | { key: string } + | { key: string; metadata?: Record } )) | { type: "failed" diff --git a/packages/sdk/js/src/gen/types.gen.ts b/packages/sdk/js/src/gen/types.gen.ts index 8fd2a02b92..5e4fd89061 100644 --- a/packages/sdk/js/src/gen/types.gen.ts +++ b/packages/sdk/js/src/gen/types.gen.ts @@ -1666,6 +1666,9 @@ export type OAuth = { export type ApiAuth = { type: "api" key: string + metadata?: { + [key: string]: string + } } export type WellKnownAuth = { diff --git a/packages/ui/src/assets/icons/provider/digitalocean.svg b/packages/ui/src/assets/icons/provider/digitalocean.svg new file mode 100644 index 0000000000..5be390b9d3 --- /dev/null +++ b/packages/ui/src/assets/icons/provider/digitalocean.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/packages/ui/src/components/provider-icons/sprite.svg b/packages/ui/src/components/provider-icons/sprite.svg index a0214b40d0..68b99ce56d 100644 --- a/packages/ui/src/components/provider-icons/sprite.svg +++ b/packages/ui/src/components/provider-icons/sprite.svg @@ -854,6 +854,20 @@ d="M79.01 5.863c-4.066 0-6.511 2.92-6.511 6.535 0 3.635 2.445 6.555 6.511 6.555 4.046 0 6.512-2.92 6.512-6.555s-2.466-6.535-6.512-6.535Zm0 10.968c-2.633 0-4.172-1.933-4.172-4.433s1.539-4.455 4.172-4.455c2.635 0 4.151 1.933 4.151 4.434 0 2.521-1.516 4.454-4.15 4.454Zm14.393 2.096c3.393 0 5.542-1.808 5.837-4.539h-2.36c-.316 1.555-1.517 2.437-3.477 2.437-2.423 0-3.878-1.68-3.878-4.433 0-2.774 1.476-4.434 3.878-4.434 1.96 0 3.14.862 3.477 2.5h2.36c-.295-2.773-2.444-4.622-5.837-4.622-3.856 0-6.217 2.669-6.217 6.535 0 3.887 2.36 6.556 6.217 6.556Zm-29.543-.311h2.36v-6.01c0-2.752 1.348-4.244 3.772-4.244h2.276V6.177h-2.255c-2.128 0-3.288.735-3.898 2.605l-.443-.063.527-2.542h-2.36v12.439h.02Zm-24.445-7.332c.106-2.101 1.517-3.53 3.793-3.53 2.276 0 3.646 1.345 3.646 3.53h-7.439Zm9.778.4c0-3.426-2.381-5.821-5.943-5.821-3.73 0-6.174 2.563-6.174 6.535 0 4.013 2.423 6.555 6.28 6.555 2.929 0 5.247-1.597 5.669-3.887h-2.36c-.507 1.156-1.666 1.828-3.31 1.828-2.38 0-3.877-1.408-3.94-3.803h9.694c.042-.588.084-.861.084-1.408Zm5.69 6.932h1.939l5.5-12.44h-2.529L56 15.99l-.316.021-3.793-9.833h-2.508l5.5 12.439ZM32.23 12.35c0-.882-.359-1.701-.99-2.437a8.594 8.594 0 0 1-1.497 1.093c.337.42.527.861.527 1.345 0 2.731-5.837 4.811-14.14 4.811-8.281.021-14.118-2.059-14.118-4.811 0-.463.168-.925.505-1.345a8.13 8.13 0 0 1-1.475-1.093c-.632.736-.99 1.555-.99 2.438 0 4.034 7.207 6.534 16.1 6.534 8.87.021 16.078-2.5 16.078-6.535Zm-3.351 1.534c-.906-.462-1.96-.861-3.16-1.197-1.37.378-2.909.672-4.553.861 2.318.294 4.341.778 5.9 1.408.76-.336 1.37-.693 1.813-1.072Zm-17.849-.357a31.902 31.902 0 0 1-4.467-.84c-1.18.336-2.255.735-3.16 1.197.42.379 1.01.715 1.748 1.05 1.539-.63 3.52-1.113 5.88-1.407Zm21.2-6.808c0-4.013-7.207-6.534-16.079-6.534C7.26.185.051 2.706.051 6.719c0 4.035 7.208 6.535 16.1 6.535 8.872.021 16.079-2.5 16.079-6.535Zm-1.94 0c0 2.732-5.836 4.812-14.139 4.812-8.302.021-14.14-2.06-14.14-4.812 0-2.731 5.838-4.811 14.14-4.811 7.86 0 14.14 2.08 14.14 4.811Zm-3.223 2.564c.758-.336 1.37-.694 1.812-1.072-2.95-1.513-7.544-2.353-12.728-2.353s-9.799.84-12.728 2.353c.422.378 1.012.715 1.75 1.05 2.507-1.05 6.363-1.68 10.978-1.68 4.404 0 8.324.651 10.916 1.702ZM1.042 15.628c-.632.736-.99 1.534-.99 2.438 0 4.034 7.207 6.534 16.1 6.534 8.892 0 16.099-2.521 16.099-6.534 0-.883-.359-1.702-.99-2.438-.422.4-.907.757-1.497 1.093.337.42.527.861.527 1.345 0 2.731-5.837 4.811-14.14 4.811-8.302 0-14.14-2.08-14.14-4.811 0-.463.17-.925.506-1.345a10.73 10.73 0 0 1-1.475-1.093Z" > + + + + + + ` in your DigitalOcean account. You can rotate or revoke it from the **Model Access Keys** page in the "Manage" section of the DigitalOcean console under Inference. + ::: + +4. Run the `/models` command. Your Inference Routers appear as the format `router:` in the model selection. + + ```txt + /models + ``` + +5. To pick up newly created Inference Routers, re-run `/connect` and select **DigitalOcean** again. + +#### Using a Model Access Key + +If you'd rather paste a key directly: + +1. Head over to the **Manage** page in the Inference section of the [DigitalOcean console](https://cloud.digitalocean.com/) and create a new key. + +2. Run the `/connect` command and select **DigitalOcean**, then **Paste Model Access Key**. + + ```txt + ┌ Enter your DigitalOcean Model Access Key + │ + │ + └ enter + ``` + + :::note + Inference Routers are not auto-discovered with this method. To surface them in the model picker, sign in via OAuth instead. + ::: + +3. Run the `/models` command to select a model. + + ```txt + /models + ``` + +#### Environment Variable + +Alternatively, set your Model Access Key as an environment variable. + +```bash frame="none" +export DIGITALOCEAN_ACCESS_TOKEN=your-model-access-key +``` + +#### Inference Routers + +Inference Routers let you define a routing policy across multiple models — picking the cheapest, fastest, or most appropriate model per request based on the task. After OAuth, OpenCode surfaces each router as `router:` in the model picker. + +Selecting a router model is a drop-in replacement for any other model — OpenCode forwards your request and DigitalOcean picks the underlying model based on your router's policy. Learn more about [Inference Routers](https://docs.digitalocean.com/products/inference/how-to/use-inference-router/) + +--- + ### FrogBot 1. Head over to the [FrogBot dashboard](https://app.frogbot.ai/signup), create an account, and generate an API key. From cb511f78ffb90899a1b79f7bc130264b52674299 Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Tue, 12 May 2026 16:23:15 -0500 Subject: [PATCH 67/70] fix(plugin): preserve tool attachments (#27157) --- packages/opencode/src/tool/registry.ts | 4 +- packages/opencode/test/tool/registry.test.ts | 50 ++++++++++++++++++++ packages/plugin/src/tool.ts | 16 ++++++- 3 files changed, 68 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index f72f10dd1f..7de3c8f4e8 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -160,11 +160,13 @@ export const layer: Layer.Layer< const result = yield* Effect.promise(() => def.execute(args as any, pluginCtx)) const output = typeof result === "string" ? result : result.output const metadata = typeof result === "string" ? {} : (result.metadata ?? {}) + const attachments = typeof result === "string" ? undefined : result.attachments const info = yield* agent.get(toolCtx.agent) const out = yield* truncate.output(output, {}, info) return { - title: "", + title: typeof result === "string" ? "" : (result.title ?? ""), output: out.truncated ? out.content : output, + attachments, metadata: { ...metadata, truncated: out.truncated, diff --git a/packages/opencode/test/tool/registry.test.ts b/packages/opencode/test/tool/registry.test.ts index fb4dd31a5f..595fcd8082 100644 --- a/packages/opencode/test/tool/registry.test.ts +++ b/packages/opencode/test/tool/registry.test.ts @@ -5,6 +5,7 @@ import { pathToFileURL } from "url" import { Effect, Layer, Result, Schema } from "effect" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { ToolRegistry } from "@/tool/registry" +import { Tool } from "@/tool/tool" import { Flag } from "@opencode-ai/core/flag/flag" import { disposeAllInstances, TestInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" @@ -29,6 +30,7 @@ import { InstanceState } from "@/effect/instance-state" import { Reference } from "@/reference/reference" import { ProviderID, ModelID } from "@/provider/schema" import { ToolJsonSchema } from "@/tool/json-schema" +import { MessageID, SessionID } from "@/session/schema" const node = CrossSpawnSpawner.defaultLayer const originalExperimentalScout = Flag.OPENCODE_EXPERIMENTAL_SCOUT @@ -193,6 +195,54 @@ describe("tool.registry", () => { }), ) + it.instance("preserves attachments from structured custom tool results", () => + Effect.gen(function* () { + const test = yield* TestInstance + const customTools = path.join(test.directory, ".opencode", "tools") + const pluginTool = pathToFileURL(path.resolve(import.meta.dir, "../../../plugin/src/tool.ts")).href + yield* Effect.promise(() => fs.mkdir(customTools, { recursive: true })) + yield* Effect.promise(() => + Bun.write( + path.join(customTools, "image.ts"), + [ + `import { tool } from ${JSON.stringify(pluginTool)}`, + "export default tool({", + " description: 'image tool',", + " args: {},", + " execute: async () => ({", + " output: 'here is an image',", + " attachments: [{ type: 'file', mime: 'image/png', filename: 'picture.png', url: 'data:image/png;base64,AAAA' }],", + " }),", + "})", + "", + ].join("\n"), + ), + ) + + const registry = yield* ToolRegistry.Service + const loaded = (yield* registry.all()).find((tool) => tool.id === "image") + if (!loaded) throw new Error("custom image tool was not loaded") + const agents = yield* Agent.Service + const result = yield* loaded.execute( + {}, + { + sessionID: SessionID.make("ses_test"), + messageID: MessageID.make("msg_test"), + agent: (yield* agents.defaultInfo()).name, + abort: new AbortController().signal, + messages: [], + metadata: () => Effect.void, + ask: () => Effect.void, + } satisfies Tool.Context, + ) + + expect(result.output).toBe("here is an image") + expect(result.attachments).toEqual([ + { type: "file", mime: "image/png", filename: "picture.png", url: "data:image/png;base64,AAAA" }, + ]) + }), + ) + it.instance("loads legacy JSON-schema-shaped custom tools with wire schema", () => Effect.gen(function* () { const test = yield* TestInstance diff --git a/packages/plugin/src/tool.ts b/packages/plugin/src/tool.ts index 3105bf534b..b8a634c796 100644 --- a/packages/plugin/src/tool.ts +++ b/packages/plugin/src/tool.ts @@ -27,7 +27,21 @@ type AskInput = { metadata: { [key: string]: any } } -export type ToolResult = string | { output: string; metadata?: { [key: string]: any } } +export type ToolAttachment = { + type: "file" + mime: string + url: string + filename?: string +} + +export type ToolResult = + | string + | { + title?: string + output: string + metadata?: { [key: string]: any } + attachments?: ToolAttachment[] + } export function tool(input: { description: string From 2cb697b72050c1f7a9661be59c2d610e6ae81d89 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Tue, 12 May 2026 21:24:39 +0000 Subject: [PATCH 68/70] chore: generate --- packages/opencode/src/plugin/digitalocean.ts | 8 +++++-- .../test/provider/digitalocean.test.ts | 20 ++++-------------- packages/opencode/test/tool/registry.test.ts | 21 ++++++++----------- 3 files changed, 19 insertions(+), 30 deletions(-) diff --git a/packages/opencode/src/plugin/digitalocean.ts b/packages/opencode/src/plugin/digitalocean.ts index 31656656f1..fa4adf6331 100644 --- a/packages/opencode/src/plugin/digitalocean.ts +++ b/packages/opencode/src/plugin/digitalocean.ts @@ -246,7 +246,9 @@ async function createModelAccessKey(bearer: string): Promise { return data.api_key_info } -async function listRouters(bearer: string): Promise<{ ok: true; routers: RouterEntry[] } | { ok: false; status: number }> { +async function listRouters( + bearer: string, +): Promise<{ ok: true; routers: RouterEntry[] } | { ok: false; status: number }> { const res = await fetch(`${DO_API_BASE}/v2/gen-ai/models/routers`, { headers: { Authorization: `Bearer ${bearer}`, @@ -293,7 +295,9 @@ function parseRoutersJSON(raw: string | undefined): RouterEntry[] { try { const parsed = JSON.parse(raw) if (!Array.isArray(parsed)) return [] - return parsed.flatMap((r) => (r && typeof r.name === "string" ? [{ name: r.name, uuid: r.uuid, description: r.description }] : [])) + return parsed.flatMap((r) => + r && typeof r.name === "string" ? [{ name: r.name, uuid: r.uuid, description: r.description }] : [], + ) } catch { return [] } diff --git a/packages/opencode/test/provider/digitalocean.test.ts b/packages/opencode/test/provider/digitalocean.test.ts index 6515ea9701..6fc49a6eff 100644 --- a/packages/opencode/test/provider/digitalocean.test.ts +++ b/packages/opencode/test/provider/digitalocean.test.ts @@ -43,10 +43,7 @@ function injectAuth(metadata: Record | undefined) { test("digitalocean provider autoloads from DIGITALOCEAN_ACCESS_TOKEN", async () => { await using tmp = await tmpdir({ init: async (dir) => { - await Bun.write( - path.join(dir, "opencode.json"), - JSON.stringify({ $schema: "https://opencode.ai/config.json" }), - ) + await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ $schema: "https://opencode.ai/config.json" })) }, }) await WithInstance.provide({ @@ -68,10 +65,7 @@ test("digitalocean provider autoloads from DIGITALOCEAN_ACCESS_TOKEN", async () test("digitalocean provider.models surfaces cached routers from auth metadata", async () => { await using tmp = await tmpdir({ init: async (dir) => { - await Bun.write( - path.join(dir, "opencode.json"), - JSON.stringify({ $schema: "https://opencode.ai/config.json" }), - ) + await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ $schema: "https://opencode.ai/config.json" })) }, }) injectAuth({ @@ -100,10 +94,7 @@ test("digitalocean provider.models surfaces cached routers from auth metadata", test("digitalocean provider.models skips refresh when oauth bearer is expired", async () => { await using tmp = await tmpdir({ init: async (dir) => { - await Bun.write( - path.join(dir, "opencode.json"), - JSON.stringify({ $schema: "https://opencode.ai/config.json" }), - ) + await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ $schema: "https://opencode.ai/config.json" })) }, }) injectAuth({ @@ -125,10 +116,7 @@ test("digitalocean provider.models skips refresh when oauth bearer is expired", test("digitalocean provider.models passes through base models when no auth metadata", async () => { await using tmp = await tmpdir({ init: async (dir) => { - await Bun.write( - path.join(dir, "opencode.json"), - JSON.stringify({ $schema: "https://opencode.ai/config.json" }), - ) + await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ $schema: "https://opencode.ai/config.json" })) }, }) await WithInstance.provide({ diff --git a/packages/opencode/test/tool/registry.test.ts b/packages/opencode/test/tool/registry.test.ts index 595fcd8082..b2beda70ca 100644 --- a/packages/opencode/test/tool/registry.test.ts +++ b/packages/opencode/test/tool/registry.test.ts @@ -223,18 +223,15 @@ describe("tool.registry", () => { const loaded = (yield* registry.all()).find((tool) => tool.id === "image") if (!loaded) throw new Error("custom image tool was not loaded") const agents = yield* Agent.Service - const result = yield* loaded.execute( - {}, - { - sessionID: SessionID.make("ses_test"), - messageID: MessageID.make("msg_test"), - agent: (yield* agents.defaultInfo()).name, - abort: new AbortController().signal, - messages: [], - metadata: () => Effect.void, - ask: () => Effect.void, - } satisfies Tool.Context, - ) + const result = yield* loaded.execute({}, { + sessionID: SessionID.make("ses_test"), + messageID: MessageID.make("msg_test"), + agent: (yield* agents.defaultInfo()).name, + abort: new AbortController().signal, + messages: [], + metadata: () => Effect.void, + ask: () => Effect.void, + } satisfies Tool.Context) expect(result.output).toBe("here is an image") expect(result.attachments).toEqual([ From 65368f609d851a9e832d8dd176903df9a690a635 Mon Sep 17 00:00:00 2001 From: Andrew Suffield Date: Tue, 12 May 2026 18:09:06 -0400 Subject: [PATCH 69/70] fix: preserve permission ordering by accepting a layered array (#23214) Co-authored-by: Andrew Suffield Co-authored-by: Aiden Cline --- packages/opencode/src/agent/agent.ts | 6 +- packages/opencode/src/config/config.ts | 22 ++- packages/opencode/src/config/permission.ts | 8 + packages/opencode/test/config/config.test.ts | 178 +++++++++++++++++- .../opencode/test/permission-task.test.ts | 25 ++- 5 files changed, 221 insertions(+), 18 deletions(-) diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index 423a513180..74ca1a402b 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -1,4 +1,5 @@ import { Config } from "@/config/config" +import { ConfigPermission } from "@/config/permission" import { Provider } from "@/provider/provider" import { ModelID, ProviderID } from "../provider/schema" import { generateObject, streamObject, type ModelMessage } from "ai" @@ -117,7 +118,10 @@ export const layer = Layer.effect( }, }) - const user = Permission.fromConfig(cfg.permission ?? {}) + // Convert permission layers to rulesets and merge them + // Each layer's rules come after the previous, so later configs override earlier ones + const layers = ConfigPermission.toLayers(cfg.permission) + const user = Permission.merge(...layers.map((p) => Permission.fromConfig(p))) const agents: Record = { build: { diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 545e48e64d..91eeab47e4 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -55,6 +55,13 @@ function mergeConfigConcatArrays(target: Info, source: Info): Info { if (target.instructions && source.instructions) { merged.instructions = Array.from(new Set([...target.instructions, ...source.instructions])) } + // Accumulate permission layers for later merging as rulesets. + // This preserves the ordering semantics: later rules override earlier rules. + // Each layer keeps the raw shape the user wrote on disk; consumers should use + // ConfigPermission.toLayers to normalise. + if (source.permission) { + merged.permission = [...ConfigPermission.toLayers(target.permission), ...ConfigPermission.toLayers(source.permission)] + } return merged } @@ -228,7 +235,12 @@ export const Info = Schema.Struct({ description: "Additional instruction files or patterns to include", }), layout: Schema.optional(ConfigLayout.Layout).annotate({ description: "@deprecated Always uses stretch layout." }), - permission: Schema.optional(ConfigPermission.Info), + permission: Schema.optional( + Schema.Union([ConfigPermission.Info, Schema.mutable(Schema.Array(ConfigPermission.Info))]), + ).annotate({ + description: + "Permission configuration. Accepts a single object (per-tool action map) or an array of layered configs; arrays are merged in order so later layers override earlier ones.", + }), tools: Schema.optional(Schema.Record(Schema.String, Schema.Boolean)), attachment: Schema.optional(ConfigAttachment.Info).annotate({ description: "Attachment processing configuration, including image size limits and resizing behavior", @@ -704,11 +716,12 @@ export const layer = Layer.effect( } if (Flag.OPENCODE_PERMISSION) { - result.permission = mergeDeep(result.permission ?? {}, JSON.parse(Flag.OPENCODE_PERMISSION)) + const envPermission = JSON.parse(Flag.OPENCODE_PERMISSION) as ConfigPermission.Info + result.permission = [...ConfigPermission.toLayers(result.permission), envPermission] } if (result.tools) { - const perms: Record = {} + const perms: ConfigPermission.Info = {} for (const [tool, enabled] of Object.entries(result.tools)) { const action: ConfigPermission.Action = enabled ? "allow" : "deny" if (tool === "write" || tool === "edit" || tool === "patch") { @@ -717,7 +730,8 @@ export const layer = Layer.effect( } perms[tool] = action } - result.permission = mergeDeep(perms, result.permission ?? {}) + // Tools permissions come before other permissions (they can be overridden) + result.permission = [perms, ...ConfigPermission.toLayers(result.permission)] } if (!result.username) result.username = os.userInfo().username diff --git a/packages/opencode/src/config/permission.ts b/packages/opencode/src/config/permission.ts index 1092ae2b7e..c780d24436 100644 --- a/packages/opencode/src/config/permission.ts +++ b/packages/opencode/src/config/permission.ts @@ -56,3 +56,11 @@ export const Info = InputSchema.pipe( ).annotate({ identifier: "PermissionConfig" }) type _Info = Schema.Schema.Type export type Info = { -readonly [K in keyof _Info]: _Info[K] } + +// Top-level config accepts either a single permission object or an array of +// layered configs. Internal merging produces arrays; this helper normalises +// either shape into the array form expected by consumers. +export function toLayers(value: Info | Info[] | undefined): Info[] { + if (!value) return [] + return Array.isArray(value) ? value : [value] +} diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts index 90e78efcdb..a2e4391777 100644 --- a/packages/opencode/test/config/config.test.ts +++ b/packages/opencode/test/config/config.test.ts @@ -3,7 +3,9 @@ import { Effect, Layer, Option } from "effect" import { NodeFileSystem, NodePath } from "@effect/platform-node" import { Config } from "@/config/config" import { ConfigManaged } from "@/config/managed" +import { ConfigPermission } from "@/config/permission" import { ConfigParse } from "../../src/config/parse" +import { Permission } from "../../src/permission" import { EffectFlock } from "@opencode-ai/core/util/effect-flock" import { Instance } from "../../src/project/instance" @@ -276,6 +278,40 @@ test("updates global config and omits empty shell key in json", async () => { } }) +test("global config update preserves single-object permission shape on disk", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Filesystem.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + shell: "bash", + permission: { bash: "ask" }, + }), + ) + }, + }) + + const prev = Global.Path.config + ;(Global.Path as { config: string }).config = tmp.path + await clear(true) + + try { + // Updating an unrelated key must not rewrite `permission` from object to array form. + await saveGlobal({ shell: "zsh" }) + + const written = await Filesystem.readJson<{ permission?: unknown; shell?: string }>( + path.join(tmp.path, "opencode.json"), + ) + expect(written.shell).toBe("zsh") + expect(Array.isArray(written.permission)).toBe(false) + expect(written.permission).toEqual({ bash: "ask" }) + } finally { + ;(Global.Path as { config: string }).config = prev + await clear(true) + } +}) + test("updates global config and omits empty shell key in jsonc", async () => { await using tmp = await tmpdir({ init: async (dir) => { @@ -1713,7 +1749,10 @@ test("permission config preserves user key order", async () => { directory: tmp.path, fn: async () => { const config = await load() - expect(Object.keys(config.permission!)).toEqual([ + // load() goes through the merge pipeline, producing the layered array form + expect(config.permission).toHaveLength(1) + const perm = (config.permission as ConfigPermission.Info[])[0] + expect(Object.keys(perm)).toEqual([ "*", "edit", "write", @@ -1729,6 +1768,129 @@ test("permission config preserves user key order", async () => { }) }) +// Global bash "rm *" deny is inherited, but user's top-level "*" ask comes after and overrides it +test("user top-level catchall overrides inherited bash rules", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Filesystem.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + permission: { + bash: { "rm *": "deny" }, + }, + }), + ) + const opencodeDir = path.join(dir, ".opencode") + await fs.mkdir(opencodeDir, { recursive: true }) + await Filesystem.write( + path.join(opencodeDir, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + permission: { + "*": "ask", + bash: { "ls *": "allow" }, + }, + }), + ) + }, + }) + await WithInstance.provide({ + directory: tmp.path, + fn: async () => { + const config = await load() + const layers = ConfigPermission.toLayers(config.permission) + const ruleset = Permission.merge(...layers.map((p) => Permission.fromConfig(p))) + + expect(Permission.evaluate("bash", "rm -rf /", ruleset).action).toBe("ask") + expect(Permission.evaluate("bash", "ls -la", ruleset).action).toBe("allow") + expect(Permission.evaluate("bash", "echo hello", ruleset).action).toBe("ask") + }, + }) +}) + +// No top-level catchall, so global bash "rm *" deny is preserved +test("inherited bash rules apply when no user top-level catchall", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Filesystem.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + permission: { + bash: { "rm *": "deny" }, + }, + }), + ) + const opencodeDir = path.join(dir, ".opencode") + await fs.mkdir(opencodeDir, { recursive: true }) + await Filesystem.write( + path.join(opencodeDir, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + permission: { + bash: { "ls *": "allow" }, + }, + }), + ) + }, + }) + await WithInstance.provide({ + directory: tmp.path, + fn: async () => { + const config = await load() + const layers = ConfigPermission.toLayers(config.permission) + const ruleset = Permission.merge(...layers.map((p) => Permission.fromConfig(p))) + + expect(Permission.evaluate("bash", "rm -rf /", ruleset).action).toBe("deny") + expect(Permission.evaluate("bash", "ls -la", ruleset).action).toBe("allow") + }, + }) +}) + +// User's bash "*" catchall overrides global "rm *" deny +test("user bash catchall overrides inherited bash rules", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Filesystem.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + permission: { + bash: { "rm *": "deny" }, + }, + }), + ) + const opencodeDir = path.join(dir, ".opencode") + await fs.mkdir(opencodeDir, { recursive: true }) + await Filesystem.write( + path.join(opencodeDir, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + permission: { + bash: { "*": "ask", "ls *": "allow" }, + }, + }), + ) + }, + }) + await WithInstance.provide({ + directory: tmp.path, + fn: async () => { + const config = await load() + const layers = ConfigPermission.toLayers(config.permission) + const ruleset = Permission.merge(...layers.map((p) => Permission.fromConfig(p))) + + expect(Permission.evaluate("bash", "rm -rf /", ruleset).action).toBe("ask") + expect(Permission.evaluate("bash", "ls -la", ruleset).action).toBe("allow") + expect(Permission.evaluate("bash", "echo hello", ruleset).action).toBe("ask") + + // Non-bash permissions should use the top-level "*" rule + expect(Permission.evaluate("read", "foo.txt", ruleset).action).toBe("ask") + }, + }) +}) + test("config parser preserves permission order while rejecting unknown top-level keys", () => { const config = ConfigParse.schema( Config.Info, @@ -1742,7 +1904,8 @@ test("config parser preserves permission order while rejecting unknown top-level "test", ) - expect(Object.keys(config.permission!)).toEqual(["bash", "*", "edit"]) + // ConfigParse.schema preserves the raw shape the user wrote + expect(Object.keys(config.permission as ConfigPermission.Info)).toEqual(["bash", "*", "edit"]) try { ConfigParse.schema(Config.Info, { invalid_field: true }, "test") throw new Error("expected config parse to fail") @@ -2579,11 +2742,12 @@ test("parseManagedPlist parses permission rules", async () => { ), "test:mobileconfig", ) - expect(config.permission?.["*"]).toBe("ask") - expect(config.permission?.grep).toBe("allow") - expect(config.permission?.webfetch).toBe("ask") - expect(config.permission?.["~/.ssh/*"]).toBe("deny") - const bash = config.permission?.bash as Record + const perm = config.permission as ConfigPermission.Info + expect(perm?.["*"]).toBe("ask") + expect(perm?.grep).toBe("allow") + expect(perm?.webfetch).toBe("ask") + expect(perm?.["~/.ssh/*"]).toBe("deny") + const bash = perm?.bash as Record expect(bash?.["rm -rf *"]).toBe("deny") expect(bash?.["curl *"]).toBe("deny") }) diff --git a/packages/opencode/test/permission-task.test.ts b/packages/opencode/test/permission-task.test.ts index 64b93bb8bc..77ceda8a4c 100644 --- a/packages/opencode/test/permission-task.test.ts +++ b/packages/opencode/test/permission-task.test.ts @@ -1,6 +1,7 @@ import { afterEach, describe, test, expect } from "bun:test" import { Permission } from "../src/permission" import { Config } from "@/config/config" +import { ConfigPermission } from "@/config/permission" import { Instance } from "../src/project/instance" import { WithInstance } from "../src/project/with-instance" import { disposeAllInstances, tmpdir } from "./fixture/fixture" @@ -163,7 +164,9 @@ describe("permission.task with real config files", () => { directory: tmp.path, fn: async () => { const config = await load() - const ruleset = Permission.fromConfig(config.permission ?? {}) + const ruleset = Permission.merge( + ...ConfigPermission.toLayers(config.permission).map((p) => Permission.fromConfig(p)), + ) // general and orchestrator-fast should be allowed, code-reviewer denied expect(Permission.evaluate("task", "general", ruleset).action).toBe("allow") expect(Permission.evaluate("task", "orchestrator-fast", ruleset).action).toBe("allow") @@ -188,7 +191,9 @@ describe("permission.task with real config files", () => { directory: tmp.path, fn: async () => { const config = await load() - const ruleset = Permission.fromConfig(config.permission ?? {}) + const ruleset = Permission.merge( + ...ConfigPermission.toLayers(config.permission).map((p) => Permission.fromConfig(p)), + ) // general and code-reviewer should be ask, orchestrator-* denied expect(Permission.evaluate("task", "general", ruleset).action).toBe("ask") expect(Permission.evaluate("task", "code-reviewer", ruleset).action).toBe("ask") @@ -213,7 +218,9 @@ describe("permission.task with real config files", () => { directory: tmp.path, fn: async () => { const config = await load() - const ruleset = Permission.fromConfig(config.permission ?? {}) + const ruleset = Permission.merge( + ...ConfigPermission.toLayers(config.permission).map((p) => Permission.fromConfig(p)), + ) expect(Permission.evaluate("task", "general", ruleset).action).toBe("allow") expect(Permission.evaluate("task", "code-reviewer", ruleset).action).toBe("deny") // Unspecified agents default to "ask" @@ -240,7 +247,9 @@ describe("permission.task with real config files", () => { directory: tmp.path, fn: async () => { const config = await load() - const ruleset = Permission.fromConfig(config.permission ?? {}) + const ruleset = Permission.merge( + ...ConfigPermission.toLayers(config.permission).map((p) => Permission.fromConfig(p)), + ) // Verify task permissions expect(Permission.evaluate("task", "general", ruleset).action).toBe("allow") @@ -278,7 +287,9 @@ describe("permission.task with real config files", () => { directory: tmp.path, fn: async () => { const config = await load() - const ruleset = Permission.fromConfig(config.permission ?? {}) + const ruleset = Permission.merge( + ...ConfigPermission.toLayers(config.permission).map((p) => Permission.fromConfig(p)), + ) // Last matching rule wins - "*" deny is last, so all agents are denied expect(Permission.evaluate("task", "general", ruleset).action).toBe("deny") @@ -309,7 +320,9 @@ describe("permission.task with real config files", () => { directory: tmp.path, fn: async () => { const config = await load() - const ruleset = Permission.fromConfig(config.permission ?? {}) + const ruleset = Permission.merge( + ...ConfigPermission.toLayers(config.permission).map((p) => Permission.fromConfig(p)), + ) // Evaluate uses findLast - "general" allow comes after "*" deny expect(Permission.evaluate("task", "general", ruleset).action).toBe("allow") From baef5cd43ba1abb921918708d56ccbd08ebeaaec Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Tue, 12 May 2026 22:11:13 +0000 Subject: [PATCH 70/70] chore: generate --- packages/opencode/src/config/config.ts | 5 ++++- packages/sdk/js/src/v2/gen/types.gen.ts | 5 ++++- packages/sdk/openapi.json | 13 ++++++++++++- 3 files changed, 20 insertions(+), 3 deletions(-) diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 91eeab47e4..78bb83ed8e 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -60,7 +60,10 @@ function mergeConfigConcatArrays(target: Info, source: Info): Info { // Each layer keeps the raw shape the user wrote on disk; consumers should use // ConfigPermission.toLayers to normalise. if (source.permission) { - merged.permission = [...ConfigPermission.toLayers(target.permission), ...ConfigPermission.toLayers(source.permission)] + merged.permission = [ + ...ConfigPermission.toLayers(target.permission), + ...ConfigPermission.toLayers(source.permission), + ] } return merged } diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index f062700b7d..b42935519b 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -1263,7 +1263,10 @@ export type Config = { } instructions?: Array layout?: LayoutConfig - permission?: PermissionConfig + /** + * Permission configuration. Accepts a single object (per-tool action map) or an array of layered configs; arrays are merged in order so later layers override earlier ones. + */ + permission?: PermissionConfig | Array tools?: { [key: string]: boolean } diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 7005382f6b..eed0282fc7 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -12407,7 +12407,18 @@ "$ref": "#/components/schemas/LayoutConfig" }, "permission": { - "$ref": "#/components/schemas/PermissionConfig" + "anyOf": [ + { + "$ref": "#/components/schemas/PermissionConfig" + }, + { + "type": "array", + "items": { + "$ref": "#/components/schemas/PermissionConfig" + } + } + ], + "description": "Permission configuration. Accepts a single object (per-tool action map) or an array of layered configs; arrays are merged in order so later layers override earlier ones." }, "tools": { "type": "object",