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
Kit Langton 60d5f83ffd test(ai): reuse executor HTTP fixtures (#45468) 2026-08-27 16:12:48 -04:00
Kit Langton 1455995ac7 refactor(core): use word casing for MCP namespaces (#45618) 2026-08-27 16:11:50 -04:00
Kit Langton 52c04508a2 test(core): share controlled websearch test layer (#45465) 2026-08-27 16:09:06 -04:00
Kit Langton 5a67fcc17e test(httpapi-codegen): share emitted-module fixtures (#45463) 2026-08-27 15:53:46 -04:00
Kit Langton 73b575468e test(server): simplify scoped endpoint fixtures (#45466) 2026-08-27 15:53:30 -04:00
242 changed files with 11690 additions and 8892 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": major
---
Treat MCP as a word in current Core namespace exports: rename `MCP` to `Mcp`, `MCPClient` to `McpClient`, `MCPStdio` to `McpStdio`, `MCPOAuth` to `McpOAuth`, `ConfigMCPPlugin` to `ConfigMcpPlugin`, and `MCPCodeModeExclusionPlugin` to `McpCodeModeExclusionPlugin`. Direct consumers must update their imports. Module paths, Schema contracts, runtime service keys, error tags, and behavior are unchanged.
+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* () {
+114 -167
View File
@@ -1,10 +1,10 @@
import { describe, expect } from "bun:test"
import { Deferred, Effect, Fiber, Layer, Ref, Stream } from "effect"
import { Headers, HttpClient, HttpClientError, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { Deferred, Effect, Fiber, Ref, Stream } from "effect"
import { Headers, HttpClientError, HttpClientRequest } from "effect/unstable/http"
import { LLM, AIError, HttpContext, InvalidProviderOutputError, TransportError } from "../src/index.js"
import { LLMClient, RequestExecutor, WebSocketTransport, type WebSocketChannelExecutor } from "../src/route.js"
import * as OpenAIChat from "../src/protocols/openai-chat.js"
import * as OpenAI from "../src/providers/openai.js"
import { route } from "../src/protocols/openai-chat.js"
import { configure } from "../src/providers/openai.js"
import { dynamicResponse, fixedResponse, systemError } from "./lib/http.js"
import { deltaChunk } from "./lib/openai-chunks.js"
import { sseEvents, sseRaw } from "./lib/sse.js"
@@ -18,47 +18,6 @@ const secretRequest = HttpClientRequest.post("https://provider.test/v1/chat?api_
HttpClientRequest.setHeaders(Headers.fromInput({ authorization: "Bearer header-secret-456" })),
)
const responsesLayer = (responses: ReadonlyArray<Response>) =>
RequestExecutor.layer.pipe(
Layer.provide(
Layer.unwrap(
Effect.gen(function* () {
const cursor = yield* Ref.make(0)
return Layer.succeed(
HttpClient.HttpClient,
HttpClient.make((request) =>
Effect.gen(function* () {
const index = yield* Ref.getAndUpdate(cursor, (value) => value + 1)
return HttpClientResponse.fromWeb(request, responses[index] ?? responses[responses.length - 1])
}),
),
)
}),
),
),
)
const countedResponsesLayer = (attempts: Ref.Ref<number>, responses: ReadonlyArray<Response>) =>
RequestExecutor.layer.pipe(
Layer.provide(
Layer.unwrap(
Effect.gen(function* () {
const cursor = yield* Ref.make(0)
return Layer.succeed(
HttpClient.HttpClient,
HttpClient.make((request) =>
Effect.gen(function* () {
yield* Ref.update(attempts, (value) => value + 1)
const index = yield* Ref.getAndUpdate(cursor, (value) => value + 1)
return HttpClientResponse.fromWeb(request, responses[index] ?? responses[responses.length - 1])
}),
),
)
}),
),
),
)
const expectAIError = (error: unknown) => {
expect(error).toBeInstanceOf(AIError)
if (!(error instanceof AIError)) throw new Error("expected AIError")
@@ -107,19 +66,17 @@ describe("RequestExecutor", () => {
return yield* executor.execute(request).pipe(Effect.flip)
}).pipe(
Effect.provide(
responsesLayer([
new Response(
new ReadableStream({
start(controller) {
controller.error(cause)
},
}),
{
status: 503,
headers: { "x-request-id": "req_failed_body" },
fixedResponse(
new ReadableStream({
start(controller) {
controller.error(cause)
},
),
]),
}),
{
status: 503,
headers: { "x-request-id": "req_failed_body" },
},
),
),
)
@@ -148,15 +105,14 @@ describe("RequestExecutor", () => {
})
}).pipe(
Effect.provide(
responsesLayer([
new Response(
new ReadableStream({
start(controller) {
controller.error(systemError("ECONNRESET", "disconnected query-secret-123 header-secret-456"))
},
}),
),
]),
fixedResponse(
new ReadableStream({
start(controller) {
controller.error(systemError("ECONNRESET", "disconnected query-secret-123 header-secret-456"))
},
}),
{},
),
),
),
)
@@ -176,15 +132,14 @@ describe("RequestExecutor", () => {
})
}).pipe(
Effect.provide(
responsesLayer([
new Response(
new ReadableStream({
pull(controller) {
controller.error(new TypeError("fetch failed", { cause: systemError("ECONNRESET", "socket closed") }))
},
}),
),
]),
fixedResponse(
new ReadableStream({
pull(controller) {
controller.error(new TypeError("fetch failed", { cause: systemError("ECONNRESET", "socket closed") }))
},
}),
{},
),
),
),
)
@@ -200,7 +155,7 @@ describe("RequestExecutor", () => {
expect(error.message).toBe("plugin rejected request")
expect(error.reason.cause).toBeInstanceOf(Error)
expect(error.reason.http).toBeUndefined()
}).pipe(Effect.provide(responsesLayer([]))),
}).pipe(Effect.provide(dynamicResponse(() => Effect.die(new Error("unexpected HTTP request"))))),
)
it.effect("reports the request sent by middleware", () =>
@@ -249,11 +204,9 @@ describe("RequestExecutor", () => {
expect(error.reason).toMatchObject({ _tag: "InvalidRequest", classification: "context-overflow" })
}).pipe(
Effect.provide(
responsesLayer([
new Response('{"error":{"code":"context_length_exceeded","message":"prompt too long"}}', {
status: 400,
}),
]),
fixedResponse('{"error":{"code":"context_length_exceeded","message":"prompt too long"}}', {
status: 400,
}),
),
),
)
@@ -269,7 +222,7 @@ describe("RequestExecutor", () => {
classification: "payload-too-large",
})
expect(error.reason.http?.status).toBe(413)
}).pipe(Effect.provide(responsesLayer([new Response("request too large", { status: 413 })]))),
}).pipe(Effect.provide(fixedResponse("request too large", { status: 413 }))),
)
it.effect("classifies Anthropic request_too_large as context overflow", () =>
@@ -285,11 +238,9 @@ describe("RequestExecutor", () => {
expect(error.reason.http?.status).toBe(413)
}).pipe(
Effect.provide(
responsesLayer([
new Response('{"error":{"type":"request_too_large","message":"Request exceeds the maximum size"}}', {
status: 413,
}),
]),
fixedResponse('{"error":{"type":"request_too_large","message":"Request exceeds the maximum size"}}', {
status: 413,
}),
),
),
)
@@ -303,7 +254,7 @@ describe("RequestExecutor", () => {
expect(error.reason).toMatchObject({ _tag: "InvalidRequest" })
expect("classification" in error.reason ? error.reason.classification : undefined).toBeUndefined()
expect(error.message).toBe("Provider request failed with HTTP 400")
}).pipe(Effect.provide(responsesLayer([new Response("invalid parameter", { status: 400 })]))),
}).pipe(Effect.provide(fixedResponse("invalid parameter", { status: 400 }))),
)
it.effect("preserves structured provider messages from large error bodies", () =>
@@ -317,15 +268,13 @@ describe("RequestExecutor", () => {
expect(error.reason.body).toContain(largeProviderMessage)
}).pipe(
Effect.provide(
responsesLayer([
new Response(
JSON.stringify({
model: "gpt-5.6-sol",
error: { type: "invalid_request", message: largeProviderMessage },
}),
{ status: 400 },
),
]),
fixedResponse(
JSON.stringify({
model: "test-model",
error: { type: "invalid_request", message: largeProviderMessage },
}),
{ status: 400 },
),
),
),
)
@@ -340,7 +289,7 @@ describe("RequestExecutor", () => {
_tag: "InvalidRequest",
})
expect(error.message).toBe("Provider request failed with HTTP 400")
}).pipe(Effect.provide(responsesLayer([new Response('{"error":{"message":" "}}', { status: 400 })]))),
}).pipe(Effect.provide(fixedResponse('{"error":{"message":" "}}', { status: 400 }))),
)
it.effect("classifies provider rate limits hidden behind HTTP 400", () =>
@@ -352,7 +301,7 @@ describe("RequestExecutor", () => {
expectAIError(error)
expect(error.reason).toMatchObject({ _tag: "RateLimit" })
}).pipe(Effect.provide(responsesLayer([new Response(body, { status: 400 })])))
}).pipe(Effect.provide(fixedResponse(body, { status: 400 })))
yield* classify("Request rate increased too quickly")
yield* classify('{"type":"error","error":{"type":"too_many_requests"}}')
@@ -369,7 +318,7 @@ describe("RequestExecutor", () => {
expectAIError(error)
expect(error.reason).toMatchObject({ _tag: "ProviderInternal" })
}).pipe(Effect.provide(responsesLayer([new Response(body, { status: 400 })])))
}).pipe(Effect.provide(fixedResponse(body, { status: 400 })))
yield* classify('{"code":"resource_exhausted"}')
yield* classify('{"code":"service_unavailable"}')
@@ -399,12 +348,10 @@ describe("RequestExecutor", () => {
expect(error.reason.body).toBe("rate limited")
}).pipe(
Effect.provide(
responsesLayer([
new Response("rate limited", {
status: 429,
headers: { "retry-after-ms": "0", "x-request-id": "req_123", "x-api-key": "secret" },
}),
]),
fixedResponse("rate limited", {
status: 429,
headers: { "retry-after-ms": "0", "x-request-id": "req_123", "x-api-key": "secret" },
}),
),
),
)
@@ -417,7 +364,7 @@ describe("RequestExecutor", () => {
expectAIError(error)
expect(error.reason.http?.headers["x-safe"]).toBe("response-secret")
}).pipe(
Effect.provide(responsesLayer([new Response("bad", { status: 400, headers: { "x-safe": "response-secret" } })])),
Effect.provide(fixedResponse("bad", { status: 400, headers: { "x-safe": "response-secret" } })),
Effect.provideService(Headers.CurrentRedactedNames, ["x-safe"]),
),
)
@@ -437,20 +384,18 @@ describe("RequestExecutor", () => {
})
}).pipe(
Effect.provide(
responsesLayer([
new Response("rate limited", {
status: 429,
headers: {
"retry-after-ms": "0",
"x-ratelimit-limit-requests": "500",
"x-ratelimit-limit-tokens": "30000",
"x-ratelimit-remaining-requests": "499",
"x-ratelimit-remaining-tokens": "29900",
"x-ratelimit-reset-requests": "1s",
"x-ratelimit-reset-tokens": "10s",
},
}),
]),
fixedResponse("rate limited", {
status: 429,
headers: {
"retry-after-ms": "0",
"x-ratelimit-limit-requests": "500",
"x-ratelimit-limit-tokens": "30000",
"x-ratelimit-remaining-requests": "499",
"x-ratelimit-remaining-tokens": "29900",
"x-ratelimit-reset-requests": "1s",
"x-ratelimit-reset-tokens": "10s",
},
}),
),
),
)
@@ -470,20 +415,18 @@ describe("RequestExecutor", () => {
})
}).pipe(
Effect.provide(
responsesLayer([
new Response("rate limited", {
status: 429,
headers: {
"retry-after-ms": "0",
"anthropic-ratelimit-requests-limit": "100",
"anthropic-ratelimit-requests-remaining": "12",
"anthropic-ratelimit-requests-reset": "2026-05-06T12:00:00Z",
"anthropic-ratelimit-input-tokens-limit": "10000",
"anthropic-ratelimit-input-tokens-remaining": "9000",
"anthropic-ratelimit-input-tokens-reset": "2026-05-06T12:00:10Z",
},
}),
]),
fixedResponse("rate limited", {
status: 429,
headers: {
"retry-after-ms": "0",
"anthropic-ratelimit-requests-limit": "100",
"anthropic-ratelimit-requests-remaining": "12",
"anthropic-ratelimit-requests-reset": "2026-05-06T12:00:00Z",
"anthropic-ratelimit-input-tokens-limit": "10000",
"anthropic-ratelimit-input-tokens-remaining": "9000",
"anthropic-ratelimit-input-tokens-reset": "2026-05-06T12:00:10Z",
},
}),
),
),
)
@@ -496,10 +439,14 @@ describe("RequestExecutor", () => {
return yield* executor.execute(request).pipe(Effect.flip)
}).pipe(
Effect.provide(
countedResponsesLayer(attempts, [
new Response("busy", { status: 503, headers: { "retry-after-ms": "0" } }),
new Response("ok", { status: 200 }),
]),
dynamicResponse((input) =>
Effect.gen(function* () {
const attempt = yield* Ref.getAndUpdate(attempts, (value) => value + 1)
return attempt === 0
? input.respond("busy", { status: 503, headers: { "retry-after-ms": "0" } })
: input.respond("ok", { status: 200 })
}),
),
),
)
@@ -522,12 +469,10 @@ describe("RequestExecutor", () => {
expect(error.reason.http?.status).toBe(status)
}).pipe(
Effect.provide(
responsesLayer([
new Response("provider failure", {
status,
headers: { "retry-after-ms": "0" },
}),
]),
fixedResponse("provider failure", {
status,
headers: { "retry-after-ms": "0" },
}),
),
)
@@ -538,20 +483,28 @@ describe("RequestExecutor", () => {
it.effect("preserves large authentication error bodies", () =>
Effect.gen(function* () {
const executor = yield* RequestExecutor.Service
const error = yield* executor.execute(request).pipe(Effect.flip)
const attempts = yield* Ref.make(0)
const error = yield* Effect.gen(function* () {
const executor = yield* RequestExecutor.Service
return yield* executor.execute(request).pipe(Effect.flip)
}).pipe(
Effect.provide(
dynamicResponse((input) =>
Effect.gen(function* () {
const attempt = yield* Ref.getAndUpdate(attempts, (value) => value + 1)
return attempt === 0
? input.respond("x".repeat(20_000), { status: 401 })
: input.respond("should not retry", { status: 200 })
}),
),
),
)
expectAIError(error)
expect(error.reason).toMatchObject({ _tag: "Authentication" })
expect(error.reason.body).toHaveLength(20_000)
}).pipe(
Effect.provide(
responsesLayer([
new Response("x".repeat(20_000), { status: 401 }),
new Response("should not retry", { status: 200 }),
]),
),
),
expect(yield* Ref.get(attempts)).toBe(1)
}),
)
it.effect("preserves response body fields", () =>
@@ -563,11 +516,9 @@ describe("RequestExecutor", () => {
expect(error.reason.body).toBe('{"error":{"message":"bad","key":"body-secret","detail":"api_key=query-secret"}}')
}).pipe(
Effect.provide(
responsesLayer([
new Response('{"error":{"message":"bad","key":"body-secret","detail":"api_key=query-secret"}}', {
status: 400,
}),
]),
fixedResponse('{"error":{"message":"bad","key":"body-secret","detail":"api_key=query-secret"}}', {
status: 400,
}),
),
),
)
@@ -581,9 +532,7 @@ describe("RequestExecutor", () => {
expect(error.reason.body).toBe("provider echoed query-secret-123 and authorization header-secret-456")
}).pipe(
Effect.provide(
responsesLayer([
new Response("provider echoed query-secret-123 and authorization header-secret-456", { status: 400 }),
]),
fixedResponse("provider echoed query-secret-123 and authorization header-secret-456", { status: 400 }),
),
),
)
@@ -591,9 +540,7 @@ describe("RequestExecutor", () => {
it.effect("does not re-execute after a successful response reaches stream parsing", () =>
Effect.gen(function* () {
const attempts = yield* Ref.make(0)
const model = OpenAIChat.route
.with({ endpoint: { baseURL: "https://api.openai.test/v1" } })
.model({ id: "gpt-4o-mini" })
const model = route.with({ endpoint: { baseURL: "https://api.openai.test/v1" } }).model({ id: "gpt-4o-mini" })
const error = yield* LLMClient.generate(LLM.request({ model, prompt: "Say hello." })).pipe(
Effect.provide(
dynamicResponse((input) =>
@@ -624,7 +571,7 @@ describe("RequestExecutor", () => {
})
describe("WebSocket channel execution", () => {
const model = OpenAI.configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).responses("gpt-4.1-mini")
const model = configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).responses("gpt-4.1-mini")
const request = LLM.request({ model, prompt: "Say hello." })
const frames = [
JSON.stringify({ type: "response.output_text.delta", item_id: "msg_1", delta: "Hi" }),
+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)
+5 -5
View File
@@ -1,11 +1,11 @@
export * as ConfigMCPPlugin from "./mcp.js"
export * as ConfigMcpPlugin from "./mcp.js"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { Document, type Entry } from "@opencode-ai/schema/config"
import { Mcp } from "@opencode-ai/schema/mcp"
import type { ServerConfig } from "@opencode-ai/schema/mcp"
import { Effect, Stream } from "effect"
import { Config } from "../../config.js"
import { MCP } from "../../mcp/index.js"
import { Mcp } from "../../mcp/index.js"
export const Plugin = define({
id: "opencode.config.mcp",
@@ -18,7 +18,7 @@ export const register = Effect.fn("ConfigMCPPlugin.register")(function* (
events: Stream.Stream<{ readonly type: string }, unknown>,
) {
const config = yield* Config.Service
const mcp = yield* MCP.Service
const mcp = yield* Mcp.Service
const loaded = { entries: [] as Entry[] }
yield* events.pipe(
@@ -43,7 +43,7 @@ export const register = Effect.fn("ConfigMCPPlugin.register")(function* (
{},
...documents.flatMap((entry) => (entry.info.mcp?.timeout ? [entry.info.mcp.timeout] : [])),
)
const servers = new Map<string, Mcp.ServerConfig>()
const servers = new Map<string, ServerConfig>()
for (const document of documents) {
for (const [name, server] of Object.entries(document.info.mcp?.servers ?? {})) {
servers.set(name, { ...server, timeout: { ...timeout, ...server.timeout } })
+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)),
+3 -3
View File
@@ -1,4 +1,4 @@
export * as MCPClient from "./client.js"
export * as McpClient from "./client.js"
import path from "node:path"
import { pathToFileURL } from "node:url"
@@ -29,7 +29,7 @@ import {
} from "@modelcontextprotocol/sdk/types.js"
import { Cause, Effect, Exit, Schema } from "effect"
import { ConfigMCP } from "@opencode-ai/schema/config/mcp"
import { MCPStdio } from "./stdio.js"
import { McpStdio } from "./stdio.js"
const DEFAULT_STARTUP_TIMEOUT = 30_000
const DEFAULT_CATALOG_TIMEOUT = 30_000
@@ -222,7 +222,7 @@ export const connect = Effect.fnUntraced(function* (
const exit = yield* Effect.gen(function* () {
if (config.type === "local") {
const [command, ...args] = config.command
const transport = yield* MCPStdio.make({
const transport = yield* McpStdio.make({
server,
command,
args,
+32 -34
View File
@@ -1,4 +1,4 @@
export * as MCP from "./index.js"
export * as Mcp from "./index.js"
import { Mcp } from "@opencode-ai/schema/mcp"
import { McpEvent } from "@opencode-ai/schema/mcp-event"
@@ -16,7 +16,7 @@ import { KeyedMutex } from "../effect/keyed-mutex.js"
import { Location } from "../location.js"
import { waitForAbort } from "@opencode-ai/util/process"
import { State } from "../state.js"
import type { MCPClient } from "./client.js"
import type { McpClient } from "./client.js"
export const ServerName = Schema.String.pipe(Schema.brand("MCP.ServerName"))
export const PromptsChanged = ephemeral({ type: "mcp.prompts.changed", schema: { server: Schema.String } })
@@ -114,7 +114,7 @@ type ServerEntry = {
status: Status
readonly startup: Latch.Latch
scope?: Scope.Closeable
client?: MCPClient.Connection
client?: McpClient.Connection
tools?: ReadonlyArray<Tool>
prompts?: ReadonlyArray<Prompt>
// Set when a remote server is registered as an OAuth integration; the credential lives in the global store.
@@ -235,8 +235,8 @@ export const layer = (options?: Options) =>
method: { id: methodID, type: "oauth", label: name },
authorize: () =>
Effect.gen(function* () {
const { MCPOAuth } = yield* Effect.promise(() => import("./oauth.js"))
return yield* MCPOAuth.authorize({ name, config: remote, methodID })
const { McpOAuth } = yield* Effect.promise(() => import("./oauth.js"))
return yield* McpOAuth.authorize({ name, config: remote, methodID })
}),
})
})
@@ -254,7 +254,7 @@ export const layer = (options?: Options) =>
// opens a browser, so an auth-gated connect ends in UnauthorizedError -> needs_auth rather than a redirect.
const connectProvider = Effect.fnUntraced(function* (entry: ServerEntry) {
if (entry.config.type !== "remote" || !entry.integrationID) return undefined
const { MCPOAuth } = yield* Effect.promise(() => import("./oauth.js"))
const { McpOAuth } = yield* Effect.promise(() => import("./oauth.js"))
const remote = entry.config
const oauth = remote.oauth || undefined
const base = {
@@ -270,19 +270,17 @@ export const layer = (options?: Options) =>
// ends in UnauthorizedError -> needs_auth. Returning no provider instead would let the transport throw
// a raw HTTP error, hiding the auth requirement behind a generic failed status. Anonymous servers are
// unaffected: tokens() returns undefined, so no auth header is sent and the SDK never calls auth().
return MCPOAuth.provider({ ...base, store: MCPOAuth.memoryStore() })
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({
return McpOAuth.provider({
...base,
// Drop a credential the SDK rejected so the next connect cleanly reports needs_auth — but only if it is
// still the stored one. Rotating servers hand out a fresh refresh token per use, so a concurrent
@@ -303,22 +301,22 @@ export const layer = (options?: Options) =>
const oauth = await readOAuthCredential()
if (!oauth) return undefined
presented = oauth.refresh
return MCPOAuth.toTokens(oauth)
return McpOAuth.toTokens(oauth)
},
saveTokens: async (tokens) => {
const previous = await readOAuthCredential()
const value = MCPOAuth.toCredential({
const value = McpOAuth.toCredential({
methodID,
serverUrl: remote.url,
tokens,
client: previous ? MCPOAuth.clientFromCredential(previous) : undefined,
client: previous ? McpOAuth.clientFromCredential(previous) : undefined,
})
presented = value.refresh
await Effect.runPromise(credentials.update(credentialID, { value }))
},
clientInformation: async () => {
const oauth = await readOAuthCredential()
return oauth ? MCPOAuth.clientFromCredential(oauth) : undefined
return oauth ? McpOAuth.clientFromCredential(oauth) : undefined
},
saveClientInformation: async () => {},
codeVerifier: async () => undefined,
@@ -330,7 +328,7 @@ export const layer = (options?: Options) =>
const elicitation = {
create: (input: {
readonly server: string
readonly params: MCPClient.ElicitationParams
readonly params: McpClient.ElicitationParams
readonly signal: AbortSignal
}) =>
Effect.gen(function* () {
@@ -355,7 +353,7 @@ export const layer = (options?: Options) =>
Effect.raceFirst(waitForAbort(input.signal)),
Effect.ensuring(Effect.sync(() => urlElicitations.delete(key))),
Effect.map(
(state): MCPClient.ElicitationResult => ({
(state): McpClient.ElicitationResult => ({
action: state.status === "answered" ? "accept" : "cancel",
}),
),
@@ -375,13 +373,13 @@ export const layer = (options?: Options) =>
})
.pipe(
Effect.raceFirst(waitForAbort(input.signal)),
Effect.map((state): MCPClient.ElicitationResult => {
Effect.map((state): McpClient.ElicitationResult => {
if (state.status !== "answered") return { action: "cancel" }
return {
action: "accept",
content: Object.fromEntries(
Object.entries(state.answer).map(
([key, value]): [string, NonNullable<MCPClient.ElicitationResult["content"]>[string]] =>
([key, value]): [string, NonNullable<McpClient.ElicitationResult["content"]>[string]] =>
typeof value === "object" ? [key, Array.from(value)] : [key, value],
),
),
@@ -395,9 +393,9 @@ export const layer = (options?: Options) =>
if (!formID) return
yield* forms.reply({ id: formID, answer: { [URL_ELICITATION_FIELD_KEY]: true } }).pipe(Effect.ignore)
}),
} satisfies MCPClient.ElicitationHandler
} satisfies McpClient.ElicitationHandler
const toTool = (server: ServerName, entry: ServerEntry, def: MCPClient.ToolDefinition) =>
const toTool = (server: ServerName, entry: ServerEntry, def: McpClient.ToolDefinition) =>
new Tool({
server,
name: def.name,
@@ -407,7 +405,7 @@ export const layer = (options?: Options) =>
outputSchema: def.outputSchema,
})
const toPrompt = (server: ServerName, def: MCPClient.PromptDefinition) =>
const toPrompt = (server: ServerName, def: McpClient.PromptDefinition) =>
new Prompt({
server,
name: def.name,
@@ -422,7 +420,7 @@ export const layer = (options?: Options) =>
),
})
const toResource = (server: ServerName, def: MCPClient.ResourceDefinition) =>
const toResource = (server: ServerName, def: McpClient.ResourceDefinition) =>
Resource.make({
server,
name: def.name,
@@ -431,7 +429,7 @@ export const layer = (options?: Options) =>
mimeType: def.mimeType,
})
const toResourceTemplate = (server: ServerName, def: MCPClient.ResourceTemplateDefinition) =>
const toResourceTemplate = (server: ServerName, def: McpClient.ResourceTemplateDefinition) =>
ResourceTemplate.make({
server,
name: def.name,
@@ -440,14 +438,14 @@ export const layer = (options?: Options) =>
mimeType: def.mimeType,
})
const refreshTools = (name: ServerName, entry: ServerEntry, connection: MCPClient.Connection) =>
const refreshTools = (name: ServerName, entry: ServerEntry, connection: McpClient.Connection) =>
connection.tools().pipe(
Effect.map((defs) => {
entry.tools = defs.map((def) => toTool(name, entry, def))
}),
)
const refreshPrompts = (name: ServerName, entry: ServerEntry, connection: MCPClient.Connection) =>
const refreshPrompts = (name: ServerName, entry: ServerEntry, connection: McpClient.Connection) =>
connection.prompts().pipe(
Effect.orElseSucceed(() => []),
Effect.map((defs) => {
@@ -459,7 +457,7 @@ export const layer = (options?: Options) =>
// Runs a connection callback under the server lock, dropping it if the connection is no longer
// the entry's live client, so late SDK callbacks cannot commit obsolete state.
const whenLive =
(name: ServerName, entry: ServerEntry, connection: MCPClient.Connection) =>
(name: ServerName, entry: ServerEntry, connection: McpClient.Connection) =>
<E>(effect: Effect.Effect<void, E>) =>
fork(
Effect.suspend(() => (entry.client === connection ? effect : Effect.void)).pipe(
@@ -468,7 +466,7 @@ export const layer = (options?: Options) =>
),
)
const watch = (name: ServerName, entry: ServerEntry, connection: MCPClient.Connection) => {
const watch = (name: ServerName, entry: ServerEntry, connection: McpClient.Connection) => {
const live = whenLive(name, entry, connection)
connection.onClose(() =>
live(
@@ -491,7 +489,7 @@ export const layer = (options?: Options) =>
connection.onResourcesChanged(() => live(bus.publish(McpEvent.ResourcesChanged, { server: name })))
}
const serverLog = (server: ServerName, message: MCPClient.LogMessage) => {
const serverLog = (server: ServerName, message: McpClient.LogMessage) => {
const fields = { server, logger: message.logger, level: message.level, data: message.data }
switch (message.level) {
case "debug":
@@ -518,10 +516,10 @@ export const layer = (options?: Options) =>
const scope = yield* Scope.fork(root)
entry.scope = scope
const authProvider = yield* connectProvider(entry)
const { MCPClient } = yield* Effect.promise(() => import("./client.js"))
const { McpClient } = yield* Effect.promise(() => import("./client.js"))
// List tools as part of connect so a failure here marks the server failed rather than
// leaving it connected with a silently empty tool list and no path to recover.
const result = yield* MCPClient.connect(
const result = yield* McpClient.connect(
name,
entry.config,
location.directory,
@@ -555,7 +553,7 @@ export const layer = (options?: Options) =>
entry.scope = undefined
const error = Cause.squash(result.cause)
entry.status =
error instanceof MCPClient.NeedsAuthError
error instanceof McpClient.NeedsAuthError
? { status: "needs_auth" }
: { status: "failed", error: error instanceof Error ? error.message : String(error) }
yield* Effect.logWarning("mcp connect failed", { server: name, status: entry.status })
@@ -938,4 +936,4 @@ function toElicitationField(key: string, property: ElicitationProperty, required
}
}
type ElicitationProperty = MCPClient.ElicitationFormParams["requestedSchema"]["properties"][string]
type ElicitationProperty = McpClient.ElicitationFormParams["requestedSchema"]["properties"][string]
+3 -3
View File
@@ -5,7 +5,7 @@ import { Context, Effect, Layer, Schema } from "effect"
import { Agent } from "../agent.js"
import { Permission } from "../permission.js"
import { McpTool } from "../tool/mcp.js"
import { MCP } from "./index.js"
import { Mcp } from "./index.js"
import { Instructions } from "../instructions/index.js"
const Summary = Schema.Struct({
@@ -63,7 +63,7 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/Mc
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const mcp = yield* MCP.Service
const mcp = yield* Mcp.Service
return Service.of({
load: Effect.fn("McpInstructions.load")(function* (selection) {
@@ -110,4 +110,4 @@ export const layer = Layer.effect(
}),
)
export const node = makeLocationNode({ service: Service, layer, deps: [MCP.node] })
export const node = makeLocationNode({ service: Service, layer, deps: [Mcp.node] })
+1 -1
View File
@@ -1,4 +1,4 @@
export * as MCPOAuth from "./oauth.js"
export * as McpOAuth from "./oauth.js"
import { auth, type OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js"
import type { OAuthClientInformationMixed, OAuthTokens } from "@modelcontextprotocol/sdk/shared/auth.js"
+1 -1
View File
@@ -1,4 +1,4 @@
export * as MCPStdio from "./stdio.js"
export * as McpStdio from "./stdio.js"
import { ReadBuffer, serializeMessage } from "@modelcontextprotocol/sdk/shared/stdio.js"
import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js"
@@ -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
}
+6 -11
View File
@@ -13,7 +13,7 @@ import { Command } from "./command.js"
import { Bus } from "./bus.js"
import { Integration } from "./integration.js"
import { KV } from "./kv.js"
import { MCP } from "./mcp/index.js"
import { Mcp } from "./mcp/index.js"
import { Location } from "./location.js"
import { PluginHost } from "./plugin/host.js"
import { PluginRuntime } from "./plugin/runtime.js"
@@ -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]
@@ -197,7 +192,7 @@ export const node = makeLocationNode({
Command.node,
Integration.node,
KV.node,
MCP.node,
Mcp.node,
Location.node,
Reference.node,
Skill.node,
+13 -17
View File
@@ -4,7 +4,7 @@ import { define } from "@opencode-ai/plugin/effect/plugin"
import { Effect, Stream } from "effect"
import { Bus } from "../bus.js"
import { Location } from "../location.js"
import { MCP } from "../mcp/index.js"
import { Mcp } from "../mcp/index.js"
import PROMPT_INITIALIZE from "./command/initialize.txt"
import PROMPT_REVIEW from "./command/review.txt"
@@ -12,20 +12,18 @@ export const Plugin = define({
id: "opencode.command",
effect: Effect.fn(function* (ctx) {
const location = yield* Location.Service
const mcp = yield* MCP.Service
const mcp = yield* Mcp.Service
const bus = yield* Bus.Service
const loaded = { prompts: [] as MCP.Prompt[] }
yield* bus
.subscribe(MCP.PromptsChanged)
.pipe(
Stream.runForEach(() =>
mcp.prompts().pipe(
Effect.tap((prompts) => Effect.sync(() => (loaded.prompts = prompts))),
Effect.andThen(ctx.command.reload()),
),
const loaded = { prompts: [] as Mcp.Prompt[] }
yield* bus.subscribe(Mcp.PromptsChanged).pipe(
Stream.runForEach(() =>
mcp.prompts().pipe(
Effect.tap((prompts) => Effect.sync(() => (loaded.prompts = prompts))),
Effect.andThen(ctx.command.reload()),
),
Effect.forkScoped({ startImmediately: true }),
)
),
Effect.forkScoped({ startImmediately: true }),
)
loaded.prompts = yield* mcp.prompts()
yield* ctx.command.transform((draft) => {
draft.add({
@@ -60,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.
+4 -4
View File
@@ -3,7 +3,7 @@ export * as PluginHost from "./host.js"
import { Plugin } from "@opencode-ai/plugin/effect"
import type { IntegrationMethodRegistration } from "@opencode-ai/plugin/effect/integration"
import { EventManifest } from "@opencode-ai/schema/event-manifest"
import { Mcp } from "@opencode-ai/schema/mcp"
import { ServerConfig } from "@opencode-ai/schema/mcp"
import { App } from "../app.js"
import { Effect, Schema, Stream } from "effect"
import { Agent } from "../agent.js"
@@ -16,7 +16,7 @@ import { Integration } from "../integration.js"
import { KV } from "../kv.js"
import { Location } from "../location.js"
import { Model } from "../model.js"
import { MCP } from "../mcp/index.js"
import { Mcp } from "../mcp/index.js"
import { PluginRuntime } from "./runtime.js"
import { Provider } from "../provider.js"
import { Reference } from "../reference.js"
@@ -41,7 +41,7 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: Interface, p
const bus = yield* Bus.Service
const integration = yield* Integration.Service
const kv = yield* KV.Service
const mcp = yield* MCP.Service
const mcp = yield* Mcp.Service
const location = yield* Location.Service
const reference = yield* Reference.Service
const skill = yield* Skill.Service
@@ -295,7 +295,7 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: Interface, p
callback({
list: () => draft.list().map(([name, config]) => [name, mutable(config)]),
get: (name) => mutable(draft.get(name)),
set: (name, config) => draft.set(name, Schema.decodeUnknownSync(Mcp.ServerConfig)(config)),
set: (name, config) => draft.set(name, Schema.decodeUnknownSync(ServerConfig)(config)),
update: draft.update,
remove: draft.remove,
})
+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 }))
}
+8 -8
View File
@@ -18,7 +18,7 @@ import { ConfigFormatterPlugin } from "../config/plugin/formatter.js"
import { ConfigImagePlugin } from "../config/plugin/image.js"
import { ConfigInstructionPlugin } from "../config/plugin/instruction.js"
import { ConfigLocationWatcherPlugin } from "../config/plugin/location-watcher.js"
import { ConfigMCPPlugin } from "../config/plugin/mcp.js"
import { ConfigMcpPlugin } from "../config/plugin/mcp.js"
import { ConfigProviderPlugin } from "../config/plugin/provider.js"
import { ConfigPolicyPlugin } from "../config/plugin/policy.js"
import { ConfigReferencePlugin } from "../config/plugin/reference.js"
@@ -44,7 +44,7 @@ import { KV } from "../kv.js"
import { Location } from "../location.js"
import { LocationMutation } from "../location-mutation.js"
import { ModelsDev } from "../models-dev.js"
import { MCP } from "../mcp/index.js"
import { Mcp } from "../mcp/index.js"
import { Npm } from "@opencode-ai/util/npm"
import { Permission } from "../permission.js"
import { Reference } from "../reference.js"
@@ -78,7 +78,7 @@ import { AgentPlugin } from "./agent.js"
import { CommandPlugin } from "./command.js"
import { PlanPlugin } from "./plan.js"
import { ModelsDevPlugin } from "./models-dev.js"
import { MCPCodeModeExclusionPlugin } from "./mcp-codemode-exclusion.js"
import { McpCodeModeExclusionPlugin } from "./mcp-codemode-exclusion.js"
import { ProviderPlugins } from "./provider.js"
import { WebSearchPlugins } from "./websearch/index.js"
import { PluginRuntime } from "./runtime.js"
@@ -114,7 +114,7 @@ const services = Effect.fn("PluginInternal.services")(function* () {
const location = yield* Location.Service
const locationMutation = yield* LocationMutation.Service
const models = yield* ModelsDev.Service
const mcp = yield* MCP.Service
const mcp = yield* Mcp.Service
const npm = yield* Npm.Service
const permission = yield* Permission.Service
const runtime = yield* PluginRuntime.Service
@@ -158,7 +158,7 @@ const services = Effect.fn("PluginInternal.services")(function* () {
Context.make(Location.Service, location),
Context.make(LocationMutation.Service, locationMutation),
Context.make(ModelsDev.Service, models),
Context.make(MCP.Service, mcp),
Context.make(Mcp.Service, mcp),
Context.make(Npm.Service, npm),
Context.make(Permission.Service, permission),
Context.make(PluginRuntime.Service, runtime),
@@ -209,7 +209,7 @@ export const requirements = LayerNode.group([
Location.node,
LocationMutation.node,
ModelsDev.node,
MCP.node,
Mcp.node,
Npm.node,
Permission.node,
PluginRuntime.node,
@@ -234,8 +234,8 @@ export const requirements = LayerNode.group([
export type InternalPlugin = Plugin<Requirements | Scope.Scope>
const pre = [
ConfigMCPPlugin.Plugin,
MCPCodeModeExclusionPlugin.Plugin,
ConfigMcpPlugin.Plugin,
McpCodeModeExclusionPlugin.Plugin,
WellKnownPlugin.Plugin,
VcsGitPlugin.Plugin,
AgentPlugin.Plugin,
@@ -1,4 +1,4 @@
export * as MCPCodeModeExclusionPlugin from "./mcp-codemode-exclusion.js"
export * as McpCodeModeExclusionPlugin from "./mcp-codemode-exclusion.js"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { Effect } from "effect"
+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) {
+3 -3
View File
@@ -6,7 +6,7 @@ import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { Job } from "../job.js"
import { Location } from "../location.js"
import { LocationServiceMap } from "../location-service-map.js"
import { MCP } from "../mcp/index.js"
import { Mcp } from "../mcp/index.js"
import { Session } from "../session.js"
export interface Interface {
@@ -38,7 +38,7 @@ export interface Interface {
readonly mcp: {
readonly list: (
ref: Location.Ref,
) => Effect.Effect<{ readonly location: Location.Info; readonly data: MCP.ServerInfo[] }, unknown>
) => Effect.Effect<{ readonly location: Location.Info; readonly data: Mcp.ServerInfo[] }, unknown>
}
}
}
@@ -130,7 +130,7 @@ export const providerLayerWithCell = (cell: Cell) =>
list: (ref) =>
Effect.gen(function* () {
const location = yield* Location.Service
const mcp = yield* MCP.Service
const mcp = yield* Mcp.Service
return {
location: new Location.Info({
directory: location.directory,
+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: {

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