Compare commits

..
Author SHA1 Message Date
Kit Langton 9545bb3a89 fix(core): refresh OAuth credentials during batched activation 2026-08-27 22:55:13 -04:00
Kit Langton d84d1421ae test(core): bind acquired test services (#45711) 2026-08-27 22:43:09 -04:00
Kit Langton dd2674e9c4 refactor(core): reuse Mercurial diff preparation (#45652) 2026-08-27 22:42:47 -04:00
Kit Langton ade8175ad8 refactor(core): use environment driver namespace (#45707) 2026-08-27 22:42:36 -04:00
Kit Langton b0db52da6f refactor(tui): remove inert diff viewer setup (#45687) 2026-08-27 22:42:29 -04:00
Kit Langton 9d673d7fb3 docs(core): align built-in prompt guidance (#45642) 2026-08-27 22:42:19 -04:00
Kit Langton 9cab8b45b2 refactor(core): remove unreachable transport state (#45628) 2026-08-27 22:42:11 -04:00
Kit Langton 17aa942541 refactor(core): reuse tool name normalization (#45644) 2026-08-27 22:42:00 -04:00
Kit Langton cbc3e63869 refactor(core): simplify skill discovery validation (#45729) 2026-08-27 22:41:51 -04:00
Kit Langton 2ce126e2df test(core): simplify runner scenario fixtures (#45440) 2026-08-27 22:37:07 -04:00
Aiden Cline 1faba03cfe fix(core): normalize Windows tool paths (#45806) 2026-08-27 21:36:22 -05:00
Kit Langton 09b0edd662 refactor(core): compare plugin generations directly (#45728) 2026-08-27 22:31:42 -04:00
Kit Langton 0c75b7b3c7 feat(core): per-instance plugin input (#45732) 2026-08-28 02:31:36 +00:00
Kit Langton fd77a721ac test(core): align session test vocabulary (#45670) 2026-08-27 22:31:33 -04:00
Kit Langton 11aab207f0 test(core): remove dead test setup (#45669) 2026-08-27 22:31:26 -04:00
Kit Langton 04c9e01dad refactor: normalize filesystem boundary imports (#45730) 2026-08-27 22:31:15 -04:00
Kit Langton 762291b2a8 feat(core): durable session metadata at creation (#45805) 2026-08-28 02:30:55 +00:00
Kit Langton e0bad10065 test(core): exercise fsutil passthroughs (#45667) 2026-08-27 22:30:43 -04:00
Kit Langton e6d656dec1 refactor(core): clarify PTY binary validation (#45715) 2026-08-27 22:30:35 -04:00
Kit Langton d34dd6c14a refactor(core): type native PTY import explicitly (#45710) 2026-08-27 22:30:15 -04:00
Kit Langton 43c8690c14 docs(core): correct newtype decoding guidance (#45736) 2026-08-27 22:30:07 -04:00
Kit Langton 35d681afab test(core): make test sentinels effective (#45706) 2026-08-27 22:29:59 -04:00
Kit Langton 8ab08f7fc9 refactor(core): remove infallible layer conversion (#45672) 2026-08-27 22:29:44 -04:00
Luke Parker c5113ee74a chore(desktop): trim external dependency archives (#45804) 2026-08-28 02:24:46 +00:00
Kit Langton 88988b8c12 fix(core): preserve typed config test-layer errors (#45778) 2026-08-27 22:13:34 -04:00
opencode-agent[bot] 2389111bf6 chore: update nix node_modules hashes 2026-08-28 02:08:36 +00:00
Luke Parker b92b84f33d chore(deps): refresh markdown dependencies (#45793) 2026-08-28 01:45:46 +00:00
opencode-agent[bot] 078522e534 chore: update nix node_modules hashes 2026-08-28 01:28:15 +00:00
Aiden Cline 7111e71528 fix(core): treat project paths as internal (#45799) 2026-08-27 20:26:25 -05:00
Luke Parker 0ce3214844 chore(deps): upgrade Sentry and verify reporting (#45796) 2026-08-28 01:13:17 +00:00
Luke Parker 132ec56fda chore(ui): refresh Kobalte and Solid primitives (#45794) 2026-08-28 01:08:44 +00:00
opencode-agent[bot] 216af25ecc chore: update nix node_modules hashes 2026-08-28 01:00:46 +00:00
Luke Parker 6881cffc41 chore(deps): refresh Vite and Electron Builder tooling (#45790) 2026-08-28 00:46:32 +00:00
opencode-agent[bot] aecea54e95 chore: update nix node_modules hashes 2026-08-28 00:37:45 +00:00
Luke Parker c0fa9aff72 fix(app): render local Markdown images through the server (#45780) 2026-08-28 00:30:00 +00:00
opencode-agent[bot]andrekram1-node d5be4cf888 fix(core): share native model defaults with v1 config migration (#45760)
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2026-08-27 19:25:56 -05:00
Aiden Cline 7b6b0f2717 fix(core): cap provider retry-after delays at fifteen minutes (#45787) 2026-08-27 19:23:06 -05:00
James Long a03a1dd854 chore: upgrade opentui to 0.5.9 (#45785) 2026-08-27 20:20:59 -04:00
Luke Parker 8f350df392 chore(deps): remove unused frontend dependencies (#45779) 2026-08-28 00:19:57 +00:00
Luke Parker 4cecfe8e79 chore(desktop): remove stale native packaging (#45786) 2026-08-28 10:18:53 +10:00
Kit Langton 89ca9fc641 fix(core): complete supervisor flush when plugin activation fails (#45783) 2026-08-28 00:16:25 +00:00
Kit Langton a2dcf2dff0 fix(core): wake sessions for recovered shell outcomes (#45781) 2026-08-27 20:11:56 -04:00
Luke Parker 7477e21b02 fix(session-ui): upgrade DOMPurify and restore Mermaid rendering (#45773) 2026-08-28 00:00:21 +00:00
Kit Langton 59e7b32c92 refactor(core): extract instance module from location services (#45705) 2026-08-27 23:57:52 +00:00
Luke Parker 4681c734b2 fix(desktop): bundle selected main-process dependencies (#45774) 2026-08-27 23:53:51 +00:00
Luke Parker 84fa689686 fix(desktop): preserve lazy Sentry loading (#45776) 2026-08-27 23:41:09 +00:00
Kit Langton 62d9aa9838 refactor(core): delegate credential activation (#45692) 2026-08-27 19:39:25 -04:00
Kit Langton 02550b36c7 refactor(util): narrow layer visitor options (#45734) 2026-08-27 19:39:18 -04:00
Kit Langton 35231b408e refactor(core): use keyed MCP credential reads (#45708) 2026-08-27 19:39:12 -04:00
Kit Langton 74f5317489 docs(tui): complete session tabs story guidance (#45712) 2026-08-27 19:39:05 -04:00
Kit Langton 81e099a441 fix(core): preserve benchmark directory argument (#45634) 2026-08-27 19:38:58 -04:00
Luke Parker 498436f225 chore(desktop): upgrade Electron to 42.10.1 (#45775) 2026-08-27 23:36:39 +00:00
opencode-agent[bot]andneriousy b20748f821 fix(cli): prevent repeated updates and npm cache growth (#45091)
Co-authored-by: neriousy <34747899+neriousy@users.noreply.github.com>
2026-08-28 01:30:34 +02:00
Kit Langton f0d5b5e073 refactor(core): remove unused response stream state (#45742) 2026-08-27 19:27:21 -04:00
Kit Langton 4f6c4ddf1c test(core): guarantee execution scope cleanup (#45740) 2026-08-27 19:27:05 -04:00
Kit Langton 792f84b59d refactor(core): reuse execute visibility predicate (#45699) 2026-08-27 19:26:55 -04:00
Kit Langton b4bd14864a refactor(core): remove unused responses state (#45659) 2026-08-27 19:26:48 -04:00
Kit Langton 6bbf2e34e7 refactor(core): parse MCP prompt arguments once (#45646) 2026-08-27 19:26:40 -04:00
Luke Parker d432823515 fix(deps): upgrade diff to 8.0.4 (#45772) 2026-08-27 23:24:33 +00:00
opencode-agent[bot]andneriousy bdf2e84812 feat(core): support Azure CLI authentication (#45086)
Co-authored-by: neriousy <34747899+neriousy@users.noreply.github.com>
2026-08-27 23:22:22 +00:00
Kit Langton a438d34fcd chore: lint identity pipe callbacks (#45771) 2026-08-27 19:11:03 -04:00
Kit Langton fa7a1ca39e chore: remove duplicate oxlint options (#45719) 2026-08-27 19:10:55 -04:00
Kit Langton 38e4c97aa9 refactor(core): import filesystem search schemas directly (#45684) 2026-08-27 19:10:50 -04:00
Kit Langton 569a39c4ba refactor(core): preserve Copilot handler inference (#45668) 2026-08-27 19:10:44 -04:00
Kit Langton bfb00cbb75 refactor(core): remove unused chat state (#45664) 2026-08-27 19:10:36 -04:00
Kit Langton a067c45cb6 test(schema): target config color contract (#45626) 2026-08-27 19:10:29 -04:00
Kit Langton 0a7baaaed5 refactor(core): reuse shared record guard (#45643) 2026-08-27 19:06:52 -04:00
Luke Parker 454ed81556 fix(desktop): minify production bundles (#45770) 2026-08-27 23:06:28 +00:00
Kit Langton 7fe8bf5b24 refactor(core): remove duplicate config failure (#45625) 2026-08-27 18:52:36 -04:00
Kit Langton 991a0d05b5 refactor(core): remove identity schema pipes (#45624) 2026-08-27 18:52:20 -04:00
opencode-agent[bot]andneriousy 3288a12528 fix(ai): safely replay unsigned Bedrock reasoning (#45765)
Co-authored-by: neriousy <34747899+neriousy@users.noreply.github.com>
2026-08-28 00:51:07 +02:00
Kit Langton 091396742e refactor(core): isolate tool test fixture state (#45452) 2026-08-27 18:48:44 -04:00
Kit Langton ba85531931 refactor(core): remove redundant coordinator test scopes (#45451) 2026-08-27 18:48:20 -04:00
Kit Langton 0440bd96be refactor(core): simplify Effect test setup (#45446) 2026-08-27 18:47:56 -04:00
Kit Langton ace4249711 refactor(core): name session runner test services (#45445) 2026-08-27 18:47:44 -04:00
Kit Langton 7d007670a0 refactor(core): simplify duplicate test layers (#45444) 2026-08-27 18:47:20 -04:00
Kit Langton 98e3fa9d0d test(core): budget real startup in session revert test (#45762) 2026-08-27 18:43:54 -04:00
opencode-agent[bot]andjlongster 5d4ce14dc4 fix(core): skip tracing streamed LLM deltas (#45761)
Co-authored-by: jlongster <17031+jlongster@users.noreply.github.com>
2026-08-27 17:59:43 -04:00
Simon Klee 8381153418 fix(tui): respect selection copy modes (#45758) 2026-08-27 23:53:07 +02:00
Kit Langton 96d84626f8 fix(core): keep project labels stable across clones (#45735)
Keep shared project labels stable across clones while explicitly using each selected checkout for worktree creation and setup. Preserve real directory rename handling and cover the TUI Home and app workspace creation paths.
2026-08-27 17:35:18 -04:00
Kit Langton f607ca4c72 fix(tui): unify attention indicators with unread accent (#45741)
Use the unread accent color for question and permission status indicators by default, preserving semantic tokens and explicit theme overrides.
2026-08-27 17:15:44 -04:00
Kit Langton 39416a0d95 test(ai): align test runners with API boundaries (#45469) 2026-08-27 17:07:02 -04:00
opencode-agent[bot]andiamdavidhill 84a012a0e9 fix(app): show grouped tool counts inline (#45603)
Co-authored-by: iamdavidhill <1879069+iamdavidhill@users.noreply.github.com>
2026-08-27 21:01:38 +00:00
opencode-agent[bot]andiamdavidhill 16a0996bd4 fix(ui): replace error icon with updated svg (#45604)
Co-authored-by: iamdavidhill <1879069+iamdavidhill@users.noreply.github.com>
2026-08-28 06:50:56 +10:00
209 changed files with 10847 additions and 7947 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@opencode-ai/theme": patch
---
Use the unread accent color by default for question and permission status indicators, while preserving explicit theme overrides.
+5
View File
@@ -0,0 +1,5 @@
---
"@opencode-ai/core": patch
---
Align the built-in review and system prompts with available documentation and code-search tools and the current Plan mode reminder.
+5
View File
@@ -0,0 +1,5 @@
---
"@opencode-ai/core": patch
---
Correct the published Newtype documentation to recommend `Schema.decodeUnknownSync` for schema-based synchronous decoding.
+5
View File
@@ -0,0 +1,5 @@
---
"@opencode-ai/core": patch
---
Wake idle sessions when recovering background shell outcomes after a server restart. Admit shell outcomes before resuming background child sessions, while preserving restart retry budgets and completion-driven parent notifications.
+8
View File
@@ -0,0 +1,8 @@
---
"@opencode-ai/core": patch
"@opencode-ai/app": patch
---
Keep project labels stable when opening multiple clones of the same repository, while still refreshing the canonical path when its directory is renamed or removed.
Worktree setup scripts receive the selected source directory as `OPENCODE_WORKTREE_BASE` rather than another clone's shared project path.
+5
View File
@@ -0,0 +1,5 @@
---
"@opencode-ai/core": patch
---
Refresh expired OAuth credentials resolved during batched plugin activation. Integration connection resolution now materializes registrations deferred by the activation batch before consulting refresh implementations, so a just-registered OAuth method is no longer skipped and an expired token no longer used as-is.
-6
View File
@@ -41,11 +41,5 @@
// Warn when spreading non-plain objects (Headers, class instances, etc.)
"typescript/no-misused-spread": "warn"
},
"options": {
"typeAware": true
},
"options": {
"typeAware": true
},
"ignorePatterns": ["**/node_modules", "**/dist", "**/.build", "**/.sst", "**/*.d.ts", "**/sdk.gen.ts"]
}
+137 -323
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -2,7 +2,7 @@
exact = true
# Only install newly resolved package versions published at least 3 days ago.
minimumReleaseAge = 259200
minimumReleaseAgeExcludes = ["@ai-sdk/amazon-bedrock", "@ai-sdk/anthropic", "@brendonovich/vite-plugin-opencode", "@opencode-ai/sdk", "@opencode-ai/pty", "@opencode-ai/pty-darwin-arm64", "@opencode-ai/pty-darwin-x64", "@opencode-ai/pty-linux-arm64-gnu", "@opencode-ai/pty-linux-arm64-musl", "@opencode-ai/pty-linux-x64-gnu", "@opencode-ai/pty-linux-x64-musl", "@opentui/core", "@opentui/core-darwin-arm64", "@opentui/core-darwin-x64", "@opentui/core-linux-arm64", "@opentui/core-linux-arm64-musl", "@opentui/core-linux-x64", "@opentui/core-linux-x64-musl", "@opentui/core-win32-arm64", "@opentui/core-win32-x64", "@opentui/keymap", "@opentui/solid", "opentui-spinner", "gitlab-ai-provider", "opencode-gitlab-auth", "@ff-labs/fff-node", "@ff-labs/fff-bun", "@ff-labs/fff-bin-darwin-arm64", "@ff-labs/fff-bin-darwin-x64", "@ff-labs/fff-bin-linux-arm64-gnu", "@ff-labs/fff-bin-linux-arm64-musl", "@ff-labs/fff-bin-linux-x64-gnu", "@ff-labs/fff-bin-linux-x64-musl", "@ff-labs/fff-bin-win32-arm64", "@ff-labs/fff-bin-win32-x64", "@pierre/diffs", "@pierre/theming", "app-builder-lib", "dmg-builder", "electron-builder", "electron-publish", "blume"]
minimumReleaseAgeExcludes = ["@ai-sdk/amazon-bedrock", "@ai-sdk/anthropic", "@brendonovich/vite-plugin-opencode", "@opencode-ai/sdk", "@opencode-ai/pty", "@opencode-ai/pty-darwin-arm64", "@opencode-ai/pty-darwin-x64", "@opencode-ai/pty-linux-arm64-gnu", "@opencode-ai/pty-linux-arm64-musl", "@opencode-ai/pty-linux-x64-gnu", "@opencode-ai/pty-linux-x64-musl", "@opentui/core", "@opentui/core-darwin-arm64", "@opentui/core-darwin-x64", "@opentui/core-linux-arm64", "@opentui/core-linux-arm64-musl", "@opentui/core-linux-x64", "@opentui/core-linux-x64-musl", "@opentui/core-win32-arm64", "@opentui/core-win32-x64", "@opentui/keymap", "@opentui/solid", "opentui-spinner", "gitlab-ai-provider", "opencode-gitlab-auth", "@ff-labs/fff-node", "@ff-labs/fff-bun", "@ff-labs/fff-bin-darwin-arm64", "@ff-labs/fff-bin-darwin-x64", "@ff-labs/fff-bin-linux-arm64-gnu", "@ff-labs/fff-bin-linux-arm64-musl", "@ff-labs/fff-bin-linux-x64-gnu", "@ff-labs/fff-bin-linux-x64-musl", "@ff-labs/fff-bin-win32-arm64", "@ff-labs/fff-bin-win32-x64", "@pierre/diffs", "@pierre/theming", "app-builder-lib", "dmg-builder", "electron", "electron-builder", "electron-publish", "blume", "mermaid"]
[test]
root = "./do-not-run-tests-from-root"
+2 -1
View File
@@ -63,7 +63,8 @@ new sst.cloudflare.StaticSite("WebApp", {
domain: "app." + domain,
path: "packages/app",
build: {
command: "bun turbo build",
// Preserve Sentry credentials and run source-map uploads on every deployment.
command: "bun run build",
output: "./dist",
},
})
+7 -9
View File
@@ -4,7 +4,7 @@
bun,
nodejs,
darwin,
electron_41,
callPackage,
makeWrapper,
writableTmpDirAsHomeHook,
autoPatchelfHook,
@@ -13,7 +13,7 @@
opencode,
}:
let
electron = electron_41;
electron = callPackage ./electron.nix { };
in
stdenv.mkDerivation (finalAttrs: {
pname = "opencode-desktop";
@@ -48,7 +48,7 @@ stdenv.mkDerivation (finalAttrs: {
desktopName = "OpenCode";
exec = "opencode-desktop %U";
icon = "ai.opencode.desktop";
# Electron 41 derives X11 WM_CLASS from app.name.
# Electron derives X11 WM_CLASS from app.name.
startupWMClass = "OpenCode";
categories = [ "Development" ];
});
@@ -67,12 +67,10 @@ stdenv.mkDerivation (finalAttrs: {
# https://github.com/electron/electron/issues/31121
# mac builds use a .app bundle which doesnt have this issue
+ lib.optionalString stdenv.isLinux ''
BASE_PATH=packages/desktop
FILES=(src/main/windows.ts)
for file in "''${FILES[@]}"; do
substituteInPlace $BASE_PATH/$file \
--replace-fail "process.resourcesPath" "'$out/opt/opencode-desktop/resources'"
done
substituteInPlace \
packages/desktop/src/main/windows/appearance.ts \
packages/desktop/src/main/service/desktop-cli.ts \
--replace-fail "process.resourcesPath" "'$out/opt/opencode-desktop/resources'"
'';
preBuild = ''
+13
View File
@@ -0,0 +1,13 @@
{ callPackage, path }:
let
version = (builtins.fromJSON (builtins.readFile ../packages/desktop/package.json)).devDependencies.electron;
in
(callPackage (path + "/pkgs/development/tools/electron/binary/generic.nix") { }) version {
# Electron 42.10.1 SHASUMS256.txt; update with the desktop package version.
aarch64-linux = "20e68d6c4e47f3ebf59de7c6b1f8b8bec6a6ebda6a451132f9b465f3f13ce467";
x86_64-linux = "2452b27112d92387471fa2488aafac85d79ea3f2ee1216c0abd5150d6c12362b";
aarch64-darwin = "ac7194a3dfd81930ba35355c01620262c1254752859b42dcb8f4b9e4d174a871";
x86_64-darwin = "4489aba55477a0082266cb690db1c829503ba3338048599d8fd243953df37dab";
# fetchzip hashes the unpacked headers, not the release tarball.
headers = "sha256-4eUy3BZVvxTl7KUOsxio7769lL6ag/ecbeK+qLURWMI=";
}
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-agxF1Ab8P84tijHvw7jaG+9UZm/dJZhHw8CGNlQQqYc=",
"aarch64-linux": "sha256-Kg6CkhoEEsb+ZOGPN/rnNoirLdGSyQl6HU/Fy8t9Mqs=",
"aarch64-darwin": "sha256-SnxBpmcfPL2p3OYmSuG5Y/aFW8PeEOLhfQrxAs/53fs=",
"x86_64-darwin": "sha256-Dnxl1EcfiE7/rlHmPkko0BKc6hHVk6rQ/2h/PWuLfNo="
"x86_64-linux": "sha256-vn4haT0uuD5tCbMD2F5IuDj+/XS/U/sVQ0bk5xJCbIM=",
"aarch64-linux": "sha256-XUGJ6hE7xvIyT53k5uCqaF/eGU4d4tIbuVuGrHU12Fg=",
"aarch64-darwin": "sha256-o3Os6rO8nGVVYs1aJBJAdYQaLbDsYNtGub4Gg1gHRQU=",
"x86_64-darwin": "sha256-YB00nH7WmgqRXvzA+zYEbaaik/vKheu3n/vJ6Ombx9k="
}
}
+17 -14
View File
@@ -49,14 +49,14 @@
"@octokit/rest": "22.0.0",
"@hono/standard-validator": "0.2.0",
"@hono/zod-validator": "0.4.2",
"@opentui/core": "0.5.8",
"@opentui/keymap": "0.5.8",
"@opentui/solid": "0.5.8",
"@opentui/core": "0.5.9",
"@opentui/keymap": "0.5.9",
"@opentui/solid": "0.5.9",
"@tanstack/solid-virtual": "3.13.37",
"@shikijs/stream": "4.2.0",
"@shikijs/stream": "4.4.3",
"@standard-schema/spec": "1.1.0",
"ulid": "3.0.1",
"@kobalte/core": "0.13.11",
"@kobalte/core": "0.13.13",
"@corvu/drawer": "0.2.4",
"@types/luxon": "3.7.1",
"@types/node": "24.12.2",
@@ -67,10 +67,13 @@
"@openauthjs/openauth": "0.0.0-20250322224806",
"@pierre/diffs": "1.2.10",
"opentui-spinner": "0.0.7",
"@solid-primitives/storage": "4.3.3",
"@solid-primitives/event-listener": "2.4.6",
"@solid-primitives/media": "2.3.6",
"@solid-primitives/resize-observer": "2.2.0",
"@solid-primitives/storage": "4.4.0",
"@tailwindcss/vite": "4.1.11",
"diff": "8.0.2",
"dompurify": "3.3.1",
"diff": "8.0.4",
"dompurify": "3.4.14",
"drizzle-kit": "1.0.0-rc.5-ab785fc",
"drizzle-orm": "1.0.0-rc.5-169397b",
"effect": "4.0.0-rc.111",
@@ -81,9 +84,9 @@
"fuzzysort": "3.1.0",
"get-east-asian-width": "1.6.0",
"luxon": "3.6.1",
"marked": "18.0.7",
"marked": "18.0.11",
"marked-shiki": "1.2.1",
"remend": "1.3.0",
"remend": "1.3.1",
"@playwright/test": "1.59.1",
"semver": "7.7.4",
"typescript": "5.8.2",
@@ -92,16 +95,16 @@
"remeda": "2.26.0",
"resolve.exports": "2.0.3",
"sst": "4.13.1",
"shiki": "4.2.0",
"shiki": "4.4.3",
"solid-list": "0.3.0",
"string-width": "7.2.0",
"tailwindcss": "4.1.11",
"vite": "7.1.4",
"vite": "7.3.6",
"@solidjs/meta": "0.29.4",
"@solidjs/router": "0.15.4",
"@solidjs/start": "https://pkg.pr.new/@solidjs/start@dfb2020",
"@sentry/solid": "10.36.0",
"@sentry/vite-plugin": "4.6.0",
"@sentry/solid": "10.71.0",
"@sentry/vite-plugin": "5.4.0",
"solid-js": "1.9.10",
"solid-sonner": "0.3.1",
"vite-plugin-solid": "2.11.10",
@@ -374,6 +374,12 @@ const lowerMessages = Effect.fn("BedrockConverse.lowerMessages")(function* (
content.push({ reasoningContent: { redactedContent: redactedData } })
continue
}
if (signature === undefined || signature.trim().length === 0) {
// Interrupted streams and model switches can leave unsigned reasoning.
// Preserve readable history as text rather than replay invalid reasoningContent.
if (part.text.trim().length > 0) content.push(...textWithCache(breakpoints, part.text, part.cache))
continue
}
content.push({ reasoningContent: { reasoningText: { text: part.text, signature } } })
continue
}
@@ -382,7 +388,7 @@ const lowerMessages = Effect.fn("BedrockConverse.lowerMessages")(function* (
continue
}
}
messages.push({ role: "assistant", content })
if (content.length > 0) messages.push({ role: "assistant", content })
continue
}
+7 -9
View File
@@ -1,4 +1,4 @@
import { describe, expect } from "bun:test"
import { describe, expect, test } from "bun:test"
import { Effect, Schema, Stream } from "effect"
import { LLM, LLMRequest, LLMResponse } from "../src/index.js"
import { Route, Endpoint, LLMClient, Protocol, type FramingDef } from "../src/route.js"
@@ -148,15 +148,13 @@ describe("llm route", () => {
}),
)
it.effect("builds models from configured routes", () =>
Effect.gen(function* () {
const configured = fake.with({ provider: "fake-provider", endpoint: { baseURL: "https://fake.local" } })
test("builds models from configured routes", () => {
const configured = fake.with({ provider: "fake-provider", endpoint: { baseURL: "https://fake.local" } })
expect(configured.model({ id: "fake-model" })).toMatchObject({
provider: "fake-provider",
})
}),
)
expect(configured.model({ id: "fake-model" })).toMatchObject({
provider: "fake-provider",
})
})
it.effect("does not register duplicate route ids globally", () =>
Effect.gen(function* () {
+17 -15
View File
@@ -44,21 +44,23 @@ describe("Tool.make (dynamic JSON Schema)", () => {
expect(definition?.inputSchema).toEqual(jsonSchema)
})
test("execute receives the raw input untouched", async () => {
const seen: unknown[] = []
const tool = Tool.make({
description: "echo",
jsonSchema: { type: "object" },
execute: (params) =>
Effect.sync(() => {
seen.push(params)
return { ok: true }
}),
})
const result = await Effect.runPromise(tool.execute({ hello: "world" }))
expect(seen).toEqual([{ hello: "world" }])
expect(result).toEqual({ ok: true })
})
it.effect("execute receives the raw input untouched", () =>
Effect.gen(function* () {
const seen: unknown[] = []
const tool = Tool.make({
description: "echo",
jsonSchema: { type: "object" },
execute: (params) =>
Effect.sync(() => {
seen.push(params)
return { ok: true }
}),
})
const result = yield* tool.execute({ hello: "world" })
expect(seen).toEqual([{ hello: "world" }])
expect(result).toEqual({ ok: true })
}),
)
})
describe("LLM.generateObject", () => {
@@ -531,6 +531,104 @@ describe("Bedrock Converse route", () => {
}),
)
for (const signature of [undefined, "", " "]) {
for (const cache of ["none", "auto"] as const) {
it.effect(`demotes unsigned reasoning to text (${JSON.stringify(signature)}, cache: ${cache})`, () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model,
messages: [
Message.user("Think"),
Message.assistant([
{
type: "reasoning",
text: "Partial thought",
providerMetadata: signature === undefined ? undefined : { bedrock: { signature } },
cache: new CacheHint({ type: "ephemeral" }),
},
]),
Message.user("Continue"),
],
cache,
}),
)
expect(prepared.body.messages[1]).toEqual({
role: "assistant",
content: [{ text: "Partial thought" }, { cachePoint: { type: "default" } }],
})
}),
)
}
}
it.effect("omits empty unsigned reasoning without leaving an empty or cache-only assistant", () =>
Effect.gen(function* () {
const cache = new CacheHint({ type: "ephemeral" })
const prepared = yield* compileRequest(
LLM.request({
model,
messages: [
Message.user("Think"),
Message.assistant([
{ type: "reasoning", text: "", cache },
{ type: "reasoning", text: " ", providerMetadata: { bedrock: { signature: "" } }, cache },
]),
Message.user([{ type: "text", text: "Continue", cache }]),
],
cache: "none",
}),
)
expect(prepared.body.messages).toEqual([
{
role: "user",
content: [{ text: "Think" }, { text: "Continue" }, { cachePoint: { type: "default" } }],
},
])
}),
)
it.effect("demotes foreign reasoning while preserving signed, redacted, text, and tool blocks", () =>
Effect.gen(function* () {
const cache = new CacheHint({ type: "ephemeral" })
const prepared = yield* compileRequest(
LLM.request({
model,
messages: [
Message.assistant([
{ type: "reasoning", text: "Foreign thought", providerMetadata: { anthropic: { signature: "old" } } },
{
type: "reasoning",
text: "Signed thought",
providerMetadata: { bedrock: { signature: "sig_1" } },
cache,
},
{ type: "reasoning", text: "", encrypted: "sig_2", cache },
{ type: "reasoning", text: "", providerMetadata: { bedrock: { redactedData: "cmVkYWN0ZWQ=" } }, cache },
{ type: "text", text: "Checking" },
ToolCallPart.make({ id: "call_1", name: "lookup", input: {} }),
]),
],
tools: [{ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }],
cache: "none",
}),
)
expect(prepared.body.messages).toEqual([
{
role: "assistant",
content: [
{ text: "Foreign thought" },
{ reasoningContent: { reasoningText: { text: "Signed thought", signature: "sig_1" } } },
{ reasoningContent: { reasoningText: { text: "", signature: "sig_2" } } },
{ reasoningContent: { redactedContent: "cmVkYWN0ZWQ=" } },
{ text: "Checking" },
{ toolUse: { toolUseId: "call_1", name: "lookup", input: {} } },
],
},
])
}),
)
it.effect("preserves streamed reasoning signatures for continuation lowering", () =>
Effect.gen(function* () {
const body = eventStreamBody(
+10 -12
View File
@@ -1,4 +1,4 @@
import { describe, expect } from "bun:test"
import { describe, expect, test } from "bun:test"
import { ConfigProvider, Effect, Schema } from "effect"
import { HttpClientRequest } from "effect/unstable/http"
import { LLM, LLMEvent } from "../../src/index.js"
@@ -136,17 +136,15 @@ describe("Cloudflare", () => {
}),
)
it.effect("defaults AI Gateway id to default when omitted or blank", () =>
Effect.gen(function* () {
expect(
CloudflareAIGateway.configure({
accountId: "test-account",
gatewayId: "",
gatewayApiKey: "test-token",
}).model("workers-ai/@cf/meta/llama-3.3-70b-instruct").route.endpoint.baseURL,
).toBe("https://gateway.ai.cloudflare.com/v1/test-account/default/compat")
}),
)
test("defaults AI Gateway id to default when omitted or blank", () => {
expect(
CloudflareAIGateway.configure({
accountId: "test-account",
gatewayId: "",
gatewayApiKey: "test-token",
}).model("workers-ai/@cf/meta/llama-3.3-70b-instruct").route.endpoint.baseURL,
).toBe("https://gateway.ai.cloudflare.com/v1/test-account/default/compat")
})
it.effect("supports authenticated AI Gateway plus upstream provider auth", () =>
Effect.gen(function* () {
@@ -1,4 +1,4 @@
import { describe, expect } from "bun:test"
import { describe, expect, test } from "bun:test"
import { Effect } from "effect"
import { HttpClientRequest } from "effect/unstable/http"
import { LLM, Message, ToolCallPart } from "../../src/index.js"
@@ -376,11 +376,9 @@ describe("Google Vertex providers", () => {
}),
)
it.effect("rejects tuned Gemini models in express mode", () =>
Effect.sync(() => {
expect(() => GoogleVertex.configure({ apiKey: "fixture" }).model("endpoints/1234567890")).toThrow(
"Google Vertex tuned models do not support Express Mode API keys",
)
}),
)
test("rejects tuned Gemini models in express mode", () => {
expect(() => GoogleVertex.configure({ apiKey: "fixture" }).model("endpoints/1234567890")).toThrow(
"Google Vertex tuned models do not support Express Mode API keys",
)
})
})
@@ -1,4 +1,4 @@
import { describe, expect } from "bun:test"
import { describe, expect, test } from "bun:test"
import { ConfigProvider, Effect } from "effect"
import { HttpClientRequest } from "effect/unstable/http"
import { LLM, Message, ToolDefinition } from "../../src/index.js"
@@ -31,82 +31,71 @@ import { dynamicResponse } from "../lib/http.js"
import { sseEvents } from "../lib/sse.js"
describe("native OpenAI-compatible providers", () => {
it.effect("assigns provider-owned metadata namespaces across native routes", () =>
Effect.gen(function* () {
const vertex = { project: "project", accessToken: "token" }
const providers = [
[OpenAI.configure({ apiKey: "test" }).chat("model"), "openai"],
[OpenAI.configure({ apiKey: "test" }).responses("model"), "openai"],
[Azure.configure({ resourceName: "resource", apiKey: "test" }).chat("model"), "azure"],
[Azure.configure({ resourceName: "resource", apiKey: "test" }).responses("model"), "azure"],
[AmazonBedrock.configure({ apiKey: "test" }).model("model"), "bedrock"],
[AmazonBedrockMantle.configure({ apiKey: "test" }).chat("model"), "mantle"],
[AmazonBedrockMantle.configure({ apiKey: "test" }).responses("model"), "mantle"],
[Google.configure({ apiKey: "test" }).model("model"), "google"],
[GoogleVertex.configure(vertex).model("model"), "vertex"],
[GoogleVertexChat.configure(vertex).model("model"), "vertex"],
[GoogleVertexResponses.configure(vertex).model("model"), "vertex"],
[GoogleVertexMessages.configure(vertex).model("model"), "anthropic"],
[Anthropic.configure({ apiKey: "test" }).model("model"), "anthropic"],
[
AnthropicCompatible.configure({ baseURL: "https://example.test/v1", provider: "minimax" }).model("model"),
"minimax",
],
[
OpenAICompatible.configure({ baseURL: "https://example.test/v1", provider: "custom" }).model("model"),
"custom",
],
[
OpenAICompatibleResponses.configure({ baseURL: "https://example.test/v1", provider: "custom" }).model(
"model",
),
"custom",
],
[Cerebras.configure({ apiKey: "test" }).model("model"), "cerebras"],
[DeepInfra.configure({ apiKey: "test" }).model("model"), "deepinfra"],
[TogetherAI.configure({ apiKey: "test" }).model("model"), "togetherai"],
[CloudflareAIGateway.configure({ accountId: "account" }).model("model"), "cloudflare-ai-gateway"],
[CloudflareWorkersAI.configure({ accountId: "account" }).model("model"), "cloudflare-workers-ai"],
[OpenRouter.configure({ apiKey: "test" }).model("model"), "openrouter"],
[XAI.configure({ apiKey: "test" }).chat("model"), "xai"],
[XAI.configure({ apiKey: "test" }).responses("model"), "xai"],
] as const
test("assigns provider-owned metadata namespaces across native routes", () => {
const vertex = { project: "project", accessToken: "token" }
const providers = [
[OpenAI.configure({ apiKey: "test" }).chat("model"), "openai"],
[OpenAI.configure({ apiKey: "test" }).responses("model"), "openai"],
[Azure.configure({ resourceName: "resource", apiKey: "test" }).chat("model"), "azure"],
[Azure.configure({ resourceName: "resource", apiKey: "test" }).responses("model"), "azure"],
[AmazonBedrock.configure({ apiKey: "test" }).model("model"), "bedrock"],
[AmazonBedrockMantle.configure({ apiKey: "test" }).chat("model"), "mantle"],
[AmazonBedrockMantle.configure({ apiKey: "test" }).responses("model"), "mantle"],
[Google.configure({ apiKey: "test" }).model("model"), "google"],
[GoogleVertex.configure(vertex).model("model"), "vertex"],
[GoogleVertexChat.configure(vertex).model("model"), "vertex"],
[GoogleVertexResponses.configure(vertex).model("model"), "vertex"],
[GoogleVertexMessages.configure(vertex).model("model"), "anthropic"],
[Anthropic.configure({ apiKey: "test" }).model("model"), "anthropic"],
[
AnthropicCompatible.configure({ baseURL: "https://example.test/v1", provider: "minimax" }).model("model"),
"minimax",
],
[OpenAICompatible.configure({ baseURL: "https://example.test/v1", provider: "custom" }).model("model"), "custom"],
[
OpenAICompatibleResponses.configure({ baseURL: "https://example.test/v1", provider: "custom" }).model("model"),
"custom",
],
[Cerebras.configure({ apiKey: "test" }).model("model"), "cerebras"],
[DeepInfra.configure({ apiKey: "test" }).model("model"), "deepinfra"],
[TogetherAI.configure({ apiKey: "test" }).model("model"), "togetherai"],
[CloudflareAIGateway.configure({ accountId: "account" }).model("model"), "cloudflare-ai-gateway"],
[CloudflareWorkersAI.configure({ accountId: "account" }).model("model"), "cloudflare-workers-ai"],
[OpenRouter.configure({ apiKey: "test" }).model("model"), "openrouter"],
[XAI.configure({ apiKey: "test" }).chat("model"), "xai"],
[XAI.configure({ apiKey: "test" }).responses("model"), "xai"],
] as const
for (const [model, key] of providers) expect(model.route.providerMetadataKey).toBe(key)
}),
)
for (const [model, key] of providers) expect(model.route.providerMetadataKey).toBe(key)
})
it.effect("preserves native Together AI and Cerebras provider and route identities", () =>
Effect.gen(function* () {
const together = TogetherAI.configure({ apiKey: "fixture" }).model("meta-llama/Llama-3.3-70B")
const cerebras = Cerebras.configure({ apiKey: "fixture" }).model("qwen-3-235b-a22b")
test("preserves native Together AI and Cerebras provider and route identities", () => {
const together = TogetherAI.configure({ apiKey: "fixture" }).model("meta-llama/Llama-3.3-70B")
const cerebras = Cerebras.configure({ apiKey: "fixture" }).model("qwen-3-235b-a22b")
expect(together).toMatchObject({
provider: "togetherai",
compatibility: { maxTokensField: "max_tokens", supportsStore: false, supportsStrictMode: false },
route: { id: "togetherai-chat", protocol: "openai-chat" },
})
expect(together.route.endpoint.baseURL).toBe("https://api.together.xyz/v1")
expect(cerebras).toMatchObject({
provider: "cerebras",
compatibility: { maxTokensField: "max_tokens", reasoningField: "reasoning", supportsStore: false },
route: { id: "cerebras-chat", protocol: "openai-chat" },
})
expect(cerebras.route.endpoint.baseURL).toBe("https://api.cerebras.ai/v1")
}),
)
expect(together).toMatchObject({
provider: "togetherai",
compatibility: { maxTokensField: "max_tokens", supportsStore: false, supportsStrictMode: false },
route: { id: "togetherai-chat", protocol: "openai-chat" },
})
expect(together.route.endpoint.baseURL).toBe("https://api.together.xyz/v1")
expect(cerebras).toMatchObject({
provider: "cerebras",
compatibility: { maxTokensField: "max_tokens", reasoningField: "reasoning", supportsStore: false },
route: { id: "cerebras-chat", protocol: "openai-chat" },
})
expect(cerebras.route.endpoint.baseURL).toBe("https://api.cerebras.ai/v1")
})
it.effect("preserves native DeepInfra provider and route identity", () =>
Effect.gen(function* () {
const deepinfra = DeepInfra.configure({ apiKey: "fixture" }).model("google/gemma-3-27b-it")
expect(deepinfra).toMatchObject({
provider: "deepinfra",
compatibility: { maxTokensField: "max_tokens", reasoningField: "reasoning_content", supportsStore: false },
route: { id: "deepinfra-chat", protocol: "openai-chat" },
})
expect(deepinfra.route.endpoint.baseURL).toBe("https://api.deepinfra.com/v1/openai")
}),
)
test("preserves native DeepInfra provider and route identity", () => {
const deepinfra = DeepInfra.configure({ apiKey: "fixture" }).model("google/gemma-3-27b-it")
expect(deepinfra).toMatchObject({
provider: "deepinfra",
compatibility: { maxTokensField: "max_tokens", reasoningField: "reasoning_content", supportsStore: false },
route: { id: "deepinfra-chat", protocol: "openai-chat" },
})
expect(deepinfra.route.endpoint.baseURL).toBe("https://api.deepinfra.com/v1/openai")
})
it.effect("applies native provider request defaults even with a custom gateway URL", () =>
Effect.gen(function* () {
@@ -161,39 +150,35 @@ describe("native OpenAI-compatible providers", () => {
}),
)
it.effect("normalizes DeepInfra API roots without duplicating the OpenAI path", () =>
Effect.gen(function* () {
for (const baseURL of [
"https://gateway.example/v1",
"https://gateway.example/v1/",
test("normalizes DeepInfra API roots without duplicating the OpenAI path", () => {
for (const baseURL of [
"https://gateway.example/v1",
"https://gateway.example/v1/",
"https://gateway.example/v1/openai",
"https://gateway.example/v1/openai/",
]) {
expect(DeepInfra.configure({ apiKey: "fixture", baseURL }).model("gemma").route.endpoint.baseURL).toBe(
"https://gateway.example/v1/openai",
"https://gateway.example/v1/openai/",
]) {
expect(DeepInfra.configure({ apiKey: "fixture", baseURL }).model("gemma").route.endpoint.baseURL).toBe(
"https://gateway.example/v1/openai",
)
}
}),
)
)
}
})
it.effect("maps package settings onto native executable models", () =>
Effect.gen(function* () {
for (const native of [TogetherAI, Cerebras]) {
const selected = native.model("provider-model", {
apiKey: "fixture",
baseURL: "https://gateway.example/v1",
headers: { "x-application": "opencode" },
body: { service_tier: "priority" },
providerOptions: { reasoningEffort: "high" },
})
test("maps package settings onto native executable models", () => {
for (const native of [TogetherAI, Cerebras]) {
const selected = native.model("provider-model", {
apiKey: "fixture",
baseURL: "https://gateway.example/v1",
headers: { "x-application": "opencode" },
body: { service_tier: "priority" },
providerOptions: { reasoningEffort: "high" },
})
expect(selected.route.endpoint.baseURL).toBe("https://gateway.example/v1")
expect(selected.route.defaults.headers).toEqual({ "x-application": "opencode" })
expect(selected.route.defaults.http?.body).toEqual({ service_tier: "priority" })
expect(selected.route.defaults.providerOptions).toEqual({ reasoningEffort: "high" })
}
}),
)
expect(selected.route.endpoint.baseURL).toBe("https://gateway.example/v1")
expect(selected.route.defaults.headers).toEqual({ "x-application": "opencode" })
expect(selected.route.defaults.http?.body).toEqual({ service_tier: "priority" })
expect(selected.route.defaults.providerOptions).toEqual({ reasoningEffort: "high" })
}
})
it.effect("resolves provider environment credentials and preserves deprecated Together credentials", () =>
Effect.gen(function* () {
@@ -1,4 +1,4 @@
import { describe, expect } from "bun:test"
import { describe, expect, test } from "bun:test"
import { Effect, Schema } from "effect"
import { HttpClientRequest } from "effect/unstable/http"
import { LLM, LLMRequest, Message, ToolCallPart, ToolChoice, ToolDefinition } from "../../src/index.js"
@@ -91,40 +91,38 @@ describe("OpenAI-compatible Chat route", () => {
}),
)
it.effect("provides model helpers for compatible provider families", () =>
Effect.gen(function* () {
expect(
providerFamilies.map(([provider, family]) => {
const model = family.configure({ apiKey: "test-key" }).model(`${provider}-model`)
return {
id: String(model.id),
provider: String(model.provider),
route: model.route.id,
baseURL: model.route.endpoint.baseURL,
}
}),
).toEqual(
providerFamilies.map(([provider, _, baseURL]) => ({
id: `${provider}-model`,
provider,
route: "openai-compatible-chat",
baseURL,
})),
)
test("provides model helpers for compatible provider families", () => {
expect(
providerFamilies.map(([provider, family]) => {
const model = family.configure({ apiKey: "test-key" }).model(`${provider}-model`)
return {
id: String(model.id),
provider: String(model.provider),
route: model.route.id,
baseURL: model.route.endpoint.baseURL,
}
}),
).toEqual(
providerFamilies.map(([provider, _, baseURL]) => ({
id: `${provider}-model`,
provider,
route: "openai-compatible-chat",
baseURL,
})),
)
const custom = OpenAICompatible.deepseek
.configure({
apiKey: "test-key",
baseURL: "https://custom.deepseek.test/v1",
})
.model("deepseek-chat")
expect(custom).toMatchObject({
provider: "deepseek",
route: { id: "openai-compatible-chat" },
const custom = OpenAICompatible.deepseek
.configure({
apiKey: "test-key",
baseURL: "https://custom.deepseek.test/v1",
})
expect(custom.route.endpoint.baseURL).toBe("https://custom.deepseek.test/v1")
}),
)
.model("deepseek-chat")
expect(custom).toMatchObject({
provider: "deepseek",
route: { id: "openai-compatible-chat" },
})
expect(custom.route.endpoint.baseURL).toBe("https://custom.deepseek.test/v1")
})
it.effect("matches AI SDK compatible basic request body fixture", () =>
Effect.gen(function* () {
+38 -38
View File
@@ -26,6 +26,7 @@ import {
Usage,
} from "../src/schema/index.js"
import { ProviderShared } from "../src/protocols/shared.js"
import { it } from "./lib/effect.js"
const model = new LanguageModel({
id: ModelID.make("fake-model"),
@@ -101,49 +102,48 @@ describe("AI.Usage", () => {
expect(ProviderShared.sumTokens()).toBeUndefined()
})
test("sseFraming maps decoder failures to AI errors", async () => {
const error = await Effect.runPromise(
ProviderShared.sseFraming(Stream.make(new TextEncoder().encode(`data: ${"x".repeat(10 * 1024 * 1024)}`))).pipe(
Stream.runCollect,
Effect.flip,
),
)
it.effect("sseFraming maps decoder failures to AI errors", () =>
Effect.gen(function* () {
const error = yield* ProviderShared.sseFraming(
Stream.make(new TextEncoder().encode(`data: ${"x".repeat(10 * 1024 * 1024)}`)),
).pipe(Stream.runCollect, Effect.flip)
expect(error).toBeInstanceOf(AIError)
expect(error.reason._tag).toBe("InvalidProviderOutput")
})
expect(error).toBeInstanceOf(AIError)
expect(error.reason._tag).toBe("InvalidProviderOutput")
}),
)
test("sseFraming ignores retry directives without ending the stream", async () => {
const encoder = new TextEncoder()
const frames = await Effect.runPromise(
ProviderShared.sseFraming(
it.effect("sseFraming ignores retry directives without ending the stream", () =>
Effect.gen(function* () {
const encoder = new TextEncoder()
const frames = yield* ProviderShared.sseFraming(
Stream.make(
encoder.encode("retry: 1000\n\n"),
encoder.encode('data: {"first":true}\n\n'),
encoder.encode("retry: 2000\n\n"),
encoder.encode('data: {"second":true}\n\n'),
).pipe(Stream.rechunk(1)),
).pipe(Stream.runCollect),
)
).pipe(Stream.runCollect)
expect(Array.from(frames)).toEqual(['{"first":true}', '{"second":true}'])
})
expect(Array.from(frames)).toEqual(['{"first":true}', '{"second":true}'])
}),
)
test("sseFraming preserves event data around retry directives", async () => {
const encoder = new TextEncoder()
const frames = await Effect.runPromise(
ProviderShared.sseFraming(
it.effect("sseFraming preserves event data around retry directives", () =>
Effect.gen(function* () {
const encoder = new TextEncoder()
const frames = yield* ProviderShared.sseFraming(
Stream.make(
encoder.encode("event: update\ndata: first\n"),
encoder.encode("retry: 1000\n"),
encoder.encode("data: second\n\n"),
).pipe(Stream.rechunk(1)),
new Set(["update"]),
).pipe(Stream.runCollect),
)
).pipe(Stream.runCollect)
expect(Array.from(frames)).toEqual(["first\nsecond"])
})
expect(Array.from(frames)).toEqual(["first\nsecond"])
}),
)
test("visibleOutputTokens clamps reasoning > output to zero", () => {
expect(new Usage({ outputTokens: 10, reasoningTokens: 4 }).visibleOutputTokens).toBe(6)
@@ -153,18 +153,18 @@ describe("AI.Usage", () => {
})
})
test("AI errors expose the shared runtime tag", async () => {
const error = new AIError({
reason: new InvalidRequestError({ message: "invalid" }),
})
expect(error._tag).toBe("AI.Error")
expect(error.message).toBe("invalid")
expect(error.cause).toBe(error.reason)
expect(error.reason.cause).toBeUndefined()
expect(
await Effect.runPromise(Effect.fail(error).pipe(Effect.catchTag("AI.Error", () => Effect.succeed("caught")))),
).toBe("caught")
})
it.effect("AI errors expose the shared runtime tag", () =>
Effect.gen(function* () {
const error = new AIError({
reason: new InvalidRequestError({ message: "invalid" }),
})
expect(error._tag).toBe("AI.Error")
expect(error.message).toBe("invalid")
expect(error.cause).toBe(error.reason)
expect(error.reason.cause).toBeUndefined()
expect(yield* Effect.fail(error).pipe(Effect.catchTag("AI.Error", () => Effect.succeed("caught")))).toBe("caught")
}),
)
test("transport errors serialize execution facts", () => {
const reason = new TransportError({
+67 -73
View File
@@ -1,4 +1,4 @@
import { describe, expect } from "bun:test"
import { describe, expect, test } from "bun:test"
import { Content } from "@opencode-ai/schema/tool"
import { Effect, Schema, Stream } from "effect"
import {
@@ -277,65 +277,61 @@ describe("LLMClient tools", () => {
}),
)
it.effect("models canonical tool files with URIs", () =>
Effect.sync(() => {
const decode = Schema.decodeUnknownSync(Content)
test("models canonical tool files with URIs", () => {
const decode = Schema.decodeUnknownSync(Content)
expect(decode({ type: "file", uri: "data:image/png;base64,AAAA", mime: "image/png" })).toEqual({
type: "file",
uri: "data:image/png;base64,AAAA",
mime: "image/png",
})
expect(decode({ type: "file", uri: "https://example.test/image.png", mime: "image/png" })).toEqual({
type: "file",
uri: "https://example.test/image.png",
mime: "image/png",
})
expect(decode({ type: "file", uri: "file:///tmp/image.png", mime: "image/png" })).toEqual({
type: "file",
uri: "file:///tmp/image.png",
mime: "image/png",
})
}),
)
expect(decode({ type: "file", uri: "data:image/png;base64,AAAA", mime: "image/png" })).toEqual({
type: "file",
uri: "data:image/png;base64,AAAA",
mime: "image/png",
})
expect(decode({ type: "file", uri: "https://example.test/image.png", mime: "image/png" })).toEqual({
type: "file",
uri: "https://example.test/image.png",
mime: "image/png",
})
expect(decode({ type: "file", uri: "file:///tmp/image.png", mime: "image/png" })).toEqual({
type: "file",
uri: "file:///tmp/image.png",
mime: "image/png",
})
})
it.effect("preserves canonical tool file URIs", () =>
Effect.sync(() => {
expect(
ToolOutput.toResultValue(
ToolOutput.make({}, [{ type: "file", uri: "data:image/png;base64,AAAA", mime: "image/png" }]),
),
).toEqual({
type: "content",
value: [{ type: "file", uri: "data:image/png;base64,AAAA", mime: "image/png" }],
})
expect(
ToolOutput.toResultValue(
ToolOutput.make({}, [{ type: "file", uri: "https://example.test/image.png", mime: "image/png" }]),
),
).toEqual({
test("preserves canonical tool file URIs", () => {
expect(
ToolOutput.toResultValue(
ToolOutput.make({}, [{ type: "file", uri: "data:image/png;base64,AAAA", mime: "image/png" }]),
),
).toEqual({
type: "content",
value: [{ type: "file", uri: "data:image/png;base64,AAAA", mime: "image/png" }],
})
expect(
ToolOutput.toResultValue(
ToolOutput.make({}, [{ type: "file", uri: "https://example.test/image.png", mime: "image/png" }]),
),
).toEqual({
type: "content",
value: [{ type: "file", uri: "https://example.test/image.png", mime: "image/png" }],
})
expect(
ToolOutput.toResultValue(
ToolOutput.make({}, [{ type: "file", uri: "file:///tmp/image.png", mime: "image/png" }]),
),
).toEqual({
type: "content",
value: [{ type: "file", uri: "file:///tmp/image.png", mime: "image/png" }],
})
expect(
ToolOutput.fromResultValue({
type: "content",
value: [{ type: "file", uri: "https://example.test/image.png", mime: "image/png" }],
})
expect(
ToolOutput.toResultValue(
ToolOutput.make({}, [{ type: "file", uri: "file:///tmp/image.png", mime: "image/png" }]),
),
).toEqual({
type: "content",
value: [{ type: "file", uri: "file:///tmp/image.png", mime: "image/png" }],
})
expect(
ToolOutput.fromResultValue({
type: "content",
value: [{ type: "file", uri: "https://example.test/image.png", mime: "image/png" }],
}),
).toEqual({
structured: {},
content: [{ type: "file", uri: "https://example.test/image.png", mime: "image/png" }],
})
}),
)
}),
).toEqual({
structured: {},
content: [{ type: "file", uri: "https://example.test/image.png", mime: "image/png" }],
})
})
it.effect("settles projected URL files as canonical tool results", () =>
Effect.gen(function* () {
@@ -364,24 +360,22 @@ describe("LLMClient tools", () => {
}),
)
it.effect("derives typed output schemas and preserves dynamic output schemas", () =>
Effect.sync(() => {
const [typed] = toDefinitions({ get_weather })
const schema = { type: "object", properties: { result: { type: "string" } } } as const
const [dynamic] = toDefinitions({
dynamic: Tool.make({ description: "Dynamic tool.", jsonSchema: { type: "object" }, outputSchema: schema }),
})
test("derives typed output schemas and preserves dynamic output schemas", () => {
const [typed] = toDefinitions({ get_weather })
const schema = { type: "object", properties: { result: { type: "string" } } } as const
const [dynamic] = toDefinitions({
dynamic: Tool.make({ description: "Dynamic tool.", jsonSchema: { type: "object" }, outputSchema: schema }),
})
expect(typed?.outputSchema).toMatchObject({
type: "object",
properties: { condition: { type: "string" } },
required: ["temperature", "condition"],
additionalProperties: false,
})
expect(Reflect.get(Reflect.get(typed?.outputSchema ?? {}, "properties") as object, "temperature")).toBeDefined()
expect(dynamic?.outputSchema).toEqual(schema)
}),
)
expect(typed?.outputSchema).toMatchObject({
type: "object",
properties: { condition: { type: "string" } },
required: ["temperature", "condition"],
additionalProperties: false,
})
expect(Reflect.get(Reflect.get(typed?.outputSchema ?? {}, "properties") as object, "temperature")).toBeDefined()
expect(dynamic?.outputSchema).toEqual(schema)
})
it.effect("preserves content tool results from dynamic tools", () =>
Effect.gen(function* () {
+37 -43
View File
@@ -1,4 +1,4 @@
import { describe, expect } from "bun:test"
import { describe, expect, test } from "bun:test"
import { Effect } from "effect"
import { AIError } from "../src/schema/index.js"
import { ToolStream } from "../src/protocols/utils/tool-stream.js"
@@ -38,44 +38,40 @@ describe("ToolStream", () => {
}),
)
it.effect("exposes cumulative partial string values", () =>
Effect.gen(function* () {
const result = ToolStream.appendOrStart(
ADAPTER,
ToolStream.empty<number>(),
0,
{ id: "call_1", name: "lookup", text: '{"query":"wea' },
"missing tool",
)
if (ToolStream.isError(result)) return yield* result
test("exposes cumulative partial string values", () => {
const result = ToolStream.appendOrStart(
ADAPTER,
ToolStream.empty<number>(),
0,
{ id: "call_1", name: "lookup", text: '{"query":"wea' },
"missing tool",
)
if (ToolStream.isError(result)) throw result
expect(result.events.at(-1)).toEqual({
type: "tool-input-delta",
id: "call_1",
name: "lookup",
text: '{"query":"wea',
input: { query: "wea" },
})
}),
)
expect(result.events.at(-1)).toEqual({
type: "tool-input-delta",
id: "call_1",
name: "lookup",
text: '{"query":"wea',
input: { query: "wea" },
})
})
it.effect("defaults partial input to an empty object when the accumulated value cannot be parsed", () =>
Effect.gen(function* () {
const result = ToolStream.appendOrStart(
ADAPTER,
ToolStream.empty<number>(),
0,
{ id: "call_1", name: "lookup", text: "x" },
"missing tool",
)
if (ToolStream.isError(result)) return yield* result
test("defaults partial input to an empty object when the accumulated value cannot be parsed", () => {
const result = ToolStream.appendOrStart(
ADAPTER,
ToolStream.empty<number>(),
0,
{ id: "call_1", name: "lookup", text: "x" },
"missing tool",
)
if (ToolStream.isError(result)) throw result
expect(result.events).toEqual([
{ type: "tool-input-start", id: "call_1", name: "lookup" },
{ type: "tool-input-delta", id: "call_1", name: "lookup", text: "x", input: {} },
])
}),
)
expect(result.events).toEqual([
{ type: "tool-input-start", id: "call_1", name: "lookup" },
{ type: "tool-input-delta", id: "call_1", name: "lookup", text: "x", input: {} },
])
})
it.effect("keeps accumulated identity when later deltas contain empty strings", () =>
Effect.gen(function* () {
@@ -104,14 +100,12 @@ describe("ToolStream", () => {
}),
)
it.effect("fails appendExisting when the provider skipped the tool start", () =>
Effect.gen(function* () {
const error = ToolStream.appendExisting(ADAPTER, ToolStream.empty<number>(), 0, "{}", "missing tool")
test("fails appendExisting when the provider skipped the tool start", () => {
const error = ToolStream.appendExisting(ADAPTER, ToolStream.empty<number>(), 0, "{}", "missing tool")
expect(error).toBeInstanceOf(AIError)
if (ToolStream.isError(error)) expect(error.message).toBe("missing tool")
}),
)
expect(error).toBeInstanceOf(AIError)
if (ToolStream.isError(error)) expect(error.message).toBe("missing tool")
})
it.effect("uses final input override without losing accumulated deltas", () =>
Effect.gen(function* () {
@@ -25,6 +25,7 @@ for (const viewport of [
const mock = await openDraft(page)
const pending = await submitPending(page, mock)
expect(mock.worktreeRequests).toEqual([expect.objectContaining({ from: directory })])
await expect(pending.message).toBeInViewport()
await expect(pending.shimmer).toBeInViewport()
await testInfo.attach("creating-worktree", {
@@ -184,6 +185,7 @@ test("restores the draft after closing and revisiting a pending session that fai
async function openDraft(page: Page, options?: { failSessionCreate?: boolean }) {
const worktree = Promise.withResolvers<{ status: number; json: { directory?: string; message?: string } }>()
const calls: string[] = []
const worktreeRequests: Record<string, unknown>[] = []
const creates: Record<string, unknown>[] = []
const prompts: { sessionID: string; body: Record<string, unknown> }[] = []
const project = {
@@ -216,7 +218,10 @@ async function openDraft(page: Page, options?: { failSessionCreate?: boolean })
page.on("request", (request) => {
if (request.method() !== "POST") return
const path = new URL(request.url()).pathname
if (path === `/api/worktree/${projectID}`) calls.push("worktree")
if (path === `/api/worktree/${projectID}`) {
calls.push("worktree")
worktreeRequests.push(request.postDataJSON())
}
if (path === "/api/session") calls.push("session")
if (/^\/api\/session\/[^/]+\/prompt$/.test(path)) calls.push("prompt")
})
@@ -274,7 +279,7 @@ async function openDraft(page: Page, options?: { failSessionCreate?: boolean })
await page.getByRole("menuitem", { name: "New workspace", exact: true }).click()
await expect(page.getByRole("button", { name: "New workspace", exact: true })).toBeVisible()
await expect(page.locator('[data-component="composer-editor"]')).toBeEditable()
return { worktree, calls, creates, prompts }
return { worktree, worktreeRequests, calls, creates, prompts }
}
async function submitPending(page: Page, mock: Awaited<ReturnType<typeof openDraft>>) {
@@ -308,8 +308,8 @@ for (const delivery of ["steer", "queue"] as const) {
})
const tools = page.locator('[data-timeline-part-ids="tool_queue_read,tool_queue_grep"]')
await expect(tools).toBeVisible()
await expect(tools).toContainText(/Used\s*Read, Grep/)
await expect(tools.locator('[data-component="tag"]')).toHaveText("2")
await expect(tools).toHaveText(/^Used\s*2 Read, Grep$/)
await expect(tools.locator('[data-slot="basic-tool-tool-title"]')).toHaveText("2 Read, Grep")
await expect(thinking).toHaveCount(0)
await expect(pending).toBeVisible()
expect(mock.rows.map((row) => ({ id: row.id, delivery: row.delivery }))).toEqual([
@@ -318,7 +318,7 @@ for (const delivery of ["steer", "queue"] as const) {
await transcript.screenshot({ path: testInfo.outputPath("pending-steer.png") })
// Soft assertions let delivery run too, even when the pending ordering regresses.
await expect.soft(tools.or(pending)).toHaveText([/Used\s*Read, Grep/, /U2: Also check the retry path\./])
await expect.soft(tools.or(pending)).toHaveText([/^Used\s*2 Read, Grep$/, /U2: Also check the retry path\./])
await expect
.soft(transcript.locator('[data-timeline-row="AssistantPart"]').filter({ has: tools }))
.toHaveAttribute("data-message-id", userID)
@@ -350,7 +350,7 @@ for (const delivery of ["steer", "queue"] as const) {
await expect(response).toHaveAttribute("data-message-id", inboxID)
await expect(thinking).toHaveCount(0)
await expect(tools.or(pending).or(response)).toHaveText([
/Used\s*Read, Grep/,
/^Used\s*2 Read, Grep$/,
/U2: Also check the retry path\./,
/A3: Now checking the retry path for U2\./,
])
@@ -25,7 +25,7 @@ test("space activates a focused timeline button instead of scrolling", async ({
seedHistory: true,
})
const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
const trigger = page.getByRole("button", { name: "Used Shell" })
const trigger = page.getByRole("button", { name: "Used 1 Shell", exact: true })
await expect
.poll(() => scroller.evaluate((element) => element.scrollHeight - element.clientHeight))
.toBeGreaterThan(300)
@@ -93,7 +93,8 @@ test.describe("regression: session timeline local row state", () => {
await expectSessionTitle(page, title)
const group = page.locator('[data-component="collapsed-tool-group"]')
const summary = group.getByRole("button", { name: "Used Patch", exact: true })
const summary = group.getByRole("button", { name: /^Used \d+ Patch$/ })
await expect(summary).toHaveAccessibleName("Used 1 Patch")
await summary.click()
await group.locator(`[data-timeline-part-id="${editPartID}"]`).evaluate((element) => {
element.setAttribute("data-disclosure-probe", "existing")
@@ -109,7 +110,8 @@ test.describe("regression: session timeline local row state", () => {
if (count === 3) await trigger.click()
const id = `prt_patch_${count}`
events.push(...toolEvents({ ...part, id, callID: id }))
await expect(group.locator('[data-component="tag"]')).toHaveText(String(count))
await expect(summary).toHaveAccessibleName(`Used ${count} Patch`)
await expect(summary.locator('[data-slot="basic-tool-tool-title"]')).toHaveText(`${count} Patch`)
await expect(group).toHaveAttribute("data-timeline-part-ids", new RegExp(`${id}$`))
await expect(trigger).toHaveAttribute("aria-expanded", String(count === 2))
await expect(summary).toHaveAttribute("aria-expanded", "true")
@@ -55,7 +55,7 @@ test.describe("regression: session timeline context group resize", () => {
await devtools.send("Emulation.setCPUThrottlingRate", { rate: 4 })
const context = page.locator(`[data-timeline-part-ids="${contextIDs.join(",")}"]`).first()
await expectAppVisible(context)
await expect(context.getByRole("button")).toHaveAccessibleName("Used Read, Glob, Grep, List")
await expect(context.getByRole("button")).toHaveAccessibleName("Used 4 Read, Glob, Grep, List")
const contextSelector = `[data-timeline-part-ids="${contextIDs.join(",")}"]`
const regions = defineVisualRegions({
@@ -88,7 +88,7 @@ test.describe("regression: session timeline context group resize", () => {
await page.waitForTimeout(delay)
}
await expect(context.getByRole("button")).toHaveAccessibleName("Used Read, Glob, Grep, List")
await expect(context.getByRole("button")).toHaveAccessibleName("Used 4 Read, Glob, Grep, List")
await page.waitForTimeout(700)
const trace = await stopVisualProbe<keyof typeof regions>(page)
const labels = trace.samples
@@ -107,7 +107,7 @@ test.describe("regression: session timeline context group resize", () => {
]),
)
expect(labels).toEqual(["Used Read, Glob, Grep, List"])
expect(labels).toEqual(["Used 4 Read, Glob, Grep, List"])
expect(issues, JSON.stringify(trace.samples, null, 2)).toEqual([])
})
})
@@ -28,7 +28,7 @@ for (const expanded of [false, true]) {
})
const trigger = expanded
? page.locator(`[data-timeline-part-id="${id}"] [data-slot="collapsible-trigger"]`)
: page.getByRole("button", { name: "Used Shell" })
: page.getByRole("button", { name: "Used 1 Shell", exact: true })
await expect(trigger).toHaveAttribute("aria-expanded", String(expanded))
await trigger.click()
await expect(trigger).toHaveAttribute("aria-expanded", String(!expanded))
@@ -141,7 +141,7 @@ for (const open of [false, true]) {
await timeline.send(partUpdated(shell(shellID, "completed", "done")))
await timeline.send(messageUpdated(completedAssistantInfo(assistant)))
await timeline.send(status("idle"))
const used = group.getByRole("button", { name: "Used Shell", exact: true })
const used = group.getByRole("button", { name: "Used 1 Shell", exact: true })
await expect(used).toHaveAttribute("aria-expanded", "false")
await used.click()
await expect(used).toHaveAttribute("aria-expanded", "true")
@@ -150,7 +150,7 @@ for (const open of [false, true]) {
"aria-expanded",
String(open),
)
await expect(group.locator('[data-component="tag"]')).toHaveText("1")
await expect(used.locator('[data-slot="basic-tool-tool-title"]')).toHaveText("1 Shell")
await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
await expect(used).toHaveAttribute("aria-expanded", "true")
if (!open) await thought.click()
@@ -16,8 +16,9 @@ for (const locale of ["de", "ar"] as const) {
})
const group = page.locator(`[data-timeline-part-ids="${ids.join(",")}"]`)
await expect(group.getByRole("button")).toHaveAccessibleName(/^Used /)
await expect(group.locator('[data-component="tag"]')).toHaveText("2")
const names = locale === "de" ? "Lesen, Glob" : "\u0642\u0631\u0627\u0621\u0629, Glob"
await expect(group.getByRole("button")).toHaveAccessibleName(`Used 2 ${names}`)
await expect(group.locator('[data-slot="basic-tool-tool-title"]')).toHaveText(`2 ${names}`)
await expect(page.locator("html")).toHaveAttribute("lang", locale)
})
}
@@ -105,10 +105,10 @@ for (const summaries of [false, true]) {
if (profile === "tool") {
const group = page.locator('[data-component="collapsed-tool-group"]')
const used = group.locator(':scope > [data-component="collapsible"] > [data-slot="collapsible-trigger"]')
await expect(used).toContainText("UsedSkill")
await expect(used).toHaveText(/^Used\s*1 Skill$/)
await expect(used).toHaveAttribute("aria-expanded", "false")
await expect(page.getByText("Inspecting stability", { exact: true })).toBeHidden()
await expect(group.locator('[data-component="tag"]')).toHaveText("1")
await expect(used.locator('[data-slot="basic-tool-tool-title"]')).toHaveText("1 Skill")
await used.click()
await expect(used).toHaveAttribute("aria-expanded", "true")
await expect(group.locator(`[data-timeline-part-id="prt_reasoning_tool_${summaries}"]`)).toBeVisible()
@@ -45,10 +45,11 @@ test("expands a mixed collapsed tool stack without expanding its individual call
const group = page.locator(
'[data-timeline-part-ids="prt_stack_shell_1,prt_stack_explore,prt_stack_patch,prt_stack_shell_2"]',
)
const summary = group.getByRole("button", { name: "Used Shell, Agent, Patch" })
const summary = group.getByRole("button", { name: "Used 4 Shell, Agent, Patch", exact: true })
await expect(summary).toHaveAttribute("aria-expanded", "false")
await expect(summary).toHaveCSS("height", "28px")
await expect(group.locator('[data-component="tag"]')).toHaveText("4")
await expect(summary.locator('[data-slot="basic-tool-tool-title"]')).toHaveText("4 Shell, Agent, Patch")
await expect(summary.locator('[data-component="tag"]')).toHaveCount(0)
await summary.click()
await expect(summary).toHaveAttribute("aria-expanded", "true")
await expect(group.locator('[data-slot="context-tool-group-item"]')).toHaveCount(4)
@@ -74,8 +75,8 @@ test("leaves tools expanded by settings outside the collapsed stack", async ({ p
await expect(page.locator('[data-timeline-part-id="prt_expanded_shell"]')).toBeVisible()
const group = page.locator('[data-timeline-part-ids="prt_collapsed_patch,prt_collapsed_read"]')
await expect(group.getByRole("button", { name: "Used Patch, Read" })).toBeVisible()
await expect(group.locator('[data-component="tag"]')).toHaveText("2")
await expect(group.getByRole("button", { name: "Used 2 Patch, Read", exact: true })).toBeVisible()
await expect(group.locator('[data-slot="basic-tool-tool-title"]')).toHaveText("2 Patch, Read")
await expect(page.locator('[data-timeline-spacing="tool"]')).toHaveCSS("padding-top", "8px")
})
@@ -113,7 +114,7 @@ test("combines follow-up patches into one three-file stack inside Used", async (
],
})
const group = page.locator('[data-component="collapsed-tool-group"]')
await group.getByRole("button", { name: "Used Shell, Patch", exact: true }).click()
await group.getByRole("button", { name: "Used 2 Shell, Patch", exact: true }).click()
await expect(group.getByText("2 files", { exact: true })).toBeVisible()
await timeline.send(
partUpdated(
@@ -128,7 +129,10 @@ test("combines follow-up patches into one three-file stack inside Used", async (
),
),
)
await expect(group.locator('[data-component="tag"]')).toHaveText("3")
await expect(group.getByRole("button", { name: "Used 3 Shell, Patch", exact: true })).toHaveAttribute(
"aria-expanded",
"true",
)
await expect(group.locator('[data-component="apply-patch-tool"]')).toHaveCount(1)
await expect(group.getByText("3 files", { exact: true })).toBeVisible()
await expect(group.locator('[data-slot="apply-patch-filename"]')).toHaveText(["a.ts", "b.ts", "c.ts"])
@@ -158,8 +162,8 @@ test("keeps failed search calls and their error cards inside the collapsed stack
await setupTimeline(page, { messages: [userMessage(), assistantMessage(parts)] })
const group = page.locator('[data-timeline-part-ids="prt_error_glob,prt_error_grep"]')
const summary = group.getByRole("button", { name: "Used Glob, Grep" })
await expect(group.locator('[data-component="tag"]')).toHaveText("2")
const summary = group.getByRole("button", { name: "Used 2 Glob, Grep", exact: true })
await expect(summary.locator('[data-slot="basic-tool-tool-title"]')).toHaveText("2 Glob, Grep")
await summary.click()
await expect(group.locator('[data-kind="tool-error-card"]')).toHaveCount(2)
const glob = group.locator('[data-timeline-part-id="prt_error_glob"]')
@@ -52,7 +52,7 @@ test("shows parent lineage while the child timeline loads", async ({ page }) =>
await page.goto(sessionHref(parentID))
await expectSessionTitle(page, parentTitle)
await page.getByRole("button", { name: "Used Agent" }).click()
await page.getByRole("button", { name: "Used 1 Agent", exact: true }).click()
await page.locator(`a[href="${sessionHref(childID)}"]`).click()
await Promise.all([requested.promise, expect(page).toHaveURL(sessionHref(childID))])
await Promise.all([
@@ -77,7 +77,7 @@ test("keeps the parent visible while the child session resolves", async ({ page
await page.goto(sessionHref(parentID))
await expectSessionTitle(page, parentTitle)
await page.getByRole("button", { name: "Used Agent" }).click()
await page.getByRole("button", { name: "Used 1 Agent", exact: true }).click()
await page.locator(`a[href="${sessionHref(childID)}"]`).click()
await requested.promise
await Promise.all([expect(page).toHaveURL(sessionHref(parentID)), expectSessionTitle(page, parentTitle)]).finally(
@@ -195,7 +195,7 @@ async function setup(page: Page, events?: () => OpenCodeEvent[]) {
async function openChildFromParent(page: Page) {
await page.goto(sessionHref(parentID))
await expectSessionTitle(page, parentTitle)
await page.getByRole("button", { name: "Used Agent" }).click()
await page.getByRole("button", { name: "Used 1 Agent", exact: true }).click()
const card = page.locator(`a[href="${sessionHref(childID)}"]`)
await expect(card).toBeVisible()
@@ -209,6 +209,29 @@ test("appearance experimental setting switches tab orientation", async ({ page }
await page.setViewportSize({ width: 390, height: 360 })
await version.scrollIntoViewIfNeeded()
await expect(version).toBeInViewport()
// Reload the UI-selected preference without seeding settings storage.
await page.reload()
const href = `/server/${base64Encode(server)}/session/${sessionA.id}`
await expect(
page
.locator('[data-slot="titlebar-tabs"]')
.locator(`[data-titlebar-tab-link][href="${href}"]`)
.getByText(sessionA.title, { exact: true }),
).toBeVisible()
await expect(page.locator('[data-slot="vertical-tabs-sidebar"]')).toHaveCount(0)
await page.setViewportSize({ width: 1280, height: 720 })
await expect(
page
.locator('[data-slot="vertical-tabs-sidebar"]')
.locator(`[data-titlebar-tab-link][href="${href}"]`)
.getByText(sessionA.title, { exact: true }),
).toBeVisible()
await expect(page.locator('[data-slot="titlebar-tabs"]')).toHaveCount(0)
await page.keyboard.press("Control+,")
await settings.getByRole("tab", { name: "Appearance" }).click()
await expect(layout).toContainText("Vertical")
})
test("vertical tab preference falls back to horizontal on mobile", async ({ page }) => {
@@ -198,6 +198,10 @@ fixture(
fixture(
"precaches public files of every type and size, excluding deployment metadata and source maps",
async ({ page, site, builds, context }) => {
for (const output of Object.values(builds)) {
expect(output["/sw.js.map"]).toBeUndefined()
expect(Object.keys(output).some((path) => path.startsWith("/_assets/") && path.endsWith(".map"))).toBe(true)
}
await install(page, site.url)
const files = ["/nested/data.json", "/nested/font.woff2", "/nested/module.wasm", "/large.bin"]
await context.setOffline(true)
+5 -13
View File
@@ -42,21 +42,18 @@
"@playwright/test": "catalog:",
"@sentry/vite-plugin": "catalog:",
"@tailwindcss/vite": "4.3.3",
"@tsconfig/bun": "1.0.9",
"@types/bun": "catalog:",
"@types/luxon": "catalog:",
"@types/node": "catalog:",
"@typescript/native-preview": "catalog:",
"diff": "catalog:",
"happy-dom": "20.11.1",
"tw-animate-css": "1.4.0",
"typescript": "catalog:",
"vite": "8.2.1",
"vite-plugin-icons-spritesheet": "3.0.1",
"vite": "8.2.2",
"vite-plugin-pwa": "1.3.0",
"vite-plugin-solid": "2.11.14"
},
"dependencies": {
"@corvu/drawer": "catalog:",
"@dnd-kit/abstract": "0.5.0",
"@dnd-kit/dom": "0.5.0",
"@dnd-kit/helpers": "0.5.0",
@@ -71,28 +68,23 @@
"@pierre/trees": "1.0.0-beta.4",
"@sentry/solid": "catalog:",
"@solid-primitives/event-bus": "1.1.2",
"@solid-primitives/event-listener": "2.4.5",
"@solid-primitives/event-listener": "catalog:",
"@solid-primitives/i18n": "2.2.1",
"@solid-primitives/keyed": "1.5.3",
"@solid-primitives/media": "2.3.3",
"@solid-primitives/resize-observer": "2.1.5",
"@solid-primitives/media": "catalog:",
"@solid-primitives/resize-observer": "catalog:",
"@solid-primitives/scheduled": "1.5.3",
"@solid-primitives/storage": "catalog:",
"@solidjs/meta": "catalog:",
"@solidjs/router": "catalog:",
"@tanstack/solid-query": "5.91.4",
"@tanstack/solid-virtual": "catalog:",
"diff": "catalog:",
"effect": "catalog:",
"fuzzysort": "catalog:",
"ghostty-web": "github:anomalyco/ghostty-web#83c0a07b8628b748aed073b232cb4b52a6ca11c1",
"luxon": "catalog:",
"marked": "catalog:",
"marked-shiki": "catalog:",
"remeda": "catalog:",
"shiki": "catalog:",
"solid-js": "catalog:",
"solid-list": "catalog:",
"solid-presence": "0.2.0",
"tailwindcss": "4.3.3"
}
@@ -1,5 +1,4 @@
import { base64Encode } from "@opencode-ai/util/encode"
import { getDirectory } from "@opencode-ai/util/path"
import type { SessionMessageUser } from "@opencode-ai/client/promise"
import { Session } from "@opencode-ai/schema/session"
import { startTransition } from "solid-js"
@@ -13,6 +12,7 @@ import { useData, useServer } from "@/runtime/server/current"
import { type ServerSDK, useServerSDK } from "@/runtime/server/client"
import { useTabs } from "@/shell/tabs/tabs"
import { useWorkspaceLocation } from "@/workspaces/location"
import { createWorktree } from "@/workspaces/create"
import { useSessionKey } from "@/session/session-layout"
import { showToast } from "@/shell/notifications/toast"
import { SessionRouteKey, SessionStateKey } from "@/runtime/server/scope"
@@ -192,25 +192,17 @@ async function resolveSessionDirectory(input: {
if (input.worktree === "main") return input.projectDirectory
if (input.worktree !== "create") return input.worktree
return input.serverSDK.api.worktree
.create({
projectID: input.data.location.info({ directory: input.projectDirectory })?.project.id ?? "",
strategy: "git",
branch: input.branch,
directory: getDirectory(
input.data.location.info({ directory: input.projectDirectory })?.project.directory ?? input.projectDirectory,
),
})
.then(async (created) => {
await input.serverSDK.api.location.get({ location: { directory: created.directory } })
return created.directory
})
.catch((error) => {
showToast({
title: input.language.t("prompt.toast.worktreeCreateFailed.title"),
description: errorMessage(input.language, error),
})
return createWorktree({
api: input.serverSDK.api,
directory: input.projectDirectory,
project: input.data.location.info({ directory: input.projectDirectory })?.project,
branch: input.branch,
}).catch((error) => {
showToast({
title: input.language.t("prompt.toast.worktreeCreateFailed.title"),
description: errorMessage(input.language, error),
})
})
}
function errorMessage(language: ReturnType<typeof useLanguage>, error: unknown) {
@@ -644,7 +644,7 @@ export function persisted<T>(
: undefined
if (channel) onCleanup(() => channel.close())
const [state, setState, init] = makePersisted(store, {
const [state, setState, init] = makePersisted<T, typeof store>(store, {
name: config.key,
storage,
sync: channel ? messageSync(channel) : undefined,
@@ -0,0 +1,135 @@
import { describe, expect, test } from "bun:test"
import { createApiForServer } from "./api"
import { readLocalImage } from "./image"
function setup(
respond: (init?: RequestInit) => Response | Promise<Response> = () => new Response(new Uint8Array([0, 127, 255])),
) {
const requests: Array<{ url: URL; init?: RequestInit }> = []
const api = createApiForServer({
server: { url: "https://server.example:4096", username: "image-user", password: "secret" },
fetch: (async (input: string | URL | Request, init?: RequestInit) => {
requests.push({ url: new URL(input instanceof Request ? input.url : input), init })
return respond(init)
}) as typeof fetch,
})
return { api, requests }
}
describe("readLocalImage", () => {
test.each([
["images/screen #1 + 50% ?.PNG", "/workspace/project", "images/screen%20%231%20%2B%2050%25%20%3F.PNG"],
["C:/tmp/opencode/screen #1.png", "C:/tmp/opencode/", "screen%20%231.png"],
["d:/screen.png", "d:/", "screen.png"],
["D:/charts/screen.png", "D:/charts/", "screen.png"],
["Z:/charts/screen.png", "Z:/charts/", "screen.png"],
["/tmp/opencode/screen #1.png", "/tmp/opencode/", "screen%20%231.png"],
["/screen.png", "/", "screen.png"],
])("reads %s with the correct location and authentication", async (path, directory, encoded) => {
const { api, requests } = setup()
const signal = new AbortController().signal
const blob = await readLocalImage(api, "/workspace/project", path, signal)
expect(blob).toBeInstanceOf(Blob)
expect(blob?.type).toBe("image/png")
expect(new Uint8Array(await blob!.arrayBuffer())).toEqual(new Uint8Array([0, 127, 255]))
expect(requests).toHaveLength(1)
expect(requests[0].url.origin).toBe("https://server.example:4096")
expect(requests[0].url.pathname).toBe(`/api/fs/read/${encoded}`)
expect([...requests[0].url.searchParams]).toEqual([["location[directory]", directory]])
expect(requests[0].init?.method).toBe("GET")
expect(new Headers(requests[0].init?.headers).get("authorization")).toBe(`Basic ${btoa("image-user:secret")}`)
expect(requests[0].init?.signal).toBe(signal)
})
test("preserves literal percent escapes and encodes the supplied relative location", async () => {
const { api, requests } = setup()
await readLocalImage(api, "C:/project #1 + 50%", "images/literal%20name.png", new AbortController().signal)
expect(requests[0].url.href).toBe(
"https://server.example:4096/api/fs/read/images/literal%2520name.png?location%5Bdirectory%5D=C%3A%2Fproject+%231+%2B+50%25",
)
})
test.each([
["png", "image/png"],
["jpeg", "image/jpeg"],
["JPG", "image/jpeg"],
["gif", "image/gif"],
["webp", "image/webp"],
["SVG", "image/svg+xml"],
["avif", "image/avif"],
["bmp", "image/bmp"],
["ico", "image/x-icon"],
])("uses the MIME type for .%s rather than the response header", async (extension, type) => {
const { api } = setup(
() => new Response("image bytes", { headers: { "content-type": "application/octet-stream" } }),
)
const blob = await readLocalImage(api, "/repo", `image.${extension}`, new AbortController().signal)
expect(blob?.type).toBe(type)
expect(await blob?.text()).toBe("image bytes")
})
test.each([
"https://example.com/image.png",
"HTTP://example.com/image.png",
"file:///tmp/image.png",
"data:image/png;base64,image.png",
"blob:https://example.com/image.png",
"custom+scheme:image.png",
"C:image.png",
"//server/share/image.png",
"\\\\server\\share\\image.png",
"//?/C:/image.png",
"///server/share/image.png",
"image.txt",
"image.html",
"image.constructor",
"image.png.txt",
"image",
"images.png/image",
"image.png/",
"",
])("does not request unsupported input %s", async (path) => {
const { api, requests } = setup()
expect(await readLocalImage(api, "/repo", path, new AbortController().signal)).toBeUndefined()
expect(requests).toHaveLength(0)
})
test.each([400, 401])("propagates declared API errors (%s)", async (status) => {
const error = { _tag: "RequestError", message: "Cannot read image" }
const { api } = setup(() => Response.json(error, { status }))
await expect(readLocalImage(api, "/repo", "image.png", new AbortController().signal)).rejects.toEqual(error)
})
test.each([404, 500])("does not turn an unexpected HTTP status (%s) into a Blob", async (status) => {
const { api } = setup(() => new Response("Not an image", { status }))
await expect(readLocalImage(api, "/repo", "image.png", new AbortController().signal)).rejects.toMatchObject({
reason: "UnexpectedStatus",
cause: { status },
})
})
test("propagates transport failures", async () => {
const error = new Error("Connection lost")
const { api } = setup(() => Promise.reject(error))
await expect(readLocalImage(api, "/repo", "image.png", new AbortController().signal)).rejects.toMatchObject({
reason: "Transport",
cause: error,
})
})
test("forwards cancellation to an in-flight fetch", async () => {
const controller = new AbortController()
const { api, requests } = setup(
(init) =>
new Promise((_resolve, reject) => {
init?.signal?.addEventListener("abort", () => reject(init.signal?.reason), { once: true })
}),
)
const result = readLocalImage(api, "/repo", "image.png", controller.signal)
expect(requests[0].init?.signal).toBe(controller.signal)
controller.abort()
await expect(result).rejects.toMatchObject({ reason: "Transport", cause: controller.signal.reason })
})
})
+36
View File
@@ -0,0 +1,36 @@
import { getDirectory, getFilename } from "@opencode-ai/util/path"
import type { OpenCodeClient } from "@opencode-ai/client/promise"
const types = new Map([
["png", "image/png"],
["jpeg", "image/jpeg"],
["jpg", "image/jpeg"],
["gif", "image/gif"],
["webp", "image/webp"],
["svg", "image/svg+xml"],
["avif", "image/avif"],
["bmp", "image/bmp"],
["ico", "image/x-icon"],
])
export async function readLocalImage(
api: Pick<OpenCodeClient, "file">,
directory: string,
path: string,
signal: AbortSignal,
): Promise<Blob | undefined> {
const drive = /^[a-z]:\//i.test(path)
if (/^[\\/]{2}/.test(path) || (!drive && /^[a-z][a-z\d+.-]*:/i.test(path))) return
const type = types.get(path.match(/\.([^./]+)$/)?.[1]?.toLowerCase() ?? "")
if (!type) return
const absolute = drive || path.startsWith("/")
// Scope absolute images to their parent, including files outside the project.
const bytes = await api.file.read(
{
path: absolute ? getFilename(path) : path,
location: { directory: absolute ? getDirectory(path) : directory },
},
{ signal },
)
return new Blob([new Uint8Array(bytes)], { type })
}
@@ -1,6 +1,6 @@
import { Menu } from "@opencode-ai/ui/menu"
import { Icon } from "@opencode-ai/ui/icon"
import { getDirectory, getFilename } from "@opencode-ai/util/path"
import { getFilename } from "@opencode-ai/util/path"
import { createStore } from "solid-js/store"
import { createSignal, For, Show, type ComponentProps, type JSX } from "solid-js"
import type { Project } from "@/runtime/server/types"
@@ -11,6 +11,7 @@ import { useSettingsDialog } from "@/settings/command"
import { pathKey } from "@/workspaces/path-key"
import { showToast } from "@/shell/notifications/toast"
import { containsDirectory, sameDirectory, workspaceDirectories } from "@/workspaces/paths"
import { createWorktree } from "@/workspaces/create"
export function SessionWorkspaceMenu(props: {
eligible?: boolean
@@ -55,7 +56,14 @@ export function SessionWorkspaceMenu(props: {
setStore("selected", selection)
try {
const destination = selection === "create" ? await createWorkspace(props.project, sdk) : selection
const destination =
selection === "create"
? await createWorktree({
api: sdk.api,
directory: props.directory,
project: data.location.info({ directory: props.directory })?.project,
})
: selection
if (!destination) return
await sdk.api.session.move({ sessionID, directory: destination })
@@ -124,13 +132,3 @@ export function SessionWorkspaceMenu(props: {
</Menu>
)
}
async function createWorkspace(project: Project, serverSDK: ReturnType<typeof useServerSDK>) {
const created = await serverSDK.api.worktree.create({
projectID: project.id,
strategy: "git",
directory: getDirectory(project.worktree),
})
await serverSDK.api.location.get({ location: { directory: created.directory } })
return created.directory
}
+10 -4
View File
@@ -1,5 +1,5 @@
import { TextField } from "@opencode-ai/ui/text-field"
import { captureException, isEnabled } from "@sentry/solid"
import type { captureException } from "@sentry/solid"
import { Logo } from "@opencode-ai/ui/logo"
import { Button } from "@opencode-ai/ui/button"
import { Component, createSignal, onMount, Show } from "solid-js"
@@ -227,6 +227,7 @@ export const ErrorPage: Component<ErrorPageProps> = (props) => {
let recordedFatalError: Promise<void> | undefined
const [store, setStore] = createStore({
actionError: undefined as string | undefined,
captureException: undefined as typeof captureException | undefined,
})
function ensureFatalErrorRecorded() {
@@ -243,6 +244,11 @@ export const ErrorPage: Component<ErrorPageProps> = (props) => {
onMount(() => {
void ensureFatalErrorRecorded().catch(() => undefined)
void import("@sentry/solid")
.then(({ captureException, isEnabled }) => {
if (isEnabled()) setStore("captureException", () => captureException)
})
.catch(() => undefined)
})
async function checkForUpdates() {
@@ -311,15 +317,15 @@ export const ErrorPage: Component<ErrorPageProps> = (props) => {
{language.t("error.page.action.exportLogs")}
</Button>
</Show>
<Show when={isEnabled}>
{(_) => {
<Show when={store.captureException}>
{(capture) => {
const [reported, setReported] = createSignal(false)
return (
<Button
size="large"
disabled={reported()}
onClick={() => {
captureException(props.error)
capture()(props.error)
setReported(true)
}}
>
@@ -1,4 +1,5 @@
import { DataProvider } from "@opencode-ai/session-ui/context"
import { MarkdownProvider, type ReadMarkdownImage } from "@opencode-ai/session-ui/context/markdown"
import { useNavigate, useParams } from "@solidjs/router"
import { createMemo, type ParentProps } from "solid-js"
import { useProviders } from "@/providers/catalog/providers"
@@ -8,6 +9,7 @@ import { sessionHref } from "@/shell/routes/session"
import { useData } from "@/runtime/server/current"
import { useServerSDK } from "@/runtime/server/client"
import { useTabs } from "@/shell/tabs/tabs"
import { readLocalImage } from "@/runtime/server/image"
export function SessionUIProvider(
props: ParentProps<{
@@ -21,6 +23,10 @@ export function SessionUIProvider(
const serverSDK = useServerSDK()
const tabs = useTabs()
const directory = () => props.directory
const readImage = createMemo<ReadMarkdownImage>(() => {
const dir = directory()
return (path, signal) => readLocalImage(serverSDK.api, dir, path, signal)
})
const href = (sessionID: string) => sessionHref(props.server, sessionID)
const navigateToSession = async (sessionID: string) => {
const tab = tabs.store.find(
@@ -60,7 +66,9 @@ export function SessionUIProvider(
onNavigateToSession={navigateToSession}
onSessionHref={href}
>
<LocalProvider>{props.children}</LocalProvider>
<MarkdownProvider readImage={readImage()}>
<LocalProvider>{props.children}</LocalProvider>
</MarkdownProvider>
</DataProvider>
)
}
@@ -0,0 +1,90 @@
import { describe, expect, test } from "bun:test"
import { OpenCode } from "@opencode-ai/client/promise"
import { createWorktree } from "./create"
describe("worktree creation", () => {
test.each(
[
{ name: "clone", directory: "/copies/repo", root: "/copies/repo", canonical: "/copies/repo", parent: "/copies/" },
{
name: "clone subdirectory",
directory: "/copies/repo/packages/app",
root: "/copies/repo",
canonical: "/copies/repo",
parent: "/copies/",
},
{
name: "linked worktree subdirectory",
directory: "/linked/task/packages/app",
root: "/linked/task",
canonical: "/copies/repo",
parent: "/copies/",
},
{
name: "Windows clone",
directory: "C:\\copies\\repo\\packages\\app",
root: "C:\\copies\\repo",
canonical: "C:\\copies\\repo",
parent: "C:/copies/",
},
].flatMap((input) => [true, false].map((cached) => ({ ...input, cached }))),
)("uses the clone-local main for $name (cached: $cached)", async (input) => {
const project = { id: "proj_clone", directory: input.root, canonical: input.canonical }
const requests: Request[] = []
const api = OpenCode.make({
baseUrl: "http://localhost:3000",
fetch: Object.assign(
async (input: RequestInfo | URL, init?: RequestInit) => {
const request = new Request(input, init)
requests.push(request)
if (request.method === "POST") return Response.json({ directory: "/created" })
return Response.json({ directory: new URL(request.url).searchParams.get("location[directory]"), project })
},
{ preconnect() {} },
),
})
expect(
await createWorktree({
api,
directory: input.directory,
project: input.cached ? project : undefined,
branch: "clone-only",
}),
).toBe("/created")
expect(await requests.find((request) => request.method === "POST")?.json()).toEqual({
strategy: "git",
from: input.canonical,
branch: "clone-only",
directory: input.parent,
})
expect(requests.find((request) => request.method === "POST")?.url).toBe(
"http://localhost:3000/api/worktree/proj_clone",
)
expect(
requests
.filter((request) => request.method === "GET")
.map((request) => new URL(request.url).searchParams.get("location[directory]")),
).toEqual(input.cached ? ["/created"] : [input.directory, "/created"])
})
test("does not fall back to a shared project when location lookup fails", async () => {
const requests: Request[] = []
const api = OpenCode.make({
baseUrl: "http://localhost:3000",
fetch: Object.assign(
async (input: RequestInfo | URL, init?: RequestInit) => {
requests.push(new Request(input, init))
return Response.json({ message: "unavailable" }, { status: 503 })
},
{ preconnect() {} },
),
})
await expect(createWorktree({ api, directory: "/copies/repo" })).rejects.toMatchObject({
reason: "UnexpectedStatus",
cause: { status: 503 },
})
expect(requests.map((request) => request.method)).toEqual(["GET"])
})
})
+20
View File
@@ -0,0 +1,20 @@
import type { LocationGetOutput, OpenCodeClient } from "@opencode-ai/client/promise"
import { getDirectory } from "@opencode-ai/util/path"
export async function createWorktree(input: {
api: Pick<OpenCodeClient, "location" | "worktree">
directory: string
project?: LocationGetOutput["project"]
branch?: string
}) {
const project = input.project ?? (await input.api.location.get({ location: { directory: input.directory } })).project
const created = await input.api.worktree.create({
projectID: project.id,
strategy: "git",
from: project.canonical,
branch: input.branch,
directory: getDirectory(project.canonical),
})
await input.api.location.get({ location: { directory: created.directory } })
return created.directory
}
+2
View File
@@ -10,6 +10,8 @@ export function serviceWorker(directory: string) {
injectRegister: false,
manifest: false,
workbox: {
// Workbox runs after Sentry's upload and cleanup, so do not publish an unuploaded map.
sourcemap: false,
globDirectory: directory,
clientsClaim: false,
// Keep each open tab on its complete build until all old clients close.
@@ -0,0 +1,58 @@
import { NodeFileSystem } from "@effect/platform-node"
import { Global } from "@opencode-ai/util/global"
import { AppProcess } from "@opencode-ai/util/process"
import { Effect, FileSystem, Layer, Stream } from "effect"
import { ChildProcessSpawner } from "effect/unstable/process"
import assert from "node:assert/strict"
import path from "node:path"
import { Updater } from "../updater"
const latest = { version: "0.0.0-beta-17498" }
const installs: string[] = []
// This fixture runs in its own process; no real update requests or installs occur.
globalThis.fetch = Object.assign(async () => Response.json(latest), { preconnect() {} })
await Effect.runPromise(
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem
const directory = yield* fs.makeTempDirectoryScoped({ prefix: "opencode-updater-" })
const dependencies = Layer.mergeAll(
Layer.succeed(FileSystem.FileSystem, fs),
Layer.succeed(
Global.Service,
Global.make({ home: directory, config: directory, cache: path.join(directory, "cache") }),
),
Layer.succeed(AppProcess.Service, {
...ChildProcessSpawner.make(() => Effect.die("Unexpected process spawn")),
runStream: () => Stream.die("Unexpected streaming process"),
run: (command) => {
assert.equal(command._tag, "StandardCommand")
if (command.command === "npm" && command.args[0] === "install") {
assert.ok(command.args.includes("--global"))
installs.push(command.args.at(-1)!)
}
return Effect.succeed({
command: command.command,
exitCode: 0,
stdout: Buffer.from(command.command === "npm" ? "@opencode-ai/cli" : ""),
stderr: Buffer.alloc(0),
stdoutTruncated: false,
stderrTruncated: false,
})
},
}),
)
yield* Effect.gen(function* () {
const updater = yield* Updater.Service
yield* updater.check()
assert.deepEqual(installs, ["@opencode-ai/cli@0.0.0-beta-17498"])
yield* updater.check()
yield* updater.check()
assert.deepEqual(installs, ["@opencode-ai/cli@0.0.0-beta-17498"])
latest.version = "0.0.0-beta-17499"
yield* updater.check()
assert.deepEqual(installs, ["@opencode-ai/cli@0.0.0-beta-17498", "@opencode-ai/cli@0.0.0-beta-17499"])
}).pipe(Effect.provide(Updater.layer.pipe(Layer.provide(dependencies))))
}).pipe(Effect.scoped, Effect.provide(NodeFileSystem.layer)),
)
+2 -2
View File
@@ -5,9 +5,9 @@ const maximumComponent = "9007199254740991"
const versionPattern =
/^v?([0-9]+)\.([0-9]+)\.([0-9]+)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/
export function action(current: string, latest: string, policy: Policy): Action {
export function action(current: string, latest: string, policy: Policy, installed = current): Action {
if (policy === false) return "none"
const currentVersion = parseReleaseVersion(current)
const currentVersion = parseReleaseVersion(installed)
const latestVersion = parseReleaseVersion(latest)
if (!currentVersion || !latestVersion || sameRelease(currentVersion, latestVersion)) return "none"
// Major upgrades are never installed automatically.
+30
View File
@@ -1,8 +1,30 @@
import { describe, expect, test } from "bun:test"
import path from "node:path"
import { action } from "./updater-action"
import { decodePolicy } from "./updater"
describe("updater", () => {
test("remembers successful installs across checks and accepts the next release", async () => {
// Isolate compiled version constants and the update endpoint from other tests.
const child = Bun.spawn(
[
process.execPath,
"--define",
'OPENCODE_VERSION="0.0.0-next-16473"',
"--define",
'OPENCODE_CHANNEL="beta"',
path.join(import.meta.dir, "fixtures/updater.ts"),
],
{ env: { ...process.env, OPENCODE_DISABLE_AUTOUPDATE: "" }, stdout: "pipe", stderr: "pipe" },
)
const [code, stdout, stderr] = await Promise.all([
child.exited,
new Response(child.stdout).text(),
new Response(child.stderr).text(),
])
expect(code, stdout + stderr).toBe(0)
})
test("reads autoupdate from JSONC", () => {
expect(decodePolicy('{ // preference\n "autoupdate": "notify",\n}')).toBe("notify")
expect(decodePolicy('{ "autoupdate": false }')).toBe(false)
@@ -32,6 +54,14 @@ describe("updater", () => {
expect(action("1.2.3", "1.2.3", true)).toBe("none")
})
test("skips an installed update but still accepts the next release", () => {
const current = "0.0.0-next-16473"
const installed = "0.0.0-beta-17498"
expect(action(current, installed, true)).toBe("upgrade")
expect(action(current, installed, true, installed)).toBe("none")
expect(action(current, "0.0.0-beta-17499", true, installed)).toBe("upgrade")
})
test("upgrades when latest is lower (rollback)", () => {
expect(action("1.2.4", "1.2.3", true)).toBe("upgrade")
})
+15 -11
View File
@@ -1,7 +1,7 @@
import { Global } from "@opencode-ai/util/global"
import { AppProcess } from "@opencode-ai/util/process"
import { OPENCODE_CHANNEL, OPENCODE_LOCAL, OPENCODE_VERSION } from "../version"
import { Context, Duration, Effect, FileSystem, Layer } from "effect"
import { Context, Duration, Effect, FileSystem, Layer, Ref } from "effect"
import { ChildProcess } from "effect/unstable/process"
import { parse, type ParseError } from "jsonc-parser"
import path from "node:path"
@@ -38,6 +38,7 @@ export const layer = Layer.effect(
const fs = yield* FileSystem.FileSystem
const global = yield* Global.Service
const appProcess = yield* AppProcess.Service
const installed = yield* Ref.make(OPENCODE_VERSION)
const channel = OPENCODE_CHANNEL.replace(/[^a-zA-Z0-9._-]/g, "-")
const readPolicy = Effect.fnUntraced(function* () {
@@ -112,18 +113,20 @@ export const layer = Layer.effect(
const upgrade = Effect.fnUntraced(function* (method: Method, version: string) {
const target = `${packageName}@${version}`
const commands: Record<Exclude<Method, "bun" | "curl">, string[]> = {
npm: ["npm", "install", "--global", target],
const commands: Record<Exclude<Method, "bun" | "curl" | "npm">, string[]> = {
pnpm: ["pnpm", "add", "--global", `--allow-build=${packageName}`, target],
yarn: ["yarn", "global", "add", target],
}
const result = yield* Effect.scoped(
Effect.gen(function* () {
if (method === "bun") {
// Bun does not prune old versions from its shared package cache.
if (method === "bun" || method === "npm") {
yield* fs.makeDirectory(global.cache, { recursive: true })
const cache = yield* fs.makeTempDirectoryScoped({ directory: global.cache, prefix: "update-" })
return yield* run(["bun", "install", "--global", "--trust", "--cache-dir", cache, target], "5 minutes")
const command =
method === "bun"
? ["bun", "install", "--global", "--trust", "--cache-dir", cache, target]
: ["npm", "install", "--global", "--cache", cache, target]
return yield* run(command, "5 minutes")
}
if (method === "curl") {
yield* fs.makeDirectory(global.cache, { recursive: true })
@@ -156,18 +159,19 @@ export const layer = Layer.effect(
return yield* Effect.gen(function* () {
const version = yield* latest()
const current = yield* Ref.get(installed)
yield* Effect.logInfo("update check", {
current: OPENCODE_VERSION,
current,
latest: version,
})
const next = action(OPENCODE_VERSION, version, policy)
const next = action(OPENCODE_VERSION, version, policy, current)
if (next === "none") return yield* Effect.logInfo("update check done", { action: "up-to-date" })
if (next === "notify")
return yield* Effect.logInfo("OpenCode update available", { current: OPENCODE_VERSION, latest: version })
if (next === "notify") return yield* Effect.logInfo("OpenCode update available", { current, latest: version })
const detected = yield* method()
if (!detected) return yield* Effect.logWarning("automatic update skipped: installation method not found")
yield* upgrade(detected, version)
yield* Effect.logInfo("updated OpenCode", { from: OPENCODE_VERSION, to: version, method: detected })
yield* Ref.set(installed, version)
yield* Effect.logInfo("updated OpenCode", { from: current, to: version, method: detected })
})
},
Effect.catchCause((cause) => Effect.logWarning("automatic update failed", { cause })),
+2
View File
@@ -183,6 +183,7 @@ export type SessionCreateInput = {
readonly agent?: Agent.ID | undefined
readonly model?: Model.Ref | undefined
readonly location?: Location.Ref | undefined
readonly metadata?: Session.Metadata | undefined
}
export type SessionCreateOutput = Session.Info
export type SessionCreateOperation<E = never> = (input?: SessionCreateInput) => Effect.Effect<SessionCreateOutput, E>
@@ -410,6 +411,7 @@ export type SessionLogOutput =
readonly title?: string | undefined
readonly agent?: Agent.ID | undefined
readonly model?: Model.Ref | undefined
readonly metadata?: Session.Metadata | undefined
readonly version: string
}
}
@@ -360,6 +360,7 @@ const EndpointSessionCreate = (raw: RawClient["server.session"]) => (input?: Ses
agent: input?.["agent"],
model: input?.["model"],
location: input?.["location"],
metadata: input?.["metadata"],
},
}).pipe(
Effect.mapError(mapClientError),
@@ -513,6 +513,7 @@ export function make(options: ClientOptions) {
agent: input?.["agent"],
model: input?.["model"],
location: input?.["location"],
metadata: input?.["metadata"],
},
successStatus: 200,
declaredStatuses: [401, 400],
@@ -29,6 +29,8 @@ export type TokenUsageInfo = {
export type LocationRef = { directory: string; workspaceID?: string }
export type SessionMetadata = { [x: string]: JsonValue }
export type FileDiffInfo = {
file: string
patch: string
@@ -548,6 +550,7 @@ export type SessionCreated = {
title?: string
agent?: string
model?: ModelRef
metadata?: SessionMetadata
version: string
}
}
@@ -1642,6 +1645,7 @@ export type SessionInfo = {
title?: string
location: LocationRef
subpath?: string
metadata?: SessionMetadata
revert?: SessionRevert
}
@@ -2687,6 +2691,7 @@ export type SessionCreateInput = {
readonly agent?: string | null
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
readonly location?: { readonly directory: string; readonly workspaceID?: string } | null
readonly metadata?: { readonly [x: string]: JsonValue } | null
}["id"]
readonly title?: {
readonly id?: string | null
@@ -2694,6 +2699,7 @@ export type SessionCreateInput = {
readonly agent?: string | null
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
readonly location?: { readonly directory: string; readonly workspaceID?: string } | null
readonly metadata?: { readonly [x: string]: JsonValue } | null
}["title"]
readonly agent?: {
readonly id?: string | null
@@ -2701,6 +2707,7 @@ export type SessionCreateInput = {
readonly agent?: string | null
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
readonly location?: { readonly directory: string; readonly workspaceID?: string } | null
readonly metadata?: { readonly [x: string]: JsonValue } | null
}["agent"]
readonly model?: {
readonly id?: string | null
@@ -2708,6 +2715,7 @@ export type SessionCreateInput = {
readonly agent?: string | null
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
readonly location?: { readonly directory: string; readonly workspaceID?: string } | null
readonly metadata?: { readonly [x: string]: JsonValue } | null
}["model"]
readonly location?: {
readonly id?: string | null
@@ -2715,7 +2723,16 @@ export type SessionCreateInput = {
readonly agent?: string | null
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
readonly location?: { readonly directory: string; readonly workspaceID?: string } | null
readonly metadata?: { readonly [x: string]: JsonValue } | null
}["location"]
readonly metadata?: {
readonly id?: string | null
readonly title?: string | null
readonly agent?: string | null
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
readonly location?: { readonly directory: string; readonly workspaceID?: string } | null
readonly metadata?: { readonly [x: string]: JsonValue } | null
}["metadata"]
}
export type SessionCreateOutput = { data: SessionInfo }["data"]
@@ -2752,6 +2769,7 @@ export type SessionImportInput = {
readonly title?: string
readonly location: { readonly directory: string; readonly workspaceID?: string }
readonly subpath?: string
readonly metadata?: { readonly [x: string]: JsonValue }
readonly revert?: {
readonly messageID: string
readonly partID?: string
@@ -3028,6 +3046,7 @@ export type SessionImportInput = {
readonly title?: string
readonly location: { readonly directory: string; readonly workspaceID?: string }
readonly subpath?: string
readonly metadata?: { readonly [x: string]: JsonValue }
readonly revert?: {
readonly messageID: string
readonly partID?: string
@@ -3304,6 +3323,7 @@ export type SessionImportInput = {
readonly title?: string
readonly location: { readonly directory: string; readonly workspaceID?: string }
readonly subpath?: string
readonly metadata?: { readonly [x: string]: JsonValue }
readonly revert?: {
readonly messageID: string
readonly partID?: string
+3 -1
View File
@@ -12,7 +12,9 @@ import { AbsolutePath } from "../src/schema"
const args = process.argv.slice(2)
const iterationsIndex = args.indexOf("--iterations")
const iterations = iterationsIndex === -1 ? 10 : Number(args[iterationsIndex + 1])
const directory = args.find((arg, index) => !arg.startsWith("--") && index !== iterationsIndex + 1) ?? process.cwd()
const directory =
args.find((arg, index) => !arg.startsWith("--") && (iterationsIndex === -1 || index !== iterationsIndex + 1)) ??
process.cwd()
if (!Number.isInteger(iterations) || iterations < 1) {
console.error("--iterations must be a positive integer")
+2 -2
View File
@@ -3,7 +3,7 @@ export * as CodeModeTool from "./tool.js"
import { CodeMode, Tool, toolError } from "@opencode-ai/codemode"
import type { Content, Context, Error, Info, Metadata, Result } from "@opencode-ai/schema/tool"
import { Effect, Ref, Schema, Semaphore } from "effect"
import { definition } from "../tool/runtime.js"
import { definition, normalizedName } from "../tool/runtime.js"
const ExecuteFile = Schema.Struct({
data: Schema.String,
@@ -165,7 +165,7 @@ function runtime(
}
function qualifiedName(registration: Info) {
const normalized = registration.name.replace(/[^a-zA-Z0-9_-]/g, "_")
const normalized = normalizedName(registration)
if (registration.options?.namespace === undefined) return normalized
return `${registration.options.namespace}.${normalized}`
}
+4 -3
View File
@@ -81,12 +81,13 @@ export const testLayer = (initial: Entry[] = []) =>
Effect.gen(function* () {
const current = yield* Ref.get(entries)
const index = current.findIndex((entry) => entry.type === "document" && entry.path !== undefined)
if (index === -1)
return yield* Effect.fail(new UpdateError({ message: "No editable config document found" }))
const entry = current[index]
if (!entry || entry.type !== "document")
return yield* Effect.fail(new UpdateError({ message: "No editable config document found" }))
const info = produce(entry.info, update)
const info = yield* Effect.try({
try: () => produce(entry.info, update),
catch: (cause) => new UpdateError({ message: "Config update failed", cause }),
})
yield* Ref.set(entries, current.with(index, new Document({ type: "document", path: entry.path, info })))
return info
}),
+1 -4
View File
@@ -1,6 +1,7 @@
export * as ConfigNormalize from "./normalize.js"
import { isDeepStrictEqual } from "node:util"
import { isRecord } from "@opencode-ai/ai/utils/record"
import { Option, Schema } from "effect"
import { Info } from "@opencode-ai/schema/config"
import { ConfigAgent } from "@opencode-ai/schema/config/agent"
@@ -782,10 +783,6 @@ function isEnabledOnlyMcp(value: unknown) {
return isRecord(value) && !own(value, "type") && typeof value.enabled === "boolean"
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value)
}
function isPlainRecord(value: unknown): value is Record<string, unknown> {
if (!isRecord(value)) return false
const prototype = Object.getPrototypeOf(value)
@@ -227,7 +227,7 @@ export class SQLiteEffectInsertBase<
config: SQLiteInsertConfig<TTable>
constructor(
table: TTable,
private table: TTable,
values: SQLiteInsertConfig["values"],
private effectSession: SQLiteEffectSession<TEffectHKT, TRunResult, any>,
private effectDialect: SQLiteDialect,
+1 -1
View File
@@ -5,7 +5,7 @@ import path from "path"
import { Clock, Context, Deferred, Duration, Effect, Layer, Schema, Scope } from "effect"
import { Fff } from "#fff"
import fuzzysort from "fuzzysort"
import { FileSystem } from "../filesystem.js"
import { FileSystem } from "@opencode-ai/schema/filesystem"
import { Location } from "../location.js"
import { Ripgrep } from "../ripgrep.js"
import { RelativePath } from "../schema.js"
+5 -5
View File
@@ -7,16 +7,16 @@ import { FileSystem } from "@opencode-ai/schema/filesystem"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { Cause, Context, Effect, Layer, PubSub, RcMap, Schema, Stream } from "effect"
import { lazy } from "../util/lazy.js"
import { watch as watchFileSystem } from "node:fs"
import { watch } from "node:fs"
import path from "path"
import loadBinding from "./watcher-binding.js"
const SUBSCRIBE_TIMEOUT_MS = 10_000
export const Event = { Updated: FileSystem.Event.Changed }
const watcher = lazy((): typeof import("@parcel/watcher") | undefined => {
const watcher = lazy((): typeof ParcelWatcher | undefined => {
try {
return createWrapper(loadBinding()) as typeof import("@parcel/watcher")
return createWrapper(loadBinding()) as typeof ParcelWatcher
} catch {
return
}
@@ -192,7 +192,7 @@ export const nativeLayer = Layer.succeed(
if (input.type === "file") {
return Effect.sync(() => {
const directory = path.dirname(input.target)
const subscription = watchFileSystem(directory, { recursive: false }, (_event, file) => {
const subscription = watch(directory, { recursive: false }, (_event, file) => {
if (file && path.resolve(directory, file.toString()) !== input.target) return
input.publish({ path: input.target, type: "update" } satisfies Update)
})
@@ -218,7 +218,7 @@ export function configured(options?: Options) {
export const node = configured()
function subscribeDirectory(
native: typeof import("@parcel/watcher") | undefined,
native: typeof ParcelWatcher | undefined,
backend: ParcelWatcher.BackendType | undefined,
directory: string,
ignore: readonly string[],
@@ -330,7 +330,6 @@ export class OpenAICompatibleChatLanguageModel implements LanguageModelV3 {
const toolCalls: Array<{
id: string
type: "function"
function: {
name: string
arguments: string
@@ -560,7 +559,6 @@ export class OpenAICompatibleChatLanguageModel implements LanguageModelV3 {
toolCalls[index] = {
id: toolCallDelta.id,
type: "function",
function: {
name: toolCallDelta.function.name,
arguments: toolCallDelta.function.arguments ?? "",
@@ -1,9 +1,4 @@
import {
type LanguageModelV3Prompt,
type LanguageModelV3ToolCallPart,
type SharedV3Warning,
UnsupportedFunctionalityError,
} from "@ai-sdk/provider"
import { type LanguageModelV3Prompt, type SharedV3Warning, UnsupportedFunctionalityError } from "@ai-sdk/provider"
import { convertToBase64, parseProviderOptions } from "@ai-sdk/provider-utils"
import { z } from "zod/v4"
import type { OpenAIResponsesInput, OpenAIResponsesReasoning } from "./openai-responses-api-types.js"
@@ -116,7 +111,6 @@ export async function convertToOpenAIResponsesInput({
case "assistant": {
const reasoningMessages: Record<string, OpenAIResponsesReasoning> = {}
const toolCallParts: Record<string, LanguageModelV3ToolCallPart> = {}
for (const part of content) {
switch (part.type) {
@@ -129,8 +123,6 @@ export async function convertToOpenAIResponsesInput({
break
}
case "tool-call": {
toolCallParts[part.toolCallId] = part
if (part.providerExecuted) {
break
}
@@ -16,7 +16,7 @@ export const openaiErrorDataSchema = z.object({
export type OpenAIErrorData = z.infer<typeof openaiErrorDataSchema>
export const openaiFailedResponseHandler: any = createJsonErrorResponseHandler({
export const openaiFailedResponseHandler = createJsonErrorResponseHandler({
errorSchema: openaiErrorDataSchema,
errorToMessage: (data) => data.error.message,
})
@@ -792,7 +792,6 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 {
const ongoingToolCalls: Record<
number,
| {
toolName: string
toolCallId: string
codeInterpreter?: {
containerId: string
@@ -851,7 +850,6 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 {
if (isResponseOutputItemAddedChunk(value)) {
if (value.item.type === "function_call") {
ongoingToolCalls[value.output_index] = {
toolName: value.item.name,
toolCallId: value.item.call_id,
}
@@ -862,7 +860,6 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 {
})
} else if (value.item.type === "web_search_call") {
ongoingToolCalls[value.output_index] = {
toolName: webSearchToolName ?? "web_search",
toolCallId: value.item.id,
}
@@ -873,7 +870,6 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 {
})
} else if (value.item.type === "computer_call") {
ongoingToolCalls[value.output_index] = {
toolName: "computer_use",
toolCallId: value.item.id,
}
@@ -884,7 +880,6 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 {
})
} else if (value.item.type === "code_interpreter_call") {
ongoingToolCalls[value.output_index] = {
toolName: "code_interpreter",
toolCallId: value.item.id,
codeInterpreter: {
containerId: value.item.container_id,
+145
View File
@@ -0,0 +1,145 @@
import { Effect, Layer } from "effect"
import { Agent } from "./agent.js"
import { AISDK } from "./aisdk.js"
import { Catalog } from "./catalog.js"
import { Command } from "./command.js"
import { Config } from "./config.js"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Node } from "@opencode-ai/util/effect/app-node"
import { FileMutation } from "./file-mutation.js"
import { Environment } from "./environment/index.js"
import { Formatter } from "./formatter.js"
import { FileSystem } from "./filesystem.js"
import { FileSystemSearch } from "./filesystem/search.js"
import { Generate } from "./generate.js"
import { Form } from "./form.js"
import { Image } from "./image.js"
import { LocationWatcher } from "./filesystem/location-watcher.js"
import { Integration } from "./integration.js"
import { Location } from "./location.js"
import { LocationMutation } from "./location-mutation.js"
import { ModelResolver } from "./model-resolver.js"
import { Mcp } from "./mcp/index.js"
import { Permission } from "./permission.js"
import { Plugin } from "./plugin.js"
import { PluginHooks } from "./plugin/hooks.js"
import { InstancePlugins } from "./plugin/instance.js"
import { PluginSupervisor } from "./plugin/supervisor.js"
import { Worktree } from "./worktree.js"
import { Pty } from "./pty.js"
import { Shell } from "./shell.js"
import { ShellSelect } from "./shell/select.js"
import { Reference } from "./reference.js"
import { WebSearch } from "./websearch.js"
import { ReferenceInstructions } from "./reference/instructions.js"
import { SessionRunnerLLM } from "./session/runner/llm.js"
import { SessionRunnerModel } from "./session/runner/model.js"
import { SessionModelTransport } from "./session/model-transport.js"
import { SessionCompaction } from "./session/compaction.js"
import { SessionTitle } from "./session/title.js"
import { Skill } from "./skill.js"
import { SkillInstructions } from "./skill/instructions.js"
import { Snapshot } from "./snapshot.js"
import { InstructionDiscovery } from "./instruction-discovery.js"
import { InstructionBuiltIns } from "./instructions/builtins.js"
import { InstructionEntry } from "./session/instruction-entry.js"
import { SessionInstructions } from "./session/instructions.js"
import { SessionGenerateNode } from "./session/generate-node.js"
import { McpTool } from "./tool/mcp.js"
import { ReadToolFileSystem } from "./tool/read-filesystem.js"
import { Tool } from "./tool.js"
import { ToolOutput } from "./tool-output.js"
import { Vcs } from "./vcs.js"
export * as Instance from "./instance.js"
const nodes = [
Location.node,
Environment.node,
Config.node,
Agent.node,
Command.node,
Reference.node,
WebSearch.node,
Integration.node,
Catalog.node,
ModelResolver.node,
AISDK.node,
Plugin.node,
PluginHooks.node,
InstancePlugins.node,
PluginSupervisor.node,
Worktree.refreshNode,
FileSystemSearch.node,
FileSystem.node,
ShellSelect.node,
Pty.node,
Shell.node,
Skill.node,
InstructionBuiltIns.node,
InstructionDiscovery.node,
LocationMutation.node,
FileMutation.node,
Formatter.node,
Mcp.node,
Permission.node,
Tool.node,
ToolOutput.node,
Image.node,
SkillInstructions.node,
ReferenceInstructions.node,
InstructionEntry.node,
Form.node,
Generate.node,
SessionGenerateNode.node,
ReadToolFileSystem.node,
McpTool.node,
SessionInstructions.node,
SessionRunnerModel.node,
SessionModelTransport.node,
SessionCompaction.node,
SessionTitle.node,
Snapshot.node,
SessionRunnerLLM.node,
Vcs.node,
// Start repository watches only after boot-critical filesystem and Git work.
LocationWatcher.node,
] as const satisfies readonly Node.LocationNode<unknown, unknown>[]
export const graph = LayerNode.group<typeof nodes>(nodes)
export type Services = LayerNode.Output<typeof graph>
export type Error = LayerNode.Error<typeof graph>
export interface Options {
// Plugins this instance is born with; empty and absent are equivalent.
readonly plugins?: InstancePlugins.List
readonly replacements?: LayerNode.Replacements
}
// One instance is one compiled, fresh copy of the graph standing on a directory.
export function layer(ref: Location.Ref, options: Options = {}) {
const startedAt = performance.now()
// Bound pairs come last, so they win over caller replacements of the same nodes.
const allReplacements = (options.replacements ?? []).concat([
[Location.node, Location.boundNode(ref)],
[InstancePlugins.node, InstancePlugins.bound(options.plugins ?? [])],
])
// Apply replacements during hoist, not afterward: replacements can
// introduce new tagged dependencies (Location.boundNode depends on
// Project), and the hoist walk is the only pass that can still slice
// those back out.
const location = LayerNode.hoist(graph, Node.tags.values.global, allReplacements)
return LayerNode.compile(location.node).pipe(
Layer.fresh,
Layer.tap(() =>
Effect.logInfo("location services booted", {
directory: ref.directory,
workspaceID: ref.workspaceID,
durationMs: Math.round(performance.now() - startedAt),
}),
),
Layer.provide(LayerNode.compile(location.hoisted)),
)
}
+5 -10
View File
@@ -684,6 +684,10 @@ const layer = Layer.effect(
const credential = yield* credentials.get(connection.id)
if (!credential) return undefined
if (credential.value.type === "key") return credential.value
// Plugin activation batches registrations: a plugin resolving during its own
// setup must see the refresh implementation it just registered, or an expired
// credential is silently returned without refreshing.
yield* state.flush()
const implementation = state
.get()
.integrations.get(credential.integrationID)
@@ -719,16 +723,7 @@ const layer = Layer.effect(
}),
})
}),
activate: Effect.fn("Integration.connection.activate")(function* (credentialID) {
const credential = yield* credentials.get(credentialID)
if (!credential) return
const active = resolveConnections(
state.get().integrations.get(credential.integrationID),
yield* credentials.list(credential.integrationID),
)[0]
if (active?.type === "credential" && active.id === credentialID) return
yield* credentials.activate(credentialID)
}),
activate: Effect.fn("Integration.connection.activate")((credentialID) => credentials.activate(credentialID)),
update: Effect.fn("Integration.connection.update")((credentialID, updates) =>
credentials.update(credentialID, updates),
),
+19 -12
View File
@@ -16,7 +16,8 @@ export type Kind = typeof Kind.Type
/**
* Mutation paths do not accept project references. A leading `~` expands to
* the home directory; other relative paths resolve from the active Location.
* Paths outside it require separate `external_directory` approval.
* Paths outside it and its non-root project worktree require separate
* `external_directory` approval.
*/
export const ResolveInput = Schema.Struct({
path: Schema.String,
@@ -52,22 +53,24 @@ export interface Interface {
/**
* Resolve a path and derive its permission resources. A leading `~` expands
* to the home directory; other relative paths resolve from the Location.
* Paths outside it require separate `external_directory` approval. This does
* not approve the mutation.
* Paths outside it and its non-root project worktree require separate
* `external_directory` approval. This does not approve the mutation.
*/
readonly resolve: (input: ResolveInput) => Effect.Effect<Target, FSUtil.Error>
}
/** Lexical absolute path, expanding a leading `~` before resolving against `directory`. */
export const resolvePath = (directory: string, input: string, home = Global.Path.home) =>
path.resolve(
/** Lexical absolute path, normalizing Windows shell paths and expanding `~` before resolution. */
export const resolvePath = (directory: string, input: string, home = Global.Path.home) => {
const normalized = FSUtil.windowsPath(input)
return path.resolve(
directory,
input === "~"
normalized === "~"
? home
: input.startsWith("~/") || (process.platform === "win32" && input.startsWith("~\\"))
? path.join(home, input.slice(2))
: input,
: normalized.startsWith("~/") || (process.platform === "win32" && normalized.startsWith("~\\"))
? path.join(home, normalized.slice(2))
: normalized,
)
}
export class Service extends Context.Service<Service, Interface>()("@opencode/LocationMutation") {}
@@ -82,7 +85,11 @@ const layer = Layer.effect(
const resolve = Effect.fnUntraced(function* (input: ResolveInput) {
const absolute = resolvePath(location.directory, input.path)
if (FSUtil.contains(location.directory, absolute)) {
const worktree = path.resolve(location.project.directory)
const internal =
FSUtil.contains(location.directory, absolute) ||
(worktree !== path.parse(worktree).root && FSUtil.contains(worktree, absolute))
if (internal) {
return {
absolute,
resource: slash(path.relative(location.directory, absolute) || "."),
@@ -120,6 +127,6 @@ const layer = Layer.effect(
export const node = makeLocationNode({
service: Service,
layer: layer.pipe(Layer.orDie),
layer,
deps: [FSUtil.node, Location.node, ProjectMarkers.node],
})
+2 -2
View File
@@ -2,11 +2,11 @@ import { Context, Effect, Layer, LayerMap } from "effect"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Node } from "@opencode-ai/util/effect/app-node"
import { Location } from "./location.js"
import type { LocationError, LocationServices } from "./location-services.js"
import type { Instance } from "./instance.js"
export class Service extends Context.Service<
Service,
LayerMap.LayerMap<Location.Ref, LocationServices, LocationError>
LayerMap.LayerMap<Location.Ref, Instance.Services, Instance.Error>
>()("@opencode/example/LocationServiceMap") {
static get(ref: Location.Ref) {
return Layer.unwrap(Effect.map(Service, (locations) => locations.get(ref)))
+11 -136
View File
@@ -1,118 +1,16 @@
import { Duration, Effect, Layer, LayerMap } from "effect"
import { existsSync } from "fs"
import path from "path"
import { Agent } from "./agent.js"
import { AISDK } from "./aisdk.js"
import { Catalog } from "./catalog.js"
import { Command } from "./command.js"
import { Config } from "./config.js"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Node } from "@opencode-ai/util/effect/app-node"
import { Bus } from "./bus.js"
import { FileMutation } from "./file-mutation.js"
import { Environment } from "./environment/index.js"
import { Formatter } from "./formatter.js"
import { FileSystem } from "./filesystem.js"
import { FileSystemSearch } from "./filesystem/search.js"
import { Generate } from "./generate.js"
import { Form } from "./form.js"
import { Image } from "./image.js"
import { LocationWatcher } from "./filesystem/location-watcher.js"
import { Integration } from "./integration.js"
import { Instance } from "./instance.js"
import { Location } from "./location.js"
import { LocationMutation } from "./location-mutation.js"
import { LocationServiceMap } from "./location-service-map.js"
import { ModelResolver } from "./model-resolver.js"
import { Mcp } from "./mcp/index.js"
import { Permission } from "./permission.js"
import { Plugin } from "./plugin.js"
import { PluginHooks } from "./plugin/hooks.js"
import { PluginSupervisor } from "./plugin/supervisor.js"
import { Worktree } from "./worktree.js"
import { Pty } from "./pty.js"
import { Shell } from "./shell.js"
import { ShellSelect } from "./shell/select.js"
import { Reference } from "./reference.js"
import { WebSearch } from "./websearch.js"
import { ReferenceInstructions } from "./reference/instructions.js"
import { SessionRunnerLLM } from "./session/runner/llm.js"
import { SessionRunnerModel } from "./session/runner/model.js"
import { SessionModelTransport } from "./session/model-transport.js"
import { SessionCompaction } from "./session/compaction.js"
import { SessionTitle } from "./session/title.js"
import { Skill } from "./skill.js"
import { SkillInstructions } from "./skill/instructions.js"
import { Snapshot } from "./snapshot.js"
import { InstructionDiscovery } from "./instruction-discovery.js"
import { InstructionBuiltIns } from "./instructions/builtins.js"
import { InstructionEntry } from "./session/instruction-entry.js"
import { SessionInstructions } from "./session/instructions.js"
import { SessionGenerateNode } from "./session/generate-node.js"
import { McpTool } from "./tool/mcp.js"
import { ReadToolFileSystem } from "./tool/read-filesystem.js"
import { Tool } from "./tool.js"
import { ToolOutput } from "./tool-output.js"
import { Vcs } from "./vcs.js"
import { AbsolutePath } from "./schema.js"
export { LocationServiceMap } from "./location-service-map.js"
const locationServiceNodes = [
Location.node,
Environment.node,
Config.node,
Agent.node,
Command.node,
Reference.node,
WebSearch.node,
Integration.node,
Catalog.node,
ModelResolver.node,
AISDK.node,
Plugin.node,
PluginHooks.node,
PluginSupervisor.node,
Worktree.refreshNode,
FileSystemSearch.node,
FileSystem.node,
ShellSelect.node,
Pty.node,
Shell.node,
Skill.node,
InstructionBuiltIns.node,
InstructionDiscovery.node,
LocationMutation.node,
FileMutation.node,
Formatter.node,
Mcp.node,
Permission.node,
Tool.node,
ToolOutput.node,
Image.node,
SkillInstructions.node,
ReferenceInstructions.node,
InstructionEntry.node,
Form.node,
Generate.node,
SessionGenerateNode.node,
ReadToolFileSystem.node,
McpTool.node,
SessionInstructions.node,
SessionRunnerModel.node,
SessionModelTransport.node,
SessionCompaction.node,
SessionTitle.node,
Snapshot.node,
SessionRunnerLLM.node,
Vcs.node,
// Start repository watches only after boot-critical filesystem and Git work.
LocationWatcher.node,
] as const satisfies readonly Node.LocationNode<unknown, unknown>[]
export const locationServices = LayerNode.group<typeof locationServiceNodes>(locationServiceNodes)
export type LocationServices = LayerNode.Output<typeof locationServices>
export type LocationError = LayerNode.Error<typeof locationServices>
export type LocationServices = Instance.Services
export type LocationError = Instance.Error
export function buildLocationServiceMap(
replacements: LayerNode.Replacements = [],
@@ -127,37 +25,14 @@ export function buildLocationServiceMap(
return Layer.effect(
LocationServiceMap.Service,
Effect.map(
LayerMap.make(
(ref: Location.Ref) => {
const startedAt = performance.now()
const allReplacements = replacements.concat([[Location.node, Location.boundNode(ref)]])
// Apply replacements during hoist, not afterward: replacements can
// introduce new tagged dependencies (Location.boundNode depends on
// Project), and the hoist walk is the only pass that can still slice
// those back out.
const location = LayerNode.hoist(locationServices, Node.tags.values.global, allReplacements)
return LayerNode.compile(location.node).pipe(
Layer.fresh,
Layer.tap(() =>
Effect.logInfo("location services booted", {
directory: ref.directory,
workspaceID: ref.workspaceID,
durationMs: Math.round(performance.now() - startedAt),
}),
),
Layer.provide(LayerNode.compile(location.hoisted)),
)
},
{
// Workspace-placed directories exist only inside the workspace, so a
// local stat consults the wrong filesystem. Workspace liveness is
// owned by placement; do not probe the sandbox here, which would
// provision lazily-idle workspaces.
idleTimeToLive: (ref) =>
ref.workspaceID !== undefined || existsSync(ref.directory) ? Duration.infinity : Duration.zero,
},
),
LayerMap.make((ref: Location.Ref) => Instance.layer(ref, { replacements }), {
// Workspace-placed directories exist only inside the workspace, so a
// local stat consults the wrong filesystem. Workspace liveness is
// owned by placement; do not probe the sandbox here, which would
// provision lazily-idle workspaces.
idleTimeToLive: (ref) =>
ref.workspaceID !== undefined || existsSync(ref.directory) ? Duration.infinity : Duration.zero,
}),
(inner) => ({
...inner,
get: (ref: Location.Ref) => inner.get(canonical(ref)),
+2 -4
View File
@@ -273,14 +273,12 @@ export const layer = (options?: Options) =>
return McpOAuth.provider({ ...base, store: McpOAuth.memoryStore() })
const credentialID = found.id
const methodID = found.value.methodID
const integrationID = entry.integrationID
// Tracks the refresh token this provider last presented, so invalidate can tell whether the SDK
// rejected the currently-stored credential or a snapshot another connection has already rotated past.
let presented = found.value.refresh
const readOAuthCredential = async () => {
const stored = await Effect.runPromise(credentials.list(integrationID))
const match = stored.find((credential) => credential.id === credentialID)
return match && match.value.type === "oauth" ? match.value : undefined
const stored = await Effect.runPromise(credentials.get(credentialID))
return stored?.value.type === "oauth" ? stored.value : undefined
}
return McpOAuth.provider({
...base,
@@ -19,7 +19,7 @@ async function install(
const directory = path.join(root, `${input.version}-${input.sha256.slice(0, 16)}`)
await privateDirectory(directory)
const destination = path.join(directory, "opencode-pty")
if (await exists(destination, input.sha256)) return destination
if (await validateIfPresent(destination, input.sha256)) return destination
const bytes = new Uint8Array(await Bun.file(input.path).arrayBuffer())
if (sha256(bytes) !== input.sha256) throw new Error("Embedded opencode-pty checksum mismatch")
@@ -34,7 +34,7 @@ async function install(
}
await chmod(temporary, 0o755)
await rename(temporary, destination).catch(async (error) => {
if (!(await exists(destination, input.sha256))) throw error
if (!(await validateIfPresent(destination, input.sha256))) throw error
})
} finally {
await rm(temporary, { force: true })
@@ -52,7 +52,7 @@ async function privateDirectory(directory: string) {
await chmod(directory, 0o700)
}
async function exists(file: string, expected: string) {
async function validateIfPresent(file: string, expected: string) {
try {
await validate(file, expected)
return true
@@ -62,14 +62,13 @@ async function exists(file: string, expected: string) {
}
}
async function validate(file: string, expected?: string) {
async function validate(file: string, expected: string) {
const info = await lstat(file)
if (!info.isFile() || info.isSymbolicLink()) throw new Error(`Unsafe opencode-pty executable: ${file}`)
const uid = typeof process.getuid === "function" ? process.getuid() : undefined
if (uid !== undefined && info.uid !== uid)
throw new Error(`opencode-pty executable is owned by another user: ${file}`)
if (expected && sha256(await readFile(file)) !== expected)
throw new Error(`Cached opencode-pty checksum mismatch: ${file}`)
if (sha256(await readFile(file)) !== expected) throw new Error(`Cached opencode-pty checksum mismatch: ${file}`)
await chmod(file, 0o755)
return file
}
+4 -9
View File
@@ -91,16 +91,11 @@ const layer = Layer.effect(
yield* lock.withPermit(
Effect.gen(function* () {
const next = definitions.map((definition) => ({ id: definition.id, version: definition.version }))
const current = Array.from(active.values(), (entry) => ({
id: entry.plugin.id,
version: entry.plugin.version,
}))
if (
current.length === next.length &&
current.every((definition, index) => {
const candidate = next[index]
return definition.id === candidate?.id && definition.version === candidate.version
active.size === definitions.length &&
Array.from(active.values()).every((entry, index) => {
const definition = definitions[index]
return entry.plugin.id === definition?.id && entry.plugin.version === definition.version
})
) {
const nextInventory = [...Array.from(active.values(), (entry) => activeInfo(entry.plugin)), ...failures]
+2 -4
View File
@@ -58,14 +58,12 @@ export const Plugin = define({
description: prompt.description,
execute: (input) =>
Effect.gen(function* () {
const args = parseArguments(input.prompt.text)
const result = yield* mcp.prompt({
server: prompt.server,
name: prompt.name,
args: Object.fromEntries(
(prompt.arguments ?? []).map((argument, index) => [
argument.name,
parseArguments(input.prompt.text)[index] ?? "",
]),
(prompt.arguments ?? []).map((argument, index) => [argument.name, args[index] ?? ""]),
),
})
if (!result) return yield* Effect.fail(new Error(`MCP prompt not found: ${prompt.server}:${prompt.name}`))
+1 -1
View File
@@ -83,7 +83,7 @@ Use best judgement when processing input.
Use these to inform your review:
- **Explore agent** - Find how existing code handles similar problems. Check patterns, conventions, and prior art before claiming something doesn't fit.
- **Exa Code Context** - Verify correct usage of libraries/APIs before flagging something as wrong.
- **Available documentation and code-search tools** - Verify correct usage of libraries/APIs before flagging something as wrong.
- **Web Search** - Research best practices if you're unsure about a pattern.
If you're uncertain about something and can't verify it with these tools, say "I'm not sure about X" rather than flagging it as a definite issue.
+49
View File
@@ -0,0 +1,49 @@
export * as InstancePlugins from "./instance.js"
import type { Plugin } from "@opencode-ai/plugin/effect/plugin"
import { Context, Layer } from "effect"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import type { Versioned } from "../plugin.js"
/**
* Holds the plugins one instance is born with. Unlike the host-global
* `SdkPlugins` store, this list is a birth argument of a single instance:
* `Instance.layer` binds it through the replacement mechanism, so two
* instances in one process can carry different plugins. The list is immutable
* for the instance's lifetime; runtime dynamism lives inside plugins through
* the container transform/reload APIs.
*
* Limitations, both shared with `SdkPlugins`: `vcs` marker declarations are
* not seen by `ProjectMarkers` (it is global and runs during project
* resolution, before the instance exists), and config plugin operations may
* disable instance plugins by id.
*/
export type List = readonly Plugin[]
export interface Interface {
readonly all: () => readonly Versioned[]
}
export class Service extends Context.Service<Service, Interface>()("@opencode/InstancePlugins") {}
export const node = makeLocationNode({
service: Service,
layer: Layer.succeed(Service, Service.of({ all: () => [] })),
deps: [],
})
// The constant version is load-bearing: the plugin registry treats an
// unchanged (id, version) pair as the same plugin across activations, which
// is only correct because a bound list never changes after creation.
// `source: "sdk"` means host-contributed; an instance list is the
// per-instance form of the same channel.
export function bound(plugins: List) {
const duplicates = plugins.filter((plugin, index) => plugins.findIndex((other) => other.id === plugin.id) !== index)
if (duplicates.length > 0) {
throw new Error(`duplicate instance plugin ids: ${duplicates.map((plugin) => plugin.id).join(", ")}`)
}
const stamped = plugins.map(
(plugin): Versioned => ({ ...plugin, version: "instance", source: { type: "sdk" } }),
)
return Layer.succeed(Service, Service.of({ all: () => stamped }))
}
+263 -16
View File
@@ -1,11 +1,49 @@
import { Effect } from "effect"
import { readFile } from "node:fs/promises"
import { homedir } from "node:os"
import { join } from "node:path"
import { Clock, Effect, Schema, Semaphore, Stream } from "effect"
import { ChildProcess } from "effect/unstable/process"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { Form } from "@opencode-ai/schema/form"
import { AppProcess } from "@opencode-ai/util/process"
import { App } from "../../app.js"
import { Bus } from "../../bus.js"
import { Credential } from "../../credential.js"
import { Integration } from "../../integration.js"
import { Model } from "../../model.js"
import { Provider } from "../../provider.js"
import { iife } from "../../util/iife.js"
import { which } from "../../util/which.js"
import { configuredSettings } from "./configured.js"
const cognitiveScope = "https://cognitiveservices.azure.com/.default"
const foundryScope = "https://ai.azure.com/.default"
const methodID = Integration.MethodID.make("azure-cli")
const decodeJSON = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Unknown))
const decodeProfile = Schema.decodeUnknownEffect(
Schema.fromJsonString(Schema.Struct({ subscriptions: Schema.Array(Schema.Unknown) })),
)
const decodeToken = Schema.decodeUnknownEffect(
Schema.Struct({
accessToken: Schema.NonEmptyString,
expires_on: Schema.optional(Schema.Number),
expiresOn: Schema.optional(Schema.NonEmptyString),
}),
)
const decodeAccounts = Schema.decodeUnknownEffect(
Schema.Array(Schema.Struct({ name: Schema.NonEmptyString, resourceGroup: Schema.NonEmptyString })),
)
const Deployments = Schema.Array(
Schema.Struct({
name: Schema.NonEmptyString,
properties: Schema.Struct({
model: Schema.Struct({ name: Schema.NonEmptyString }),
provisioningState: Schema.NonEmptyString,
}),
}),
)
const decodeDeployments = Schema.decodeUnknownEffect(Deployments)
function selectLanguage(sdk: any, modelID: string, useChat: boolean) {
if (useChat && sdk.chat) return sdk.chat(modelID)
if (sdk.responses) return sdk.responses(modelID)
@@ -18,33 +56,185 @@ export const AzurePlugin = define({
id: "opencode.provider.azure",
effect: Effect.fn(function* (ctx) {
const configured = yield* configuredSettings(Provider.ID.azure)
const form = iife(() => {
if (resolveResourceName(configured) || typeof configured?.baseURL === "string") return
return Form.Fields.make([
{
type: "string",
key: "resourceName",
title: "Enter Azure Resource Name",
placeholder: "e.g. my-models",
required: true,
},
])
const processes = yield* AppProcess.Service
const bus = yield* Bus.Service
const tokens = new Map<string, { access: string; expires: number }>()
const loading = Semaphore.makeUnsafe(1)
const loaded: { resource?: string; deployments?: typeof Deployments.Type } = {}
const command = (args: string[]) =>
processes
.run(ChildProcess.make("az", args, { extendEnv: true, stdin: "ignore" }), { timeout: "10 seconds" })
.pipe(
Effect.flatMap(AppProcess.requireSuccess),
Effect.flatMap((result) => decodeJSON(result.stdout.toString("utf8"))),
)
const token = Effect.fn("AzurePlugin.token")(function* (scope: string) {
const now = yield* Clock.currentTimeMillis
const cached = tokens.get(scope)
if (cached && cached.expires - now > 5 * 60_000) return cached
const result = yield* command(["account", "get-access-token", "--scope", scope, "--output", "json"]).pipe(
Effect.flatMap(decodeToken),
)
const expires = result.expires_on !== undefined ? result.expires_on * 1000 : Date.parse(result.expiresOn ?? "")
if (!Number.isFinite(expires))
return yield* Effect.fail(new Error("Azure CLI returned an invalid token expiration"))
const refreshed = { access: result.accessToken, expires }
tokens.set(scope, refreshed)
return refreshed
})
const available = Boolean(which("az"))
// Installing Azure CLI does not mean the user has signed in. Avoid spawning it for unrelated CLI commands.
const signedIn = available
? yield* Effect.tryPromise(() =>
readFile(join(process.env.AZURE_CONFIG_DIR ?? join(homedir(), ".azure"), "azureProfile.json"), "utf8"),
).pipe(
Effect.flatMap((text) => decodeProfile(text.replace(/^\uFEFF/, ""))),
Effect.map((profile) => profile.subscriptions.length > 0),
Effect.orElseSucceed(() => false),
)
: false
const accounts =
!resolveResourceName(configured) &&
typeof configured?.baseURL !== "string" &&
!process.env.AZURE_RESOURCE_GROUP &&
signedIn
? yield* command(["cognitiveservices", "account", "list", "--output", "json", "--only-show-errors"]).pipe(
Effect.flatMap(decodeAccounts),
Effect.orElseSucceed(() => []),
)
: []
const form = (select = false) =>
iife(() => {
if (resolveResourceName(configured) || typeof configured?.baseURL === "string") return
return Form.Fields.make([
{
type: "string",
key: "resourceName",
title: "Enter Azure Resource Name",
placeholder: "e.g. my-models",
required: true,
...(select && accounts.length > 0
? {
options: accounts.map((account) => ({
value: account.name,
label: account.name,
description: account.resourceGroup,
})),
custom: true,
}
: {}),
},
])
})
yield* ctx.integration.transform((draft) => {
draft.method.update({
integrationID: Provider.ID.azure,
method: { type: "key", label: "API key", form: form() },
})
if (!available) return
draft.method.update({
integrationID: Provider.ID.azure,
method: {
type: "key",
label: "API key",
form,
id: methodID,
type: "oauth",
label: "Microsoft Entra ID (Azure CLI)",
form: form(true),
},
authorize: (answer) =>
Effect.succeed({
mode: "auto" as const,
url: "",
instructions: "Sign in with `az login` before continuing.",
callback: Effect.gen(function* () {
const resourceName =
typeof answer.resourceName === "string" ? answer.resourceName : resolveResourceName(configured)
if (!resourceName) return yield* Effect.fail(new Error("Azure resource name is required"))
const current = yield* token(cognitiveScope)
loaded.resource = resourceName
loaded.deployments = yield* discover(resourceName)
yield* ctx.catalog.reload()
return Credential.OAuth.make({
type: "oauth",
methodID,
access: current.access,
refresh: "azure-cli",
expires: current.expires,
metadata: { resourceName },
})
}),
}),
refresh: (credential) =>
token(cognitiveScope).pipe(
Effect.map((current) =>
Credential.OAuth.make({ ...credential, access: current.access, expires: current.expires }),
),
),
})
})
const discover = Effect.fn("AzurePlugin.discover")(function* (resource: string) {
return yield* Effect.gen(function* () {
const group = process.env.AZURE_RESOURCE_GROUP
const account = group
? { name: resource, resourceGroup: group }
: (accounts.length > 0
? accounts
: yield* command(["cognitiveservices", "account", "list", "--output", "json", "--only-show-errors"]).pipe(
Effect.flatMap(decodeAccounts),
)
).find((item) => item.name.toLowerCase() === resource.toLowerCase())
if (!account)
return yield* Effect.fail(new Error(`Azure resource "${resource}" was not found in the active subscription`))
return yield* command([
"cognitiveservices",
"account",
"deployment",
"list",
"--name",
account.name,
"--resource-group",
account.resourceGroup,
"--output",
"json",
"--only-show-errors",
]).pipe(Effect.flatMap(decodeDeployments))
}).pipe(
Effect.catch((error) =>
Effect.logWarning("Azure model discovery failed", {
resource,
error: error instanceof Error ? error.message : String(error),
}).pipe(Effect.as(undefined)),
),
)
})
const load = Effect.fn("AzurePlugin.load")(function* () {
const connection = yield* ctx.integration.connection.active(Provider.ID.azure)
const credential = connection
? yield* ctx.integration.connection.resolve(connection).pipe(Effect.orElseSucceed(() => undefined))
: undefined
if (credential?.type !== "oauth" || credential.methodID !== methodID) {
loaded.resource = undefined
loaded.deployments = undefined
return
}
const resource =
typeof credential.metadata?.resourceName === "string" ? credential.metadata.resourceName : undefined
loaded.resource = resource
loaded.deployments = resource ? yield* discover(resource) : undefined
})
yield* load()
yield* ctx.catalog.transform((evt) => {
for (const item of evt.provider.list()) {
if (item.provider.id !== Provider.ID.azure && Provider.packageName(item.provider.package) !== "@ai-sdk/azure")
continue
const resourceName = resolveResourceName(item.provider.settings)
const resourceName = resolveResourceName(item.provider.settings, loaded.resource)
if (resourceName)
evt.provider.update(item.provider.id, (provider) => {
provider.settings = {
@@ -55,6 +245,31 @@ export const AzurePlugin = define({
: {}),
}
})
if (item.provider.id === Provider.ID.azure && loaded.deployments) {
// Startup batches catalog transforms, so match against the current draft rather than a pre-startup snapshot.
const existing = Array.from(item.models.values())
const found = new Map<Model.ID, Model.Info>()
loaded.deployments.forEach((deployment) => {
if (deployment.properties.provisioningState !== "Succeeded") return
const model = existing.find(
(model) => model.id.toLowerCase() === deployment.properties.model.name.toLowerCase(),
)
if (!model) return
const id = found.has(model.id) ? Model.ID.make(deployment.name) : model.id
found.set(id, {
...model,
id,
name: id === model.id ? model.name : `${model.name} (${deployment.name})`,
modelID: Model.ID.make(deployment.name),
})
})
for (const id of Array.from(item.models.keys())) {
if (!found.has(Model.ID.make(id))) evt.model.remove(item.provider.id, id)
}
for (const [id, model] of found) {
evt.model.update(item.provider.id, id, (draft) => Object.assign(draft, structuredClone(model)))
}
}
for (const model of item.models.values()) {
evt.model.update(item.provider.id, model.id, (draft) => {
if (resourceName && typeof draft.settings?.baseURL === "string")
@@ -67,6 +282,38 @@ export const AzurePlugin = define({
}
}
})
const reload = () => loading.withPermit(load().pipe(Effect.andThen(ctx.catalog.reload())))
yield* bus.subscribe(Credential.Event.Switched).pipe(
Stream.filter((event) => event.data.integrationID === Integration.ID.make("azure")),
Stream.runForEach(reload),
Effect.forkScoped({ startImmediately: true }),
)
yield* ctx.session.hook(
"http.request",
(evt) =>
Effect.gen(function* () {
if (evt.model.providerID !== Provider.ID.azure) return
const connection = yield* ctx.integration.connection.active(Provider.ID.azure)
const credential = connection
? yield* ctx.integration.connection.resolve(connection).pipe(Effect.orElseSucceed(() => undefined))
: undefined
if (credential?.type !== "oauth" || credential.methodID !== methodID) return
const url = new URL(evt.request.url)
const scope =
url.hostname.endsWith(".services.ai.azure.com") && !url.pathname.startsWith("/models")
? foundryScope
: cognitiveScope
const current = yield* token(scope).pipe(Effect.orDie)
evt.request.headers.delete("api-key")
evt.request.headers.delete("x-api-key")
evt.request.headers.set("authorization", `Bearer ${current.access}`)
evt.request.headers.set("user-agent", App.useragent(ctx.app))
}),
{ providerID: Provider.ID.azure },
)
yield* ctx.aisdk.hook(
"sdk",
Effect.fn(function* (evt) {
+9 -3
View File
@@ -9,6 +9,7 @@ import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { Bus } from "../bus.js"
import { Npm } from "@opencode-ai/util/npm"
import { Plugin } from "../plugin.js"
import { InstancePlugins } from "./instance.js"
import { PluginInternal } from "./internal.js"
import { PluginModule } from "./module.js"
import { SdkPlugins } from "./sdk.js"
@@ -85,6 +86,7 @@ export const layer = Layer.effect(
Effect.gen(function* () {
const registry = yield* Plugin.Service
const sdk = yield* SdkPlugins.Service
const instance = yield* InstancePlugins.Service
const sources = yield* ConfigPluginSource.Service
const bus = yield* Bus.Service
const ready = yield* Latch.make()
@@ -93,10 +95,13 @@ export const layer = Layer.effect(
const activate = Effect.fn("PluginSupervisor.activate")(function* () {
// Resolve OpenCode's internal plugins with their privileged Location services.
const internal = yield* PluginInternal.list()
// Combine internal plugins with host-contributed SDK plugins in boot order.
// Combine internal plugins with host-contributed plugins in boot order.
// Instance-bound plugins come last: later activation can override earlier
// container writes, so the instance's explicit choices win over globals.
const pre = [
...internal.pre.map((plugin) => ({ ...plugin, version: "internal", source: { type: "builtin" as const } })),
...sdk.all(),
...instance.all(),
]
const post = internal.post.map((plugin) => ({
...plugin,
@@ -125,9 +130,9 @@ export const layer = Layer.effect(
Stream.debounce("100 millis"),
Stream.runForEach((target) =>
Effect.gen(function* () {
yield* activate()
yield* activate().pipe(Effect.catchCause((cause) => Effect.logError("failed to reload plugins", { cause })))
if (observed === target) yield* ready.open
}).pipe(Effect.catchCause((cause) => Effect.logError("failed to reload plugins", { cause }))),
}),
),
Effect.forkScoped({ startImmediately: true }),
)
@@ -138,6 +143,7 @@ export const layer = Layer.effect(
const nodeDeps = [
Plugin.node,
SdkPlugins.node,
InstancePlugins.node,
ConfigPluginSource.node,
Bus.node,
Npm.node,
@@ -49,7 +49,7 @@ Use the instructions below and the tools available to assist the user.
# Tool Use - OpenCode Specifics
- When `webfetch` returns a message about a redirect to a different host, you should immediately make a new `webfetch` request with the redirect URL provided in the response.
- When `plan` mode is active, you will see a <system-reminder> about this. `plan` mode is for planning, not editing. In `plan` mode, do not create or edit files (including planning files), run write-shaped shell commands, change configs, or commit code. If the user is asking you to perform edit operations in `plan` mode, inform them that `plan` mode is active and that they need to switch to build mode.
- When `plan` mode is active, you will see a <system-reminder> about this. Follow that reminder for the files you may edit and all other Plan mode restrictions. If the user asks you to implement changes, inform them that `plan` mode is active and that they need to switch to build mode.
# Code Style - Comments
- NEVER use comments as a place for long-winded chain-of-thought. Long thinking texts must be generated as private reasoning. Comments in code must be appropriately concise.
+7 -18
View File
@@ -109,25 +109,14 @@ function make(
return []
}),
status: Effect.fn("VcsHg.status")(function* () {
const [items, batch] = yield* Effect.all(
// Zero-context patches are enough to count changed lines.
[hg.status(undefined, scope), hg.diff(undefined, scope, { context: 0 })],
{ concurrency: 2 },
)
const chunks = chunksByFile(batch, () => undefined)
return yield* Effect.forEach(
items.toSorted((a, b) => a.file.localeCompare(b.file)),
return (yield* diffAgainst(undefined, { context: 0 })).map(
(item) =>
Effect.gen(function* () {
const patch = yield* patchFor(item, undefined, chunks.get(item.file))
const counts = countPatch(patch)
return {
file: item.file,
additions: counts.additions,
deletions: counts.deletions,
status: item.status,
} satisfies FileStatus
}),
({
file: item.file,
additions: item.additions,
deletions: item.deletions,
status: item.status,
}) satisfies FileStatus,
)
}),
diff: Effect.fn("VcsHg.diff")(function* (mode: Mode, options?: DiffOptions) {
+10 -3
View File
@@ -41,6 +41,7 @@ export interface Resolved {
readonly previous?: ID
readonly id: ID
readonly directory: AbsolutePath
// This checkout's main directory; the stored project canonical may be another clone.
readonly canonical: AbsolutePath
readonly vcs?: Vcs
readonly vcsBackend?: string
@@ -110,11 +111,17 @@ const layer = Layer.effect(
.get()
.pipe(Effect.orDie)
yield* upsertProject(db, project).pipe(Effect.orDie)
if (previous && previous.canonical !== project.canonical) {
// Clones share a project ID; only replace a canonical directory that is gone.
if (
previous &&
previous.canonical !== project.canonical &&
!(yield* fs.exists(previous.canonical).pipe(Effect.orElseSucceed(() => true)))
) {
const row = yield* db
.select()
.from(ProjectTable)
.update(ProjectTable)
.set({ worktree: project.canonical })
.where(eq(ProjectTable.id, project.id))
.returning()
.get()
.pipe(Effect.orDie)
if (row) yield* bus.publish(ProjectSchema.Event.Updated, fromRow(row))
+2 -5
View File
@@ -51,11 +51,8 @@ export function upsertProject(
.values({ id: project.id, worktree: project.canonical, vcs, sandboxes: [] })
.onConflictDoUpdate({
target: ProjectTable.id,
set: { worktree: project.canonical, vcs: vcs ?? null },
setWhere: or(
ne(ProjectTable.worktree, project.canonical),
vcs ? or(isNull(ProjectTable.vcs), ne(ProjectTable.vcs, vcs)) : isNotNull(ProjectTable.vcs),
),
set: { vcs: vcs ?? null },
setWhere: vcs ? or(isNull(ProjectTable.vcs), ne(ProjectTable.vcs, vcs)) : isNotNull(ProjectTable.vcs),
})
.run()
}
+7 -4
View File
@@ -1,14 +1,15 @@
import { createRequire } from "node:module"
import { isSea } from "node:sea"
import type { spawn } from "@lydell/node-pty"
import type { Opts, Proc } from "./pty.js"
export type { Disp, Exit, Opts, Proc } from "./pty.js"
const pty = createRequire(import.meta.url)(
process.env.OPENCODE_NODE_PTY_PATH ?? "@lydell/node-pty",
) as typeof import("@lydell/node-pty")
const pty = createRequire(import.meta.url)(process.env.OPENCODE_NODE_PTY_PATH ?? "@lydell/node-pty") as {
spawn: typeof spawn
}
export function spawn(file: string, args: string[], opts: Opts): Proc {
function spawnPty(file: string, args: string[], opts: Opts): Proc {
const proc = pty.spawn(file, args, process.platform === "win32" && isSea() ? { ...opts, useConptyDll: true } : opts)
return {
pid: proc.pid,
@@ -29,3 +30,5 @@ export function spawn(file: string, args: string[], opts: Opts): Proc {
},
}
}
export { spawnPty as spawn }
+1 -1
View File
@@ -1,6 +1,6 @@
import path from "path"
import { Context, Effect, Layer, Stream } from "effect"
import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http"
import { HttpClient, HttpClientRequest } from "effect/unstable/http"
import { ChildProcess } from "effect/unstable/process"
import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"
import { CrossSpawnSpawner } from "@opencode-ai/util/cross-spawn-spawner"
+1 -1
View File
@@ -41,7 +41,7 @@ export type DeepMutable<T> = T extends string | number | boolean | bigint | symb
/**
* Nominal wrapper for scalar types. The class itself is a valid schema
* pass it directly to `Schema.decode`, `Schema.decodeEffect`, etc.
* pass it directly to `Schema.decodeUnknownSync`, `Schema.decodeEffect`, etc.
*
* The runtime value remains an unwrapped primitive. `Schema.brand` supplies
* the primitive schema behavior and constructor validation, while the class
+5 -1
View File
@@ -98,6 +98,7 @@ type CreateBaseInput = {
title?: string
agent?: Agent.ID
model?: Model.Ref
metadata?: SessionSchema.Metadata
}
type CreateInput = CreateBaseInput &
({ location: Location.Ref; parentID?: never } | { parentID: SessionSchema.ID; location?: never })
@@ -409,6 +410,9 @@ const layer = Layer.effect(
subpath: RelativePath.make(path.relative(project.directory, location.directory).replaceAll("\\", "/")),
title: input.title,
agent: input.agent,
// Children inherit metadata the way they inherit location, so
// host policies that read it treat the family uniformly.
metadata: input.metadata ?? parent?.metadata,
model: input.model
? {
id: Model.ID.make(input.model.id),
@@ -1209,7 +1213,7 @@ const SHELL_MAX_CAPTURE_BYTES = 1024 * 1024
export const node = makeGlobalNode({
service: Service,
layer: layer.pipe(Layer.orDie),
layer,
deps: [
Job.node,
SessionEnvironment.node,
+14 -11
View File
@@ -98,6 +98,7 @@ export const layer = (options?: Options) =>
const recoverShell = Effect.fnUntraced(function* (
background: Job.Background,
recovery: Extract<Job.Recovery, { kind: "shell" }>,
suspended: ReadonlySet<SessionSchema.ID>,
) {
const state = background.status === "running" ? "cancelled" : background.status
const text =
@@ -121,7 +122,7 @@ export const layer = (options?: Options) =>
shellID: recovery.shellID,
state,
},
resume: false,
...(suspended.has(recovery.sessionID) ? { resume: false } : {}),
})
.pipe(
Effect.catchTag("Session.NotFoundError", () => Effect.void),
@@ -211,23 +212,25 @@ export const layer = (options?: Options) =>
return Service.of({
resumeSuspendedSessions: Effect.gen(function* () {
const active = yield* execution.active
// Early notices wait for root recovery's accounting, including roots that exhaust their budget.
const suspended = new Set((yield* store.listSuspended()).filter((sessionID) => !active.has(sessionID)))
const pending = yield* jobs.pendingBackground
yield* store.releaseChildClaims(
pending.flatMap((background) =>
background.status === "running" && background.recovery.kind === "subagent"
? [background.recovery.childSessionID]
: [],
),
const children = pending.flatMap((background) =>
background.status === "running" && background.recovery.kind === "subagent"
? [background.recovery.childSessionID]
: [],
)
// Early notices wait for recovery's accounting, including Sessions that exhaust their budget.
const suspended = new Set(
[...(yield* store.listSuspended()), ...children].filter((sessionID) => !active.has(sessionID)),
)
yield* store.releaseChildClaims(children)
yield* Effect.forEach(
pending,
// Admit shell outcomes before a recovered child can start its first model request.
pending.toSorted((a, b) => Number(a.recovery.kind === "subagent") - Number(b.recovery.kind === "subagent")),
Effect.fnUntraced(function* (background) {
if ((yield* jobs.get(background.id))?.status === "running") return
const recovery = background.recovery
yield* recovery.kind === "shell"
? recoverShell(background, recovery)
? recoverShell(background, recovery, suspended)
: recoverSubagent(background, recovery, suspended)
}),
{ discard: true },
+1
View File
@@ -49,6 +49,7 @@ export function fromRow(row: typeof SessionTable.$inferSelect): SessionSchema.In
workspaceID: row.workspace_id ? Workspace.ID.make(row.workspace_id) : undefined,
}),
subpath: row.path ? RelativePath.make(row.path) : undefined,
metadata: row.metadata ?? undefined,
revert: row.revert ? decodeRevert(row.revert) : undefined,
outcome: row.idle_outcome ?? undefined,
time: {
+6 -12
View File
@@ -41,7 +41,6 @@ interface Channel {
readonly openedAt: number
active?: Active
closing: boolean
poisoned: boolean
checkpoint?: ChannelCheckpoint
pending?: { readonly token: object; readonly checkpoint: ChannelCheckpoint }
reader?: Fiber.Fiber<unknown, unknown>
@@ -150,7 +149,6 @@ export const makeLayer = (connector: WebSocketConnector) =>
channel: Channel,
error: AIError,
) {
channel.poisoned = true
if (owner.channel === channel) owner.channel = undefined
if (channel.closing) return
channel.closing = true
@@ -187,7 +185,6 @@ export const makeLayer = (connector: WebSocketConnector) =>
connection,
openedAt: yield* Clock.currentTimeMillis,
closing: false,
poisoned: false,
}
owner.channel = channel
channel.reader = yield* connection.messages.pipe(
@@ -274,13 +271,11 @@ export const makeLayer = (connector: WebSocketConnector) =>
const current = owner.channel
const rotateAfterMs = exchange.connect.rotateAfterMs ?? ROTATE_AFTER_MS
const rotation = current
? current.poisoned
? "poisoned"
: current.affinity !== key
? "affinity"
: now - current.openedAt >= rotateAfterMs
? "age"
: undefined
? current.affinity !== key
? "affinity"
: now - current.openedAt >= rotateAfterMs
? "age"
: undefined
: undefined
if (current && rotation) {
yield* Effect.logDebug("session websocket rotating", {
@@ -361,7 +356,6 @@ export const makeLayer = (connector: WebSocketConnector) =>
let terminal: ChannelObservation | undefined
const token = {}
let staged: ChannelCheckpoint | undefined
const frames = Stream.fromQueue(active.queue).pipe(
Stream.timeoutOrElse({
duration: IDLE_TIMEOUT,
@@ -382,7 +376,7 @@ export const makeLayer = (connector: WebSocketConnector) =>
if (!observationTerminal(observation)) return
terminal = observation
lifecycle.delivery = "terminal"
staged = observation.type === "completed" ? observation.checkpoint : undefined
const staged = observation.type === "completed" ? observation.checkpoint : undefined
if (staged) channel.pending = { token, checkpoint: staged }
if (observation.type !== "completed" || !staged) channel.checkpoint = undefined
}),
+2
View File
@@ -157,6 +157,7 @@ const projectFork = Effect.fn("SessionProjector.projectFork")(function* (
title: forkTitle(parent.title ?? undefined),
agent: parent.agent,
model: parent.model,
metadata: parent.metadata,
version: parent.version,
cost: 0,
tokens_input: 0,
@@ -446,6 +447,7 @@ const layer = Layer.effectDiscard(
title: event.data.title,
agent: event.data.agent,
model: event.data.model,
metadata: event.data.metadata,
version: event.data.version,
time_created: event.created,
time_updated: event.created,
@@ -378,7 +378,7 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
(error: SessionError.Error, scope: "hosted" | "all" = "all") => failTools(error, scope),
)
const publish = Effect.fn("SessionRunner.publishLLMEvent")(function* (event: LLMEvent) {
const publish = Effect.fnUntraced(function* (event: LLMEvent) {
switch (event.type) {
case "step-start":
yield* startAssistant()
@@ -535,6 +535,8 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
}
})
const publishTraced = Effect.fn("SessionRunner.publishLLMEvent")(publish)
const progress = Effect.fnUntraced(function* (id: string, update: Tool.Metadata) {
const tool = tools.get(id)
if (!tool?.called || tool.settled) return yield* Effect.die(new Error(`Tool progress outside running call: ${id}`))
@@ -573,7 +575,11 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
})
return {
publish,
publish: (event: LLMEvent) => {
if (event.type === "text-delta" || event.type === "reasoning-delta" || event.type === "tool-input-delta")
return publish(event)
return publishTraced(event)
},
progress,
toolExecution,
flush,
+6 -1
View File
@@ -40,9 +40,14 @@ export function isRetryable(error: AIError) {
}
}
/** Bound provider-requested delays so a hostile or buggy retry-after cannot stall a session for hours. */
const RETRY_AFTER_MAX = Duration.toMillis("15 minutes")
const retryAfter = (input: Input) => {
if (input.cause.reason._tag === "RateLimit" || input.cause.reason._tag === "ProviderInternal")
return input.cause.reason.retryAfterMs
return input.cause.reason.retryAfterMs === undefined
? undefined
: Math.min(input.cause.reason.retryAfterMs, RETRY_AFTER_MAX)
return undefined
}
+1 -1
View File
@@ -41,7 +41,7 @@ export const SessionTable = sqliteTable(
summary_deletions: integer(),
summary_files: integer(),
summary_diffs: text({ mode: "json" }).$type<FileDiff.LegacyInfo[]>(),
metadata: text({ mode: "json" }).$type<Record<string, unknown>>(),
metadata: text({ mode: "json" }).$type<Session.Metadata>(),
cost: real().notNull().default(0),
tokens_input: integer().notNull().default(0),
tokens_output: integer().notNull().default(0),
+5
View File
@@ -100,6 +100,7 @@ const layer = Layer.effect(
title: input.data.info.title,
agent: input.data.info.agent,
model: input.data.info.model,
metadata: input.data.info.metadata,
},
{
location: input.location,
@@ -178,6 +179,10 @@ function sanitize(data: Data): Data {
info: {
...data.info,
title: data.info.title === undefined ? undefined : redact("session-title", data.info.id, data.info.title),
metadata:
data.info.metadata && Object.keys(data.info.metadata).length > 0
? { redacted: `session-metadata:${data.info.id}` }
: data.info.metadata,
location: {
...data.info.location,
directory: AbsolutePath.make(`/${redact("session-directory", data.info.id, data.info.location.directory)}`),
+4 -13
View File
@@ -2,7 +2,7 @@ export * as SkillDiscovery from "./discovery.js"
import path from "path"
import { Context, Effect, Layer, Schedule, Schema } from "effect"
import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Global } from "@opencode-ai/util/global"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
@@ -37,15 +37,7 @@ function isSafeRelativePath(value: string) {
!path.win32.isAbsolute(value) &&
segments.every((segment) => {
try {
const decoded = decodeURIComponent(segment)
return (
decoded.length > 0 &&
decoded !== "." &&
decoded !== ".." &&
!decoded.includes("/") &&
!decoded.includes("\\") &&
!decoded.includes("\0")
)
return isSafeSegment(decodeURIComponent(segment))
} catch {
return false
}
@@ -146,10 +138,9 @@ const layer = Layer.effect(
file,
}
})
if (files.some((file) => file === undefined)) {
if (!files.every((file): file is { url: string; destination: string; file: string } => file !== undefined))
return []
}
return [{ skill, root, versionFile, files: files as { url: string; destination: string; file: string }[] }]
return [{ skill, root, versionFile, files }]
}),
({ skill, root, versionFile, files }) =>
Effect.gen(function* () {
+20 -2
View File
@@ -75,6 +75,12 @@ export interface Options<State, DraftApi> {
export interface Interface<State, DraftApi> extends Transformable<DraftApi> {
readonly get: () => State
/**
* Materializes registrations and disposals deferred by an active batch so
* subsequent reads observe them. No-op when nothing is pending; never waits
* out the reload debounce.
*/
readonly flush: () => Effect.Effect<void>
}
export function create<State, DraftApi>(options: Options<State, DraftApi>): Interface<State, DraftApi> {
@@ -84,6 +90,7 @@ export function create<State, DraftApi>(options: Options<State, DraftApi>): Inte
let requestedAt = 0
let running = false
let closed = false
let dirty = false
let waiters: { generation: number; done: Deferred.Deferred<void> }[] = []
const semaphore = Semaphore.makeUnsafe(1)
@@ -93,6 +100,7 @@ export function create<State, DraftApi>(options: Options<State, DraftApi>): Inte
})
const materialize = Effect.fnUntraced(function* () {
dirty = false
if (closed) return
const next = options.initial()
const api = options.draft(next)
@@ -162,6 +170,7 @@ export function create<State, DraftApi>(options: Options<State, DraftApi>): Inte
closed = true
return
}
dirty = true
batch.reloads.add(materializeReload)
return
}
@@ -177,12 +186,21 @@ export function create<State, DraftApi>(options: Options<State, DraftApi>): Inte
)
yield* Scope.addFinalizer(scope, dispose)
const batch = yield* CurrentBatch
if (batch?.active) batch.reloads.add(materializeReload)
else yield* materializeReload()
if (batch?.active) {
dirty = true
batch.reloads.add(materializeReload)
} else yield* materializeReload()
return { dispose }
}),
)
}),
reload,
flush: Effect.fnUntraced(function* () {
if (!dirty) return
// Flushing from inside the deferring batch replaces its queued rebuild.
const batch = yield* CurrentBatch
batch?.reloads.delete(materializeReload)
yield* materializeReload()
}),
}
}
+2 -10
View File
@@ -15,7 +15,7 @@ import { PluginHooks } from "./plugin/hooks.js"
import { SessionMessage } from "./session/message.js"
import { SessionSchema } from "./session/schema.js"
import { State } from "./state.js"
import { definition, execute, normalizeContent } from "./tool/runtime.js"
import { definition, effectiveName, execute, normalizedName, normalizeContent } from "./tool/runtime.js"
import { Wildcard } from "./util/wildcard.js"
export class RegistrationError extends Schema.TaggedError<RegistrationError>()("Tool.RegistrationError", {
@@ -211,8 +211,7 @@ const layer = Layer.effect(
}
const direct = new Map(Array.from(active).filter(([, tool]) => tool.options?.codemode === false))
const codemode = new Map(Array.from(active).filter(([, tool]) => tool.options?.codemode !== false))
const executeRule = rules.findLast((rule) => Wildcard.match("execute", rule.action))
const codemodeEnabled = executeRule?.resource !== "*" || executeRule.effect !== "deny"
const codemodeEnabled = !whollyDisabled("execute", rules)
const codemodeTool = codemodeEnabled
? CodeModeTool.create(codemode, (name, tool, input, context) =>
beforeExecute(name, input, context).pipe(
@@ -285,13 +284,6 @@ function registrationError(tool: Tool.Info) {
return Result.isFailure(result) ? result.failure : undefined
}
const normalizedName = (tool: Tool.Info) => tool.name.replace(/[^a-zA-Z0-9_-]/g, "_")
const effectiveName = (tool: Tool.Info) =>
tool.options?.namespace === undefined
? normalizedName(tool)
: `${tool.options.namespace.replaceAll(".", "_")}_${normalizedName(tool)}`
export const node = makeLocationNode({
service: Service,
layer,
+4 -2
View File
@@ -1,6 +1,5 @@
export * as ShellTool from "./shell.js"
import path from "path"
import { ToolFailure } from "@opencode-ai/ai"
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
import { Deferred, Effect, Schema, Scope } from "effect"
@@ -190,7 +189,10 @@ export const Plugin = {
portable,
})
const directories = yield* Effect.forEach(parsed.directories, (directory) =>
mutation.resolve({ path: path.resolve(target.absolute, directory), kind: "directory" }),
mutation.resolve({
path: LocationMutation.resolvePath(target.absolute, directory),
kind: "directory",
}),
)
const external = [target, ...directories]
.map((item) => item.externalDirectory)

Some files were not shown because too many files have changed in this diff Show More