Compare commits

...
Author SHA1 Message Date
usrnk1 d45d7d2ee6 Merge branch 'v2' into adaptive-command-menu 2026-09-21 16:09:06 +02:00
opencode-agent[bot]andnexxeln 2f0c861af0 fix(cli): show actionable upgrade errors (#50346)
Co-authored-by: nexxeln <95541290+nexxeln@users.noreply.github.com>
2026-09-21 19:09:56 +05:30
opencode-agent[bot] 4d94777d4d chore(core): refresh bundled models.dev snapshot 2026-09-21 12:19:57 +00:00
opencode-agent[bot] 46ebde65e9 chore: update nix node_modules hashes 2026-09-21 11:58:21 +00:00
Luke Parker 932c12ad1d chore(desktop): upgrade Electron to 44.4.3 (#50327) 2026-09-21 21:33:49 +10:00
opencode 6f655dcbab sync release versions for v2.0.12 2026-09-21 09:57:01 +00:00
Luke Parker ab60f08c69 fix(app): open Review pane for browser focus in background sessions (#50284) 2026-09-21 06:08:36 +00:00
Luke Parker 60ed84ecd1 feat(app): open agent-referenced files as rich artifact tabs (#49882) 2026-09-21 15:20:15 +10:00
Luke Parker 8aebed170a fix(app): show empty review state for projects without VCS (#50265) 2026-09-21 14:54:47 +10:00
14148a0ea4 fix(app): restore server status spacing (#50273)
Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com>
Co-authored-by: Kit Langton <kit.langton@gmail.com>
2026-09-21 12:37:19 +08:00
Aiden Cline 7f51fbd878 feat(codemode): rank whole-word path matches above substring matches in search (#50275) 2026-09-20 22:59:01 -05:00
Aiden Cline cbdd1f66da fix(codemode): locate failures in the submitted source (#50197) 2026-09-20 20:47:54 -05:00
Luke Parker 788eb0fa29 fix(cli): report fatal startup causes on stderr (#50240) 2026-09-20 22:57:20 +00:00
opencode-agent[bot]andkitlangton 58fcad77a8 feat(tui): copy session ID from tab menu (#50181)
Co-authored-by: kitlangton <7587245+kitlangton@users.noreply.github.com>
2026-09-20 19:15:37 +00:00
Aiden Cline c555559ac1 refactor(plugin): name tool execution context (#50195) 2026-09-20 11:36:19 -05:00
Aiden Cline e3a3fa7108 fix(plugin): forward Promise tool cancellation (#50190) 2026-09-20 11:22:49 -05:00
opencode-agent[bot] 6238af397e chore(core): refresh bundled models.dev snapshot 2026-09-20 16:17:29 +00:00
Aiden Cline 7ffd75faf6 fix(core): restore anthropic budget variants (#50182) 2026-09-20 10:50:21 -05:00
opencode-agent[bot] 1f8ab95695 chore(core): refresh bundled models.dev snapshot 2026-09-20 15:37:06 +00:00
Aiden Cline 62dc1f7696 feat(codemode): add toLocaleString, Error.isError, and Map.getOrInsert (#50098) 2026-09-20 10:23:23 -05:00
opencode-agent[bot] 66f10ab7bf chore(core): refresh bundled models.dev snapshot 2026-09-20 14:37:06 +00:00
opencode-agent[bot] 1ca8f63a79 chore(core): refresh bundled models.dev snapshot 2026-09-20 13:36:51 +00:00
opencode-agent[bot] 03bcdd580d chore(core): refresh bundled models.dev snapshot 2026-09-20 12:43:39 +00:00
opencode-agent[bot] cc502f7e5f chore(core): refresh bundled models.dev snapshot 2026-09-20 11:37:37 +00:00
opencode-agent[bot] 64ce8771c0 chore(core): refresh bundled models.dev snapshot 2026-09-20 10:37:01 +00:00
opencode-agent[bot] 702a73d91a chore(core): refresh bundled models.dev snapshot 2026-09-20 09:37:50 +00:00
opencode 991b727eb8 sync release versions for v2.0.11 2026-09-20 08:57:41 +00:00
opencode-agent[bot] ba342ce227 chore(core): refresh bundled models.dev snapshot 2026-09-20 08:42:13 +00:00
David Hill 1464545665 fix(app): center timeline beside summary (#49620) 2026-09-20 18:06:32 +10:00
David Hill fecacc9e68 fix(app): match vertical nav tab states (#49619) 2026-09-20 18:03:49 +10:00
David HillandLukeParkerDev 1f73b4806b fix(desktop): polish browser panel states and navigation (#49432)
Co-authored-by: LukeParkerDev <10430890+Hona@users.noreply.github.com>
2026-09-20 07:54:25 +00:00
opencode-agent[bot] da2ce02596 chore(core): refresh bundled models.dev snapshot 2026-09-20 07:38:48 +00:00
opencode-agent[bot] 25f35dcfb8 chore(core): refresh bundled models.dev snapshot 2026-09-20 06:49:40 +00:00
Luke Parker 717f81ce08 chore(desktop): attribute renderer CPU profiles to source files (#50114) 2026-09-20 16:30:35 +10:00
Luke Parker 5fdfcc7a80 perf(desktop): hand the window its persisted stores before the first render (#50113) 2026-09-20 16:29:02 +10:00
Luke Parker 0530c8e512 perf(desktop): talk to the main process without the Effect runtime in the renderer (#50112) 2026-09-20 16:26:08 +10:00
Luke Parker 1d8cf4564b perf(desktop): move startup work that the first window does not need off its path (#50111) 2026-09-20 16:23:37 +10:00
Luke Parker 7e88f6bb18 perf(desktop): bundle the renderer's initial module graph as one chunk (#50110) 2026-09-20 16:19:28 +10:00
Luke Parker af592fb779 perf(desktop): serve the renderer's first assets before evaluating the main bundle (#50109) 2026-09-20 16:18:50 +10:00
Luke Parker 7fc3f68007 perf(desktop): boot the renderer alongside the main bundle (#50108) 2026-09-20 16:15:58 +10:00
opencode-agent[bot] cdcbb0047e chore(core): refresh bundled models.dev snapshot 2026-09-20 05:38:29 +00:00
opencode-agent[bot] a1956a7522 chore(core): refresh bundled models.dev snapshot 2026-09-20 04:42:05 +00:00
opencode-agent[bot] 55bc7fd403 chore(core): refresh bundled models.dev snapshot 2026-09-20 03:43:32 +00:00
opencode-agent[bot] 3049b1e684 chore(core): refresh bundled models.dev snapshot 2026-09-20 02:45:23 +00:00
opencode-agent[bot] 1f36a7aff8 chore(core): refresh bundled models.dev snapshot 2026-09-20 01:31:11 +00:00
Aiden Cline eb0e26b974 feat(codemode): return live iterators from keys, values, and entries (#50061) 2026-09-19 18:46:35 -05:00
opencode-agent[bot] 6f2b0e7833 chore(core): refresh bundled models.dev snapshot 2026-09-19 23:38:19 +00:00
opencode-agent[bot] f153255942 chore(core): refresh bundled models.dev snapshot 2026-09-19 22:37:11 +00:00
opencode-agent[bot] fef2fad76f chore(core): refresh bundled models.dev snapshot 2026-09-19 22:15:20 +00:00
Aiden Cline f30d06ea34 fix(core): pin the session to http after repeated websocket stream losses (#50031) 2026-09-19 16:45:54 -05:00
Kit Langton dfa44e94e8 fix(tui): use focused action token for question form border (#50037) 2026-09-19 19:07:10 +00:00
Aiden Cline b81e10a461 fix(core): re-read models.dev catalog after subscribing to Refreshed (#50012) 2026-09-19 13:31:38 -05:00
Aiden Cline 65c93b69ed fix(ai): never fail decoding on Responses error frames (#49402) 2026-09-19 13:27:05 -05:00
Aiden Cline b073b052d3 fix(ai): remove bounded websocket inbound queues (#50026) 2026-09-19 13:26:37 -05:00
usrnk1 3c8301e155 feat(desktop): adapt composer suggestion menu 2026-09-16 15:47:36 +02:00
188 changed files with 4093 additions and 938 deletions
+39 -39
View File
@@ -32,7 +32,7 @@
},
"packages/ai": {
"name": "@opencode/ai",
"version": "2.0.10",
"version": "2.0.12",
"dependencies": {
"@aws-sdk/credential-providers": "3.1057.0",
"@opencode/schema": "workspace:*",
@@ -54,7 +54,7 @@
},
"packages/app": {
"name": "@opencode/app",
"version": "2.0.10",
"version": "2.0.12",
"dependencies": {
"@corvu/drawer": "catalog:",
"@dnd-kit/abstract": "0.5.0",
@@ -111,7 +111,7 @@
},
"packages/cli": {
"name": "@opencode/cli",
"version": "2.0.10",
"version": "2.0.12",
"bin": {
"opencode2": "./bin/opencode2.cjs",
},
@@ -175,7 +175,7 @@
},
"packages/client": {
"name": "@opencode/client",
"version": "2.0.10",
"version": "2.0.12",
"dependencies": {
"@opencode/protocol": "workspace:*",
"@opencode/schema": "workspace:*",
@@ -201,7 +201,7 @@
},
"packages/codemode": {
"name": "@opencode/codemode",
"version": "2.0.10",
"version": "2.0.12",
"dependencies": {
"acorn": "8.15.0",
"effect": "catalog:",
@@ -214,7 +214,7 @@
},
"packages/console/app": {
"name": "@opencode/console-app",
"version": "2.0.10",
"version": "2.0.12",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@ibm/plex": "6.4.1",
@@ -250,7 +250,7 @@
},
"packages/console/core": {
"name": "@opencode/console-core",
"version": "2.0.10",
"version": "2.0.12",
"dependencies": {
"@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1",
@@ -277,7 +277,7 @@
},
"packages/console/function": {
"name": "@opencode/console-function",
"version": "2.0.10",
"version": "2.0.12",
"dependencies": {
"@openauthjs/openauth": "0.0.0-20250322224806",
"@opencode/console-core": "workspace:*",
@@ -294,7 +294,7 @@
},
"packages/console/mail": {
"name": "@opencode/console-mail",
"version": "2.0.10",
"version": "2.0.12",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
@@ -318,7 +318,7 @@
},
"packages/console/support": {
"name": "@opencode/console-support",
"version": "2.0.10",
"version": "2.0.12",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@opencode/console-core": "workspace:*",
@@ -338,7 +338,7 @@
},
"packages/core": {
"name": "@opencode/core",
"version": "2.0.10",
"version": "2.0.12",
"dependencies": {
"@ai-sdk/cohere": "3.0.27",
"@ai-sdk/gateway": "3.0.104",
@@ -406,10 +406,10 @@
},
"packages/desktop": {
"name": "@opencode/desktop",
"version": "2.0.10",
"version": "2.0.12",
"dependencies": {
"@zip.js/zip.js": "2.7.62",
"electron-context-menu": "4.1.2",
"electron-context-menu": "5.0.0",
"electron-log": "^5",
"electron-updater": "6.8.9",
"lighthouse": "13.4.1",
@@ -436,7 +436,7 @@
"drizzle-kit": "catalog:",
"drizzle-orm": "catalog:",
"effect": "catalog:",
"electron": "42.10.1",
"electron": "44.4.3",
"electron-builder": "26.15.7",
"electron-vite": "6.0.0-beta.1",
"puppeteer-core": "25.9.0",
@@ -455,7 +455,7 @@
},
"packages/enterprise": {
"name": "@opencode/enterprise",
"version": "2.0.10",
"version": "2.0.12",
"dependencies": {
"@hono/standard-validator": "catalog:",
"@opencode-ai/sdk": "1.18.21",
@@ -492,7 +492,7 @@
},
"packages/function": {
"name": "@opencode/function",
"version": "2.0.10",
"version": "2.0.12",
"dependencies": {
"@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:",
@@ -508,7 +508,7 @@
},
"packages/http-recorder": {
"name": "@opencode/http-recorder",
"version": "2.0.10",
"version": "2.0.12",
"dependencies": {
"@effect/platform-node-shared": "4.0.0-rc.112",
},
@@ -527,7 +527,7 @@
},
"packages/httpapi-codegen": {
"name": "@opencode/httpapi-codegen",
"version": "2.0.10",
"version": "2.0.12",
"dependencies": {
"effect": "catalog:",
"prettier": "3.6.2",
@@ -540,7 +540,7 @@
},
"packages/latex": {
"name": "@opencode/latex",
"version": "2.0.10",
"version": "2.0.12",
"dependencies": {
"@opencode/plugin": "workspace:*",
"@opentui/core": "catalog:",
@@ -554,7 +554,7 @@
},
"packages/merman": {
"name": "@opencode/merman",
"version": "2.0.10",
"version": "2.0.12",
"dependencies": {
"@opencode/plugin": "workspace:*",
"@opentui/core": "catalog:",
@@ -569,7 +569,7 @@
},
"packages/plugin": {
"name": "@opencode/plugin",
"version": "2.0.10",
"version": "2.0.12",
"dependencies": {
"@ai-sdk/provider": "3.0.8",
"@opencode/ai": "workspace:*",
@@ -608,7 +608,7 @@
},
"packages/plugin-browser": {
"name": "@opencode/plugin-browser",
"version": "2.0.10",
"version": "2.0.12",
"dependencies": {
"@opencode/plugin": "workspace:*",
"@opencode/schema": "workspace:*",
@@ -638,7 +638,7 @@
},
"packages/protocol": {
"name": "@opencode/protocol",
"version": "2.0.10",
"version": "2.0.12",
"dependencies": {
"@opencode/schema": "workspace:*",
"effect": "catalog:",
@@ -653,7 +653,7 @@
},
"packages/schema": {
"name": "@opencode/schema",
"version": "2.0.10",
"version": "2.0.12",
"dependencies": {
"@standard-schema/spec": "catalog:",
"effect": "catalog:",
@@ -677,7 +677,7 @@
},
"packages/sdk": {
"name": "@opencode/sdk",
"version": "2.0.10",
"version": "2.0.12",
"dependencies": {
"@opencode/client": "workspace:*",
"@opencode/core": "workspace:*",
@@ -698,7 +698,7 @@
},
"packages/server": {
"name": "@opencode/server",
"version": "2.0.10",
"version": "2.0.12",
"dependencies": {
"@effect/platform-node": "catalog:",
"@effect/platform-node-shared": "catalog:",
@@ -720,7 +720,7 @@
},
"packages/session-ui": {
"name": "@opencode/session-ui",
"version": "2.0.10",
"version": "2.0.12",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode/client": "workspace:*",
@@ -755,7 +755,7 @@
},
"packages/simulation": {
"name": "@opencode/simulation",
"version": "2.0.10",
"version": "2.0.12",
"dependencies": {
"@opencode/ai": "workspace:*",
"@opencode/core": "workspace:*",
@@ -775,7 +775,7 @@
},
"packages/stats/app": {
"name": "@opencode/stats-app",
"version": "2.0.10",
"version": "2.0.12",
"dependencies": {
"@ibm/plex": "6.4.1",
"@kobalte/core": "catalog:",
@@ -809,7 +809,7 @@
},
"packages/stats/core": {
"name": "@opencode/stats-core",
"version": "2.0.10",
"version": "2.0.12",
"dependencies": {
"@aws-sdk/client-athena": "3.933.0",
"@planetscale/database": "1.19.0",
@@ -828,7 +828,7 @@
},
"packages/stats/server": {
"name": "@opencode/stats-server",
"version": "2.0.10",
"version": "2.0.12",
"dependencies": {
"@aws-sdk/client-firehose": "3.933.0",
"@effect/platform-node": "catalog:",
@@ -874,7 +874,7 @@
},
"packages/theme": {
"name": "@opencode/theme",
"version": "2.0.10",
"version": "2.0.12",
"dependencies": {
"@opentui/core": "catalog:",
"effect": "catalog:",
@@ -888,7 +888,7 @@
},
"packages/tui": {
"name": "@opencode/tui",
"version": "2.0.10",
"version": "2.0.12",
"dependencies": {
"@opencode/client": "workspace:*",
"@opencode/core": "workspace:*",
@@ -923,7 +923,7 @@
},
"packages/ui": {
"name": "@opencode/ui",
"version": "2.0.10",
"version": "2.0.12",
"dependencies": {
"@kobalte/core": "catalog:",
"@pierre/diffs": "catalog:",
@@ -958,7 +958,7 @@
},
"packages/util": {
"name": "@opencode/util",
"version": "2.0.10",
"version": "2.0.12",
"dependencies": {
"@effect/opentelemetry": "catalog:",
"@effect/platform-node": "catalog:",
@@ -991,7 +991,7 @@
},
"packages/web": {
"name": "@opencode/web",
"version": "2.0.10",
"version": "2.0.12",
"dependencies": {
"@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1",
@@ -1032,7 +1032,7 @@
},
"services/update": {
"name": "@opencode/update",
"version": "2.0.10",
"version": "2.0.12",
"dependencies": {
"jose": "6.0.11",
"semver": "catalog:",
@@ -3880,13 +3880,13 @@
"ejs": ["ejs@3.1.10", "", { "dependencies": { "jake": "^10.8.5" }, "bin": { "ejs": "bin/cli.js" } }, "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA=="],
"electron": ["electron@42.10.1", "", { "dependencies": { "@electron-internal/extract-zip": "^1.0.1", "@electron/get": "^5.0.0", "@types/node": "^24.9.0" }, "bin": { "electron": "cli.js", "install-electron": "install.js" } }, "sha512-ITc1HPeoDzsxCCaH6MFsN67Nq2nUiJf5N9pBWxzhUpFfKJF0IwiAkKT3emg1lPbvC4OfFE+Cdwe4vhgdOZ1YKg=="],
"electron": ["electron@44.4.3", "", { "dependencies": { "@electron-internal/extract-zip": "^1.0.1", "@electron/get": "^5.0.0", "@types/node": "^24.9.0" }, "bin": { "electron": "cli.js", "install-electron": "install.js" } }, "sha512-LTpSFTB40qVCXIX5xMo+cgHI/Jjkbjw7VpB26PccEbroqOn72LBukeaDwPVo1fBYzSzs0c9iPuAucFCO7Tw81Q=="],
"electron-builder": ["electron-builder@26.15.7", "", { "dependencies": { "app-builder-lib": "26.15.7", "builder-util": "26.15.3", "builder-util-runtime": "9.7.0", "chalk": "^4.1.2", "ci-info": "^4.2.0", "dmg-builder": "26.15.7", "fs-extra": "^10.1.0", "lazy-val": "^1.0.5", "simple-update-notifier": "2.0.0", "yargs": "^17.6.2" }, "bin": { "electron-builder": "./cli.js", "install-app-deps": "./install-app-deps.js" } }, "sha512-DBpaNzxsPs1BvEblzFoNriSbzsBqDCy/gseIngeEhYzQG1IxfB7Hvc2tBBVmpWE2BTQGP9J1RrAvDT+Vc/uAxg=="],
"electron-builder-squirrel-windows": ["electron-builder-squirrel-windows@26.15.7", "", { "dependencies": { "app-builder-lib": "26.15.7", "builder-util": "26.15.3", "electron-winstaller": "5.4.0" } }, "sha512-B4uvn2NzFSuf084udWqugludFull6CRJiWe2dLzMnZLl6G5hdAGk0fsBMGlBSpKjvQCJn8IPc+S7OnJ+GXqwLA=="],
"electron-context-menu": ["electron-context-menu@4.1.2", "", { "dependencies": { "cli-truncate": "^4.0.0", "electron-dl": "^4.0.0", "electron-is-dev": "^3.0.1" } }, "sha512-9xYTUV0oRqKL50N9W71IrXNdVRB0LuBp3R1zkUdUc2wfIa2/QZwYYj5RLuO7Tn7ZSLVIaO3X6u+EIBK+cBvzrQ=="],
"electron-context-menu": ["electron-context-menu@5.0.0", "", { "dependencies": { "cli-truncate": "^4.0.0", "electron-dl": "^4.0.0", "electron-is-dev": "^3.0.1" } }, "sha512-rgFpRtwY0/rhsRCoz9rE6VM4WueEsLbIpca7ucOhERVrbGB2dQrxa9xwBNLplU54jRgPuv6nTEbxoplE5bzy2A=="],
"electron-dl": ["electron-dl@4.0.0", "", { "dependencies": { "ext-name": "^5.0.0", "pupa": "^3.1.0", "unused-filename": "^4.0.1" } }, "sha512-USiB9816d2JzKv0LiSbreRfTg5lDk3lWh0vlx/gugCO92ZIJkHVH0UM18EHvKeadErP6Xn4yiTphWzYfbA2Ong=="],
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-8mOzCscBAuogG4tm8CroqjTX5E5yzCvTobbhKvxQP0U=",
"aarch64-linux": "sha256-drrRSpzxC8bfaTXBpOyaN0QAyBVV7WJzm0NstJ+8sAE=",
"aarch64-darwin": "sha256-YY5A/zxLPONvgnI+DZlzcD2K5Q9PIw4F2YbDxKbT4UU=",
"x86_64-darwin": "sha256-/c/Ew4onA+9+l6GRKXz3zWq3QFESM7j5RyGLCWRPQFw="
"x86_64-linux": "sha256-/jah4P2a0aGJNJ0aMdlFEbGHAXx33lqxHbc7UmpLFDg=",
"aarch64-linux": "sha256-L3SoZ24qNXicsE2FK6LATQjOmjPxY679RjugrUyO/1Y=",
"aarch64-darwin": "sha256-pI9NT8KWUPi+JCk6DYMqIAYmBqTbi13uL4VdNV3WS6Y=",
"x86_64-darwin": "sha256-rMAGhTTz46KA5Ya7E5J0af7Bn1QzTDhTaNfNJm8qfsw="
}
}
+1 -1
View File
@@ -2,7 +2,7 @@
"$schema": "https://json.schemastore.org/package.json",
"name": "opencode",
"description": "AI-powered development tool",
"version": "2.0.10",
"version": "2.0.12",
"private": true,
"type": "module",
"packageManager": "bun@1.4.2",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "2.0.10",
"version": "2.0.12",
"name": "@opencode/ai",
"type": "module",
"license": "MIT",
@@ -73,11 +73,6 @@ const driver = (options: Options, body: string): WebSocketChannelDriver => {
)
if (event.type === "error") {
terminal = true
yield* OpenResponses.decodeKnownErrorEvent(event).pipe(
Effect.mapError((cause) =>
ProviderShared.eventError(options.id, `${options.name} returned a malformed error event`, frame, cause),
),
)
return {
type: "provider-failure",
error: OpenResponses.providerFailure(event, `${options.name} stream error`, frame),
@@ -108,7 +108,7 @@ const incremental = (
return input.slice(baseline.length)
}
const code = (event: OpenResponses.Event) => event.code || event.error?.code || event.response?.error?.code || undefined
const code = (event: OpenResponses.Event) => OpenResponses.errorDetail(event).code
const rejected = (
observation: Extract<ChannelObservation, { readonly type: "provider-failure" }>,
+28 -94
View File
@@ -1,4 +1,4 @@
import { Effect, Option, Schema, SchemaGetter } from "effect"
import { Effect, Option, Schema } from "effect"
import type { Content } from "@opencode/schema/tool"
import { HttpTransport } from "../route/transport/index.js"
import { Protocol } from "../route/protocol.js"
@@ -333,53 +333,13 @@ export const StreamItem = Schema.StructWithRest(
export type StreamItem = Schema.Schema.Type<typeof StreamItem>
export type OutputItem = StreamItem & { readonly id: string }
// Responses-compatible providers put streaming error details at the top level or
// under `error`, and response failures under `response.error`. Accept all three shapes.
// Responses-compatible providers put error details at the top level, under `error`, or under
// `response.error`, and gateways reshape them freely: strings, numeric codes, extra fields. Those
// fields decode as opaque values and `errorDetail` reads them defensively, so an error frame can
// only fail on invalid JSON and otherwise always classifies with the raw body as the fallback.
// https://www.openresponses.org/specification
const OpenResponsesErrorObject = Schema.Struct({
type: optionalNull(Schema.String),
code: optionalNull(Schema.String),
message: optionalNull(Schema.String),
param: optionalNull(Schema.String),
})
const OpenResponsesErrorPayload = Schema.Union([Schema.String, OpenResponsesErrorObject]).pipe(
Schema.decodeTo(OpenResponsesErrorObject, {
decode: SchemaGetter.transform((error) => (typeof error === "string" ? { message: error } : error)),
encode: SchemaGetter.passthrough(),
}),
)
type OpenResponsesErrorPayload = Schema.Schema.Type<typeof OpenResponsesErrorPayload>
const WebSocketErrorHeader = Schema.Union([Schema.String, Schema.Number, Schema.Boolean])
export const WebSocketErrorEvent = Schema.StructWithRest(
Schema.Struct({
type: Schema.tag("error"),
status: Schema.optional(Schema.Number),
status_code: Schema.optional(Schema.Number),
code: optionalNull(Schema.String),
message: Schema.optional(Schema.String),
param: optionalNull(Schema.String),
error: optionalNull(OpenResponsesErrorPayload),
headers: Schema.optional(Schema.Record(Schema.String, WebSocketErrorHeader)),
}),
[Schema.Record(Schema.String, Schema.Unknown)],
)
const decodeWebSocketErrorEvent = Schema.decodeUnknownEffect(WebSocketErrorEvent)
export const decodeKnownErrorEvent = (event: Event) =>
decodeWebSocketErrorEvent({
...event,
status: typeof event.status === "number" ? event.status : undefined,
status_code: typeof event.status_code === "number" ? event.status_code : undefined,
headers: ProviderShared.isRecord(event.headers)
? Object.fromEntries(
Object.entries(event.headers).filter(
(entry): entry is [string, string | number | boolean] =>
typeof entry[1] === "string" || typeof entry[1] === "number" || typeof entry[1] === "boolean",
),
)
: undefined,
})
const asText = (value: unknown) =>
typeof value === "string" && value.length > 0 ? value : typeof value === "number" ? String(value) : undefined
export const Event = Schema.StructWithRest(
Schema.Struct({
@@ -400,31 +360,18 @@ export const Event = Schema.StructWithRest(
incomplete_details: optionalNull(Schema.Struct({ reason: Schema.optional(Schema.String) })),
output: Schema.optional(Schema.Array(StreamItem)),
usage: optionalNull(OpenResponsesUsage),
error: optionalNull(OpenResponsesErrorPayload),
error: Schema.optional(Schema.Unknown),
}),
[Schema.Record(Schema.String, Schema.Unknown)],
),
),
code: optionalNull(Schema.String),
message: Schema.optional(Schema.String),
param: optionalNull(Schema.String),
error: optionalNull(OpenResponsesErrorPayload),
code: Schema.optional(Schema.Unknown),
message: Schema.optional(Schema.Unknown),
error: Schema.optional(Schema.Unknown),
status: Schema.optional(Schema.Unknown),
status_code: Schema.optional(Schema.Unknown),
headers: Schema.optional(Schema.Unknown),
}),
[Schema.Record(Schema.String, Schema.Unknown)],
).pipe(
Schema.decode({
decode: SchemaGetter.transform((event) => {
if (event.type !== "error" || event.error != null) return event
const { code, message, param, ...rest } = event
if (code === undefined && message === undefined && param === undefined) return event
// Flat errors (for example, Meta's) can also arrive through generic Responses endpoints.
return { ...rest, error: { code, message, param } }
}),
encode: SchemaGetter.passthrough(),
}),
)
export type Event = Schema.Schema.Type<typeof Event>
export type NormalizedEvent = Event & { readonly item?: OutputItem | null }
@@ -433,16 +380,15 @@ const decodeEventValue = Schema.decodeUnknownEffect(Event)
const decodeFrame = Schema.decodeUnknownEffect(ProviderShared.Json)
/**
* Decodes one WebSocket frame. xAI answers a rejected `response.create` with `{ "error": { "message", "type" } }` and no
* event type; that envelope reads as an error event so the failure classifies instead of failing decoding.
* Decodes one WebSocket frame. Some providers and gateways answer a rejected `response.create` with a bare
* `{ "error": ... }` envelope and no event type; that reads as an error event so it classifies instead of
* failing decoding.
*/
export const decodeChannelEvent = (frame: string) =>
decodeFrame(frame).pipe(
Effect.flatMap((value) =>
decodeEventValue(
ProviderShared.isRecord(value) &&
value.type === undefined &&
(typeof value.error === "string" || ProviderShared.isRecord(value.error))
ProviderShared.isRecord(value) && value.type === undefined && value.error != null
? { ...value, type: "error" }
: value,
),
@@ -1422,22 +1368,21 @@ const onResponseFinish = Effect.fn("OpenResponses.onResponseFinish")(function* (
return [{ ...current, lifecycle }, events] satisfies StepResult
})
// Build the prettiest summary available from whatever the provider supplied.
// When both code and message are present, prefix the code so consumers see
// the failure mode (e.g. `rate_limit_exceeded: Slow down`) instead of just
// the bare message — production rate limits and context-length failures used
// to be indistinguishable from generic stream drops. Returns undefined when
// the payload carries no usable summary.
const providerErrorMessage = (event: Event, nested: OpenResponsesErrorPayload | undefined): string | undefined => {
const message = event.message || nested?.message || undefined
const code = event.code || nested?.code || undefined
if (message && code) return `${code}: ${message}`
return message || code
/** Error code and message from wherever the frame put them; top-level fields win over nested ones. */
export const errorDetail = (event: Event) => {
const raw = event.error ?? event.response?.error
const nested = typeof raw === "string" ? { message: raw } : ProviderShared.isRecord(raw) ? raw : undefined
return {
message: asText(event.message) ?? asText(nested?.message),
code: asText(event.code) ?? asText(nested?.code),
}
}
// Prefix the code when both are present (`rate_limit_exceeded: Slow down`) so the failure mode is
// visible; fall back to the raw frame rather than a generic message when neither decodes.
export const providerFailure = (event: Event, fallback: string, body = ProviderShared.encodeJson(event)) => {
const nested = event.error ?? event.response?.error ?? undefined
const summary = providerErrorMessage(event, nested)
const detail = errorDetail(event)
const summary = detail.message && detail.code ? `${detail.code}: ${detail.message}` : (detail.message ?? detail.code)
const message = summary ?? (body === "{}" ? fallback : body)
const status =
typeof event.status === "number"
@@ -1520,18 +1465,7 @@ export const step = (state: ParserState, event: NormalizedEvent) => {
if (event.type === "response.output_item.done") return onOutputItemDone(state, event.item)
if (event.type === "response.completed" || event.type === "response.incomplete") return onResponseFinish(state, event)
if (event.type === "response.failed") return providerFailure(event, `${state.name} response failed`)
if (event.type === "error")
return decodeKnownErrorEvent(event).pipe(
Effect.mapError((cause) =>
ProviderShared.eventError(
state.id,
`${state.name} returned a malformed error event`,
ProviderShared.encodeJson(event),
cause,
),
),
Effect.flatMap(() => providerFailure(event, `${state.name} stream error`)),
)
if (event.type === "error") return providerFailure(event, `${state.name} stream error`)
return Effect.succeed<StepResult>([state, NO_EVENTS])
}
+4 -14
View File
@@ -215,7 +215,9 @@ export const fromWebSocket = (
): Effect.Effect<WebSocketConnection, AIError> =>
Effect.gen(function* () {
yield* waitOpen(ws, input)
const messages = yield* Queue.bounded<string | Uint8Array, AIError | Cause.Done<void>>(128)
// The socket pushes frames synchronously and cannot be paused, so the hand-off to the consumer
// fiber must absorb whole read buffers. Bun delivers over a thousand small frames in one tick.
const messages = yield* Queue.unbounded<string | Uint8Array, AIError | Cause.Done<void>>()
const oversized = (message: string | Uint8Array) =>
typeof message === "string" ? new Blob([message]).size > MAX_FRAME_BYTES : message.byteLength > MAX_FRAME_BYTES
@@ -238,19 +240,7 @@ export const fromWebSocket = (
}
const offer = (message: string | Uint8Array) => {
if (rejectOversized(message)) return
if (Queue.offerUnsafe(messages, message)) return
Queue.failCauseUnsafe(
messages,
Cause.fail(
transportError("WebSocket inbound queue overflow", {
body: typeof message === "string" ? message : new TextDecoder().decode(message),
url: input.url,
operation: "read",
code: "queue-overflow",
phase: "receive",
}),
),
)
Queue.offerUnsafe(messages, message)
}
const onMessage = (event: MessageEvent) => {
@@ -11,66 +11,78 @@ import { sseEvents } from "../lib/sse.js"
const decodeEvent = Schema.decodeUnknownEffect(OpenResponses.protocol.stream.event)
it.effect("normalizes flat errors in shared SSE and WebSocket decoding", () =>
it.effect("decodes error frames verbatim in shared SSE and WebSocket decoding", () =>
Effect.gen(function* () {
const frame = {
type: "error",
sequence_number: 4,
code: "server_shutting_down",
message: "Server is shutting down. Please retry your request.",
param: null,
}
for (const decode of [decodeEvent, OpenResponses.decodeChannelEvent]) {
const event = yield* decode(JSON.stringify(frame))
expect(event).toEqual({
type: "error",
sequence_number: 4,
error: { code: frame.code, message: frame.message, param: null },
})
for (const unchanged of [
event,
for (const frame of [
{ type: "error", sequence_number: 4, code: "server_shutting_down", message: "Shutting down", param: null },
{ type: "error" },
{ type: "error", error: "Gateway failed" },
{ type: "error", error: { code: 429, message: "slow down" } },
{ type: "error", error: 42 },
{ type: "error", code: 500, message: ["not", "a", "string"] },
{ type: "response.failed", response: { id: "resp_failed", error: "Gateway failed" } },
{ type: "response.failed", response: { id: "resp_failed", error: ["weird"] } },
{
type: "response.failed",
response: { id: "resp_failed", error: { code: "server_error", message: "Internal server error" } },
},
{ type: "response.output_text.delta", item_id: "msg_text", delta: "Hello" },
]) {
expect(yield* decode(JSON.stringify(unchanged))).toEqual(unchanged)
expect(yield* decode(JSON.stringify(frame))).toEqual(frame)
}
}
}),
)
it.effect("continues to normalize untyped xAI WebSocket errors", () =>
it.effect("reads bare WebSocket error envelopes as error events", () =>
Effect.gen(function* () {
const message = "gRPC error: Response with id=resp_missing not found"
for (const error of [{ type: "api_error", message }, message]) {
expect(yield* OpenResponses.decodeChannelEvent(JSON.stringify({ error }))).toEqual({
type: "error",
error: typeof error === "string" ? { message } : error,
})
for (const error of [{ type: "api_error", message }, message, 42]) {
expect(yield* OpenResponses.decodeChannelEvent(JSON.stringify({ error }))).toEqual({ type: "error", error })
}
for (const frame of [{ error: null }, { message }]) {
expect(yield* OpenResponses.decodeChannelEvent(JSON.stringify(frame)).pipe(Effect.flip)).toBeDefined()
}
}),
)
it.effect("normalizes string errors in shared SSE and WebSocket decoding", () =>
it.effect("extracts error details from every shape and falls back to the raw frame", () =>
Effect.gen(function* () {
for (const decode of [decodeEvent, OpenResponses.decodeChannelEvent]) {
expect(yield* decode(JSON.stringify({ type: "error", error: "Gateway failed" }))).toEqual({
type: "error",
error: { message: "Gateway failed" },
})
expect(
yield* decode(
JSON.stringify({ type: "response.failed", response: { id: "resp_failed", error: "Gateway failed" } }),
),
).toEqual({
type: "response.failed",
response: { id: "resp_failed", error: { message: "Gateway failed" } },
})
const cases: Array<[frame: Record<string, unknown>, message: string, tag: string]> = [
[
{ type: "error", code: "server_shutting_down", message: "Shutting down" },
"server_shutting_down: Shutting down",
"UnknownProvider",
],
[{ type: "error", error: "Gateway failed" }, "Gateway failed", "UnknownProvider"],
[{ type: "error", error: { code: 429, message: "slow down" } }, "429: slow down", "UnknownProvider"],
[{ type: "error", error: { message: "slow down" }, status: 429 }, "slow down", "RateLimit"],
[{ type: "error", code: 500, message: ["not", "a", "string"] }, "500", "UnknownProvider"],
[
{ type: "response.failed", response: { id: "resp_failed", error: "Gateway failed" } },
"Gateway failed",
"UnknownProvider",
],
]
for (const [frame, message, tag] of cases) {
const event = yield* OpenResponses.decodeChannelEvent(JSON.stringify(frame))
const error = OpenResponses.providerFailure(event, "fallback", JSON.stringify(frame))
expect(error.message).toBe(message)
expect(error.reason._tag).toBe(tag)
expect(error.reason.body).toBe(JSON.stringify(frame))
}
for (const frame of [
{ type: "error", error: 42 },
{ type: "response.failed", response: { id: "resp_failed", error: ["weird"] } },
]) {
const event = yield* OpenResponses.decodeChannelEvent(JSON.stringify(frame))
const error = OpenResponses.providerFailure(event, "fallback", JSON.stringify(frame))
expect(error.message).toBe(JSON.stringify(frame))
expect(error.reason._tag).toBe("UnknownProvider")
}
expect(OpenResponses.providerFailure({ type: "error" }, "fallback", "{}").message).toBe("fallback")
expect(OpenResponses.providerFailure({ type: "error" }, "fallback", "{}").reason._tag).toBe("ProviderInternal")
}),
)
@@ -19,6 +19,13 @@ export function mountBrowserPane() {
session: "Alpha",
mounted: true,
visible: true,
url: undefined as string | undefined,
loading: false,
generation: 0,
delayNavigation: false,
pendingURL: undefined as string | undefined,
loadErrors: {} as Record<string, string | undefined>,
error: undefined as string | undefined,
layouts: {} as Record<string, BrowserPaneLayout | undefined>,
})
const tabs = ["Alpha", "Beta"].map((name) => ({
@@ -47,13 +54,33 @@ export function mountBrowserPane() {
opened: () => !!registrations.get(store.session),
state: () => ({ tabs: tabs.filter((tab) => tab.title === store.session), focusedTabID: null }),
tabs: () => tabs.filter((tab) => tab.title === store.session),
active: () => tabs.find((tab) => tab.title === store.session) ?? tabs[0],
active: () => {
const tab = tabs.find((tab) => tab.title === store.session) ?? tabs[0]
return {
...tab,
url: store.url ?? tab.url,
loading: store.loading,
generation: store.generation,
loadError: store.loadErrors[store.session],
}
},
registration: () => registrations.get(store.session),
error: () => undefined,
error: () => store.error ?? (store.loadErrors[store.session] ? "Request failed" : undefined),
suspended: () => false,
close: () => undefined,
open: () => undefined,
command: () => undefined,
command: (command) => {
setStore("error", undefined)
if (command.type === "navigate" || command.type === "reload") setStore("loadErrors", store.session, undefined)
if (command.type === "navigate") {
if (store.delayNavigation) {
setStore("pendingURL", command.url)
return
}
setStore({ url: command.url, loading: false, generation: store.generation + 1 })
}
if (command.type === "stop") setStore("loading", false)
},
}
return (
<>
@@ -64,6 +91,32 @@ export function mountBrowserPane() {
{(name) => <button onClick={() => setStore({ session: name, mounted: name !== "Empty" })}>{name}</button>}
</For>
<button onClick={() => setStore("mounted", false)}>Unmount pane</button>
<button onClick={() => setStore({ url: "about:blank", loading: false })}>Blank page</button>
<button onClick={() => setStore({ url: "about:blank", loading: true })}>Loading page</button>
<button
onClick={() =>
setStore({ loading: true, generation: store.generation + 1, loadErrors: { [store.session]: undefined } })
}
>
Load current page
</button>
<button onClick={() => setStore("loadErrors", store.session, "ERR_CONNECTION_REFUSED")}>Failed page</button>
<button onClick={() => setStore("delayNavigation", true)}>Delay navigation</button>
<button onClick={() => setStore({ error: "ERR_BLOCKED_BY_CLIENT", pendingURL: undefined })}>
Block navigation
</button>
<button
onClick={() =>
setStore({
url: store.pendingURL,
pendingURL: undefined,
loading: false,
generation: store.generation + 1,
})
}
>
Complete navigation
</button>
<button onClick={() => setStore("visible", (visible) => !visible)}>Toggle Review tab</button>
</nav>
<div style={{ width: "640px", height: "360px", border: "1px solid #555" }}>
@@ -57,3 +57,153 @@ story("hides the native view immediately while the pane stays mounted", async ({
await toggle.click()
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "true")
})
story("shows the empty state over a blank native page and restores navigation", async ({ page }) => {
const root = page.getByTestId("browser-pane-fixture")
await root.getByRole("button", { name: "Blank page", exact: true }).click()
await expect(root.getByText("Enter URL", { exact: true })).toBeVisible()
await expect(root.getByText('Or prompt "Open in the app browser"', { exact: true })).toBeVisible()
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "false")
await expect(root.getByRole("button", { name: "Reload", exact: true })).toBeDisabled()
const address = root.getByRole("textbox", { name: "Browser address", exact: true })
await address.fill("https://example.com/")
await expect(root.getByText("Enter URL", { exact: true })).toBeVisible()
await address.press("Enter")
await expect(address).not.toBeFocused()
await expect(root.getByText("Enter URL", { exact: true })).toBeHidden()
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "true")
await expect(root.getByRole("button", { name: "Reload", exact: true })).toBeEnabled()
})
story("keeps Stop available and hides the empty state while a blank page loads", async ({ page }) => {
const root = page.getByTestId("browser-pane-fixture")
await root.getByRole("button", { name: "Loading page", exact: true }).click()
await expect(root.getByText("Enter URL", { exact: true })).toBeHidden()
await root.getByRole("button", { name: "Stop", exact: true }).click()
await expect(root.getByText("Enter URL", { exact: true })).toBeVisible()
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "false")
})
story("keeps the submitted URL visible until the browser reports navigation", async ({ page }) => {
const root = page.getByTestId("browser-pane-fixture")
await root.getByRole("button", { name: "Blank page", exact: true }).click()
await root.getByRole("button", { name: "Delay navigation", exact: true }).click()
const address = root.getByRole("textbox", { name: "Browser address", exact: true })
await address.fill("https://example.com/")
await address.press("Enter")
await expect(address).not.toBeFocused()
await expect(address).toHaveValue("https://example.com/")
await expect(root.getByText("Enter URL", { exact: true })).toBeHidden()
await root.getByRole("button", { name: "Complete navigation", exact: true }).click()
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "true")
await expect(address).toHaveValue("https://example.com/")
await address.fill("https://unsubmitted.example/")
await root.getByRole("button", { name: "Delay navigation", exact: true }).click()
await expect(address).toHaveValue("https://example.com/")
})
story("restores the current URL when a submitted navigation is blocked", async ({ page }) => {
const root = page.getByTestId("browser-pane-fixture")
await root.getByRole("button", { name: "Delay navigation", exact: true }).click()
const address = root.getByRole("textbox", { name: "Browser address", exact: true })
await address.fill("https://blocked.example/")
await address.press("Enter")
await expect(address).toHaveValue("https://blocked.example/")
await root.getByRole("button", { name: "Block navigation", exact: true }).click()
await expect(root.getByText("ERR_BLOCKED_BY_CLIENT", { exact: true })).toBeVisible()
await expect(address).toHaveValue("https://alpha.example/")
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "true")
})
story("shows a themed failure state for only the failed tab and allows retry", async ({ page }) => {
const root = page.getByTestId("browser-pane-fixture")
await root.getByRole("button", { name: "Failed page", exact: true }).click()
await expect(root.getByText("URL can't be reached", { exact: true })).toBeVisible()
await expect(root.getByText("Check the URL and your connection, then try again.", { exact: true })).toBeVisible()
await expect(root.getByText("Request failed", { exact: true })).toBeHidden()
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "false")
await expect(root.getByRole("textbox", { name: "Browser address", exact: true })).toHaveValue(
"https://alpha.example/",
)
await root.getByRole("button", { name: "Beta", exact: true }).click()
await expect(root.getByText("URL can't be reached", { exact: true })).toBeHidden()
await expect(root.getByTestId("native-Beta")).toHaveAttribute("data-visible", "true")
await root.getByRole("button", { name: "Alpha", exact: true }).click()
await expect(root.getByText("URL can't be reached", { exact: true })).toBeVisible()
await root.getByRole("button", { name: "Reload", exact: true }).click()
await expect(root.getByText("URL can't be reached", { exact: true })).toBeHidden()
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "true")
})
story("returns a failed tab to the empty state when an empty URL is submitted", async ({ page }) => {
const root = page.getByTestId("browser-pane-fixture")
await root.getByRole("button", { name: "Failed page", exact: true }).click()
await expect(root.getByText("URL can't be reached", { exact: true })).toBeVisible()
await root.getByRole("button", { name: "Delay navigation", exact: true }).click()
const address = root.getByRole("textbox", { name: "Browser address", exact: true })
await address.fill("")
await address.press("Enter")
await expect(address).not.toBeFocused()
await expect(address).toHaveValue("")
await root.getByRole("button", { name: "Load current page", exact: true }).click()
await expect(root.getByRole("button", { name: "Stop", exact: true })).toBeEnabled()
await root.getByRole("button", { name: "Complete navigation", exact: true }).click()
await expect(root.getByText("URL can't be reached", { exact: true })).toBeHidden()
await expect(root.getByText("Enter URL", { exact: true })).toBeVisible()
await expect(root.getByRole("button", { name: "Reload", exact: true })).toBeDisabled()
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "false")
})
story("selects the full URL when the address field gains focus", async ({ page }) => {
const root = page.getByTestId("browser-pane-fixture")
const address = root.getByRole("textbox", { name: "Browser address", exact: true })
await address.click()
await expect(address).toHaveJSProperty("selectionStart", 0)
await expect(address).toHaveJSProperty("selectionEnd", "https://alpha.example/".length)
await address.pressSequentially("https://example.com/")
await expect(address).toHaveValue("https://example.com/")
await address.press("Enter")
await expect(address).not.toBeFocused()
await address.focus()
await expect(address).toHaveJSProperty("selectionStart", 0)
await expect(address).toHaveJSProperty("selectionEnd", "https://example.com/".length)
await address.press("ArrowRight")
await address.click()
await expect(address).toHaveJSProperty("selectionStart", 0)
await expect(address).toHaveJSProperty("selectionEnd", "https://example.com/".length)
})
story("keeps the current page visible while a submitted URL loads", async ({ page }) => {
const root = page.getByTestId("browser-pane-fixture")
await root.getByRole("button", { name: "Delay navigation", exact: true }).click()
const address = root.getByRole("textbox", { name: "Browser address", exact: true })
await address.fill("https://example.com/")
await address.press("Enter")
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "true")
await expect(address).toHaveValue("https://example.com/")
await root.getByRole("button", { name: "Load current page", exact: true }).click()
await expect(root.getByRole("button", { name: "Stop", exact: true })).toBeEnabled()
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "true")
await root.getByRole("button", { name: "Complete navigation", exact: true }).click()
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "true")
await expect(address).toHaveValue("https://example.com/")
})
story("keeps the current page and restores its URL when an empty address is submitted", async ({ page }) => {
const root = page.getByTestId("browser-pane-fixture")
const address = root.getByRole("textbox", { name: "Browser address", exact: true })
await address.fill("")
await address.press("Enter")
await expect(address).not.toBeFocused()
await expect(address).toHaveValue("https://alpha.example/")
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "true")
await expect(root.getByText("Enter URL", { exact: true })).toBeHidden()
await expect(root.getByRole("button", { name: "Reload", exact: true })).toBeEnabled()
})
@@ -122,6 +122,35 @@ story("does not mask or pad controls when they fit", async ({ mount }) => {
await expect(controls).toHaveCSS("padding-inline-end", "0px")
})
story("grows suggestions while preserving visible timeline context", async ({ mount }) => {
const component = await mount("opencode-composer-flow--constrained-command-suggestions")
const boundary = component.locator('[data-slot="composer-suggestion-boundary-story"]')
const suggestions = component.locator('[data-component="composer-suggestions"]')
await expect(suggestions).toHaveCSS("max-height", "166px")
await expect(suggestions).toHaveCSS("scroll-padding-bottom", "18px")
await expect.poll(() => suggestions.evaluate((element) => element.scrollHeight > element.clientHeight)).toBe(true)
await expect
.poll(async () => {
const menu = await suggestions.boundingBox()
const items = await suggestions.locator("[data-suggestion-id]").evaluateAll((elements) =>
elements.map((element) => {
const rect = element.getBoundingClientRect()
return { top: rect.top, bottom: rect.bottom }
}),
)
if (!menu) return false
const bottom = menu.y + menu.height
return items.some((item) => item.top < bottom && item.bottom > bottom)
})
.toBe(true)
await boundary.evaluate((element) => {
element.style.height = "400px"
})
await expect(suggestions).toHaveCSS("max-height", "306px")
})
// ThemeProvider writes resolved token values into a <style> block, so toggling data-color-scheme by hand
// leaves every --v2-* variable at its previous value. Switch themes through the Storybook global instead.
for (const [theme, background] of [
@@ -17,14 +17,12 @@ const milestones = [
"electronSpawnStarted",
"debugEndpointReady",
"electronStarted",
"layersReady",
"windowVisible",
"serviceEnsureStarted",
"serviceSpawnRequested",
"serviceReady",
"backgroundLoadingReady",
"rendererViteConnected",
"rendererInitializationStarted",
"rendererInitializationReady",
"windowVisible",
"homeReady",
] as const
const phases = [
@@ -128,14 +126,12 @@ const milestonePatterns: ReadonlyArray<{ name: Milestone; text: string }> = [
{ name: "electronSpawnStarted", text: "starting electron app..." },
{ name: "debugEndpointReady", text: "DevTools listening on ws://" },
{ name: "electronStarted", text: "app starting" },
{ name: "layersReady", text: "layers ready" },
{ name: "windowVisible", text: "main window visible" },
{ name: "serviceEnsureStarted", text: "starting v2 background service" },
{ name: "serviceSpawnRequested", text: "v2 CLI background service starting" },
{ name: "serviceReady", text: "v2 CLI background service ready" },
{ name: "backgroundLoadingReady", text: "loading task finished" },
{ name: "rendererViteConnected", text: "[vite] connected." },
{ name: "rendererInitializationStarted", text: "awaiting server ready" },
{ name: "rendererInitializationReady", text: "server ready" },
{ name: "windowVisible", text: "main window visible" },
]
async function createColdProfile() {
@@ -404,14 +400,12 @@ function requireMilestones(observed: Partial<Record<Milestone, number>>) {
electronSpawnStarted: get("electronSpawnStarted"),
debugEndpointReady: get("debugEndpointReady"),
electronStarted: get("electronStarted"),
layersReady: get("layersReady"),
windowVisible: get("windowVisible"),
serviceEnsureStarted: get("serviceEnsureStarted"),
serviceSpawnRequested: get("serviceSpawnRequested"),
serviceReady: get("serviceReady"),
backgroundLoadingReady: get("backgroundLoadingReady"),
rendererViteConnected: get("rendererViteConnected"),
rendererInitializationStarted: get("rendererInitializationStarted"),
rendererInitializationReady: get("rendererInitializationReady"),
windowVisible: get("windowVisible"),
homeReady: get("homeReady"),
}
}
@@ -20,14 +20,12 @@ describe("desktop startup benchmark", () => {
["electronSpawnStarted", "starting electron app..."],
["debugEndpointReady", "DevTools listening on ws://"],
["electronStarted", "app starting"],
["layersReady", "layers ready"],
["windowVisible", "main window visible"],
["serviceEnsureStarted", "starting v2 background service"],
["serviceSpawnRequested", "v2 CLI background service starting"],
["serviceReady", "v2 CLI background service ready"],
["backgroundLoadingReady", "loading task finished"],
["rendererViteConnected", "[vite] connected."],
["rendererInitializationStarted", "awaiting server ready"],
["rendererInitializationReady", "server ready"],
["windowVisible", "main window visible"],
] as const
cases.forEach(([milestone, line]) => {
expect(milestoneForLine(`\u001b[32m${line}\u001b[39m`)).toBe(milestone)
@@ -58,14 +56,12 @@ function sample(run: number, commandToHomeReadyMs: number): DesktopStartupSample
electronSpawnStarted: 7,
debugEndpointReady: 8,
electronStarted: 9,
serviceEnsureStarted: 10,
serviceSpawnRequested: 11,
serviceReady: 12,
backgroundLoadingReady: 13,
rendererViteConnected: 14,
rendererInitializationStarted: 15,
rendererInitializationReady: 16,
windowVisible: 17,
layersReady: 10,
windowVisible: 11,
serviceEnsureStarted: 12,
serviceSpawnRequested: 13,
serviceReady: 14,
rendererViteConnected: 15,
homeReady: commandToHomeReadyMs,
}
return {
@@ -80,8 +76,8 @@ function sample(run: number, commandToHomeReadyMs: number): DesktopStartupSample
electronStartup: 2,
serviceSpawnWait: 1,
serviceProcessStartup: 1,
rendererStartup: commandToHomeReadyMs - 14,
visibleWindowToHome: commandToHomeReadyMs - 17,
rendererStartup: commandToHomeReadyMs - 15,
visibleWindowToHome: commandToHomeReadyMs - 11,
},
service: { version: "2.0.0-local-test", url: "http://127.0.0.1:3000", pid: run },
}
@@ -71,7 +71,7 @@ for (const direction of ["ltr", "rtl"] as const) {
const shifted = await content.evaluate((element) => getComputedStyle(element).translate)
await content.evaluate((element) => element.setAttribute("data-summary-motion", ""))
// Keep issuing resize events before the idle timer expires, including crossing the width cutoff.
for (const width of [1520, 1280, 1600]) {
for (const width of [1520, 1280, 1800]) {
await page.setViewportSize({ width, height: 900 })
await expect(panel).toHaveAttribute("data-summary-resizing", "true")
await page.clock.runFor(100)
@@ -84,6 +84,7 @@ for (const direction of ["ltr", "rtl"] as const) {
await expect(content).toHaveAttribute("data-summary-motion", "transitionrun,transitionend,")
await expect(content).not.toHaveCSS("translate", shifted)
await page.setViewportSize({ width: 1440, height: 900 })
await expect(panel).toHaveAttribute("data-summary-resizing", "false")
await expect(content).toHaveCSS("translate", shifted)
await content.evaluate((element) => element.setAttribute("data-summary-motion", ""))
@@ -112,7 +113,8 @@ for (const direction of ["ltr", "rtl"] as const) {
await expect(summary).toBeVisible()
await page.setViewportSize({ width: 1800, height: 900 })
await expect(content).toHaveCSS("translate", "0px")
await expect(panel).toHaveAttribute("data-summary-resizing", "false")
await expect(content).toHaveCSS("translate", "none")
await expect(summary).toBeVisible()
await page.emulateMedia({ reducedMotion: "reduce" })
@@ -116,7 +116,7 @@ test("single-server settings expose scoped pages without a server picker", async
await expect(connection.getByRole("heading", { name: "Connection", exact: true })).toBeVisible()
await expect(connection.locator('[data-component="settings-list"]')).toHaveCSS("padding-left", "16px")
await expect(connection.locator(".settings-servers-row")).toHaveCSS("padding-top", "20px")
await expect(connection.locator(".settings-servers-lead")).toHaveCSS("column-gap", "4px")
await expect(connection.locator(".settings-servers-lead")).toHaveCSS("column-gap", "10px")
await expect(connection.locator(".settings-servers-copy")).toHaveCSS("row-gap", "6px")
await expect(settings.getByRole("heading", { name: "Preferences", exact: true })).toBeVisible()
await expect(settings.getByText("Terminal shell", { exact: true })).toBeVisible()
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/app",
"version": "2.0.10",
"version": "2.0.12",
"description": "",
"type": "module",
"exports": {
+24 -1
View File
@@ -57,6 +57,8 @@ function ComposerStory(props: {
continueOnStop?: boolean
longLabels?: boolean
alternate?: "queue" | "steer"
manySuggestions?: boolean
suggestionBoundary?: () => HTMLElement | undefined
}) {
const [draft, setDraft] = createStore<ComposerPersistedState>({
prompt: props.prompt ?? [{ type: "text", content: "", start: 0, end: 0 }],
@@ -93,6 +95,15 @@ function ComposerStory(props: {
const commands: ComposerSuggestion[] = [
{ id: "command.test", kind: "command", label: "/test", trigger: "test", title: "Run tests" },
{ id: "command.review", kind: "command", label: "/review", trigger: "review", title: "Review changes" },
...(props.manySuggestions
? Array.from({ length: 12 }, (_, index) => ({
id: `command.example-${index}`,
kind: "command" as const,
label: `/example-${index}`,
trigger: `example-${index}`,
title: `Run example ${index}`,
}))
: []),
]
const context: ComposerSuggestion[] = [
{
@@ -206,7 +217,7 @@ function ComposerStory(props: {
<output class="text-12-regular text-text-weak" aria-live="polite">
{story.activity}
</output>
<Composer model={model} borderUnderlay />
<Composer model={model} borderUnderlay suggestionBoundary={props.suggestionBoundary} />
</div>
)
}
@@ -273,6 +284,18 @@ export const SlashSuggestions = { render: () => <ComposerStory suggestions="comm
export const ContextSuggestions = { render: () => <ComposerStory suggestions="context" /> }
function ConstrainedCommandSuggestionsStory() {
let boundary: HTMLDivElement | undefined
return (
<div class="mx-auto w-full max-w-200">
<div ref={boundary} data-slot="composer-suggestion-boundary-story" class="h-60" />
<ComposerStory suggestions="command" manySuggestions suggestionBoundary={() => boundary} />
</div>
)
}
export const ConstrainedCommandSuggestions = { render: () => <ConstrainedCommandSuggestionsStory /> }
export const RunningAndStopping = { render: () => <ComposerStory working stopping label="Session is running" /> }
export const SteeringFollowUp = {
+7 -1
View File
@@ -12,7 +12,12 @@ import { formatKeybind, useCommand } from "@/shell/commands/command"
import { useLanguage } from "@/runtime/i18n/language"
import type { ComposerModel } from "./model"
export function Composer(props: { class?: string; model: ComposerModel; borderUnderlay?: boolean }) {
export function Composer(props: {
class?: string
model: ComposerModel
borderUnderlay?: boolean
suggestionBoundary?: () => HTMLElement | undefined
}) {
const dialog = useDialog()
const command = useCommand()
const language = useLanguage()
@@ -28,6 +33,7 @@ export function Composer(props: { class?: string; model: ComposerModel; borderUn
attachShortcut={command.keybind("file.attach")}
alternateKeybind={[formatKeybind("mod", language.t), "↵"]}
exitShellKeybind={[formatKeybind("esc", language.t)]}
suggestionBoundary={props.suggestionBoundary}
modelControl={
<ComposerModelControl
loading={props.model.model.loading}
+36 -3
View File
@@ -1,5 +1,6 @@
import { createEffect, createMemo, createResource, createSignal, For, onCleanup, onMount, Show, type JSX } from "solid-js"
import { createStore } from "solid-js/store"
import { createResizeObserver } from "@solid-primitives/resize-observer"
import { FileIcon } from "@opencode/ui/file-icon"
import { Icon } from "@opencode/ui/icon"
import { IconButton } from "@opencode/ui/icon-button"
@@ -40,6 +41,12 @@ export type {
} from "../types"
export type ComposerMode = "normal" | "shell"
const COMPOSER_SUGGESTION_MAX_HEIGHT = 320
const COMPOSER_SUGGESTION_ROW_HEIGHT = 28
const COMPOSER_SUGGESTION_ROW_PEEK = 18
const COMPOSER_SUGGESTION_TOP_PADDING = 8
const COMPOSER_SUGGESTION_SEARCH_HEIGHT = 28
const COMPOSER_SUGGESTION_CONTEXT_RESERVE = 80
export type ComposerEditorProps = {
controller: ComposerEditorModel
@@ -53,6 +60,7 @@ export type ComposerEditorProps = {
attachShortcut?: string
alternateKeybind?: string[]
exitShellKeybind?: string[]
suggestionBoundary?: () => HTMLElement | undefined
}
export function ComposerEditor(props: ComposerEditorProps) {
@@ -117,6 +125,7 @@ export function ComposerEditor(props: ComposerEditorProps) {
<ComposerEditorPopover
emptyLabel={i18n.t("ui.promptInput.noMatchingItems")}
items={props.controller.suggestions()}
boundary={props.suggestionBoundary}
activeID={state.popover.type === "closed" ? undefined : state.popover.activeID}
search={
state.popover.type === "command-menu"
@@ -761,18 +770,29 @@ export function ComposerEditorPopover(props: {
onValueChange: (value: string) => void
onKeyDown: (event: KeyboardEvent) => void
}
boundary?: () => HTMLElement | undefined
onActiveChange: (item: ComposerSuggestion) => void
onSelect: (item: ComposerSuggestion) => void
}) {
const [store, setStore] = createStore({ maxHeight: COMPOSER_SUGGESTION_MAX_HEIGHT })
const resize = (height: number) =>
setStore("maxHeight", composerSuggestionMaxHeight(height, props.search !== undefined))
createEffect(() => resize(props.boundary?.()?.clientHeight ?? COMPOSER_SUGGESTION_MAX_HEIGHT * 2))
createResizeObserver(
props.boundary ?? (() => undefined),
(rect) => resize(rect.height),
)
return (
<div
data-component="composer-suggestions"
class="absolute inset-x-0 -top-2 z-40 flex max-h-80 -translate-y-full flex-col overflow-auto rounded-xl bg-v2-background-bg-base p-2 shadow-[var(--v2-elevation-raised)] no-scrollbar"
class="absolute inset-x-0 -top-2 z-40 flex -translate-y-full scroll-pb-[18px] flex-col overflow-auto rounded-xl bg-v2-background-bg-base p-2 shadow-[var(--v2-elevation-raised)] no-scrollbar"
style={{ "max-height": `${store.maxHeight}px` }}
onMouseDown={(event) => event.preventDefault()}
>
<Show when={props.search}>
{(search) => (
<div class="px-2 py-1">
<div class="shrink-0 px-2 py-1">
<input
ref={(element) => requestAnimationFrame(() => element.focus())}
value={search().value}
@@ -796,7 +816,7 @@ export function ComposerEditorPopover(props: {
type="button"
data-suggestion-id={item.id}
data-active={props.activeID === item.id ? "" : undefined}
class="flex w-full items-center gap-2 rounded-md px-2 py-1 text-start hover:bg-v2-overlay-simple-overlay-hover"
class="flex h-7 w-full shrink-0 items-center gap-2 rounded-md px-2 py-1 text-start hover:bg-v2-overlay-simple-overlay-hover"
classList={{ "bg-v2-overlay-simple-overlay-hover": props.activeID === item.id }}
onPointerMove={() => props.onActiveChange(item)}
onClick={() => props.onSelect(item)}
@@ -821,6 +841,19 @@ export function ComposerEditorPopover(props: {
)
}
function composerSuggestionMaxHeight(boundaryHeight: number, search: boolean) {
const reserve = Math.min(COMPOSER_SUGGESTION_CONTEXT_RESERVE, boundaryHeight / 4)
const limit = Math.min(COMPOSER_SUGGESTION_MAX_HEIGHT, boundaryHeight - reserve)
const chrome = COMPOSER_SUGGESTION_TOP_PADDING + (search ? COMPOSER_SUGGESTION_SEARCH_HEIGHT : 0)
if (limit < chrome + COMPOSER_SUGGESTION_ROW_HEIGHT + COMPOSER_SUGGESTION_ROW_PEEK) return limit
return (
chrome +
Math.floor((limit - chrome - COMPOSER_SUGGESTION_ROW_PEEK) / COMPOSER_SUGGESTION_ROW_HEIGHT) *
COMPOSER_SUGGESTION_ROW_HEIGHT +
COMPOSER_SUGGESTION_ROW_PEEK
)
}
// "Steer ⌘⏎" / "Queue ⌘⏎" hint next to the submit button: submits with the
// delivery opposite to what plain Enter does. Visible only while the queue
// exposes an alternate (turn running and composer holding a value), so it
+4 -4
View File
@@ -65,14 +65,14 @@
}
/* Keep the 1000px conversation intact, with 320px for the summary and its gutters.
Move only as far as needed; wider panels already have enough space in the margin. */
@container session-chat (min-width: 1320px) {
When they overlap, center the conversation in the space that remains beside the summary. */
@container session-chat (min-width: 1320px) and (width < 1640px) {
[data-slot="session-chat-panel"][data-summary-open="true"]
:is([data-timeline-virtual-content], [data-component="session-composer-dock"] > div) {
--session-summary-translate: min(0px, calc(50cqi - 820px));
--session-summary-translate: -160px;
&:dir(rtl) {
--session-summary-translate: max(0px, calc(820px - 50cqi));
--session-summary-translate: 160px;
}
}
}
+14
View File
@@ -625,6 +625,16 @@ export const dict = {
"toast.file.loadFailed.title": "Failed to load file",
"file.error.notFound": "File not found: {{name}}",
"toast.file.listFailed.title": "Failed to list files",
"file.view.preview": "Preview",
"file.view.source": "Source",
"file.view.openInBrowser": "Open in browser",
"file.view.binary": "Binary file · {{size}}",
"file.view.table.rows.one": "{{count}} row",
"file.view.table.rows.other": "{{count}} rows",
"file.view.table.columns.one": "{{count}} column",
"file.view.table.columns.other": "{{count}} columns",
"file.view.table.truncated": "Showing the first {{shown}} of {{total}} rows.",
"file.view.fontSample": "Sphinx of black quartz, judge my vow.",
"toast.context.noLineSelection.title": "No line selection",
"toast.context.noLineSelection.description": "Select a line range in a file tab first.",
@@ -906,6 +916,10 @@ export const dict = {
"session.browser.replaced": "Browser control moved to another desktop window.",
"session.browser.suspended": "Browser suspended. Interact with this session to reconnect.",
"session.browser.address.placeholder": "Enter URL",
"session.browser.empty.title": "Enter URL",
"session.browser.empty.description": 'Or prompt "Open in the app browser"',
"session.browser.failed.title": "URL can't be reached",
"session.browser.failed.description": "Check the URL and your connection, then try again.",
"titlebar.update": "Update",
"titlebar.tabs": "Tabs",
@@ -19,6 +19,7 @@ export type BrowserPaneCommand = Browser.Action
export type BrowserPaneState = Browser.State | null
export type BrowserPaneEvent =
| { type: "focus"; tabID: Browser.TabID }
| { type: "preview"; path: string }
| { type: "state"; state: BrowserPaneState; error?: string }
export type BrowserPaneRegistration = {
+2
View File
@@ -33,6 +33,8 @@ export type FileContent = {
}
encoding?: "base64"
mimeType?: string
/** On-disk size when the bytes themselves are not retained. */
size?: number
}
export type Path = {
+25 -11
View File
@@ -1,14 +1,15 @@
import { createEffect, createMemo, getOwner, on, onCleanup, runWithOwner } from "solid-js"
import { batch, createEffect, createMemo, createRoot, getOwner, on, onCleanup, runWithOwner } from "solid-js"
import { createStore, reconcile } from "solid-js/store"
import { createSimpleContext } from "@opencode/ui/context"
import type { Browser } from "@opencode/plugin-browser/rpc"
import { useLanguage } from "@/runtime/i18n/language"
import type { BrowserPaneCommand } from "@/runtime/platform/browser-pane"
import { usePlatform } from "@/runtime/platform/platform"
import type { useServer } from "@/runtime/server/current"
import type { SessionStateKey } from "@/runtime/server/scope"
import { useSettings } from "@/settings/model"
import { findSessionTab, tabKey, useTabs } from "@/shell/tabs/tabs"
import { useCurrentRoute } from "@/shell/state/layout"
import { useCurrentRoute, useLayout } from "@/shell/state/layout"
import { sessionBrowserTab } from "@/shell/state/session-tabs"
import { createEventListener } from "@solid-primitives/event-listener"
import { createBrowserConnection, type BrowserConnectionState } from "./connection"
@@ -35,13 +36,14 @@ export const { use: useBrowserAttachments, provider: BrowserAttachmentsProvider
const settings = useSettings()
const language = useLanguage()
const shellTabs = useTabs()
const layout = useLayout()
const route = useCurrentRoute()
const owner = getOwner()
const [store, setStore] = createStore<Record<string, BrowserAttachment | undefined>>({})
// Servers whose plugin lacks the browser RPC; sessions on them stop retrying.
const [unsupported, setUnsupported] = createStore<Record<string, true | undefined>>({})
const live = new Map<string, Live>()
const focus = new Map<string, Set<(tabID: Browser.TabID) => void>>()
const preview = new Map<string, Set<(path: string) => void>>()
const key = (server: Server, sessionID: string) => `${server.key}\n${sessionID}`
const enabled = createMemo(
() => !!platform.browserPane && settings.ready() && settings.general.experimentalBrowser(),
@@ -87,11 +89,14 @@ export const { use: useBrowserAttachments, provider: BrowserAttachmentsProvider
enabled,
supported: (server: Server) => !unsupported[server.key],
state: (server: Server, sessionID: string) => store[key(server, sessionID)],
attach(server: Server, sessionID: string) {
attach(server: Server, sessionID: string, sessionKey: SessionStateKey) {
const id = key(server, sessionID)
if (live.has(id)) return
const pane = platform.browserPane
if (!pane || !enabled() || unsupported[server.key] || server.health?.incompatible) return
// Focus requests write to the owning session's layout even while another shell tab is routed,
// so the Review pane and browser tab are already selected when the user returns to it.
const tabs = createRoot((dispose) => ({ dispose, layout: layout.tabs(sessionKey) }), owner)
const connection = createBrowserConnection({
pane,
// Resolve the current port at every wake, including after sidecar replacement.
@@ -100,7 +105,15 @@ export const { use: useBrowserAttachments, provider: BrowserAttachmentsProvider
sessionID,
endpoint: { ...server.conn.http, url: server.ctx.sdk.url },
}),
focus: (tabID) => focus.get(id)?.forEach((listener) => listener(tabID)),
focus: (tabID) => {
const tab = sessionBrowserTab(tabID)
batch(() => {
shellTabs.setPane(findSessionTab(shellTabs.store, server.key, sessionID), "review", true)
if (!tabs.layout.all().includes(tab)) tabs.layout.setAll([...tabs.layout.all(), tab])
tabs.layout.setActive(tab)
})
},
preview: (path) => preview.get(id)?.forEach((listener) => listener(path)),
change: (state) => {
if (state.error === "browser.pane.unsupported") {
setUnsupported(server.key, true)
@@ -138,17 +151,18 @@ export const { use: useBrowserAttachments, provider: BrowserAttachmentsProvider
entry.dispose = () => {
unsubscribe?.forEach((dispose) => dispose())
connection.dispose()
tabs.dispose()
}
},
/** Desktop focus requests for a mounted session route; nothing is replayed to routes mounted later. */
onFocus(server: Server, sessionID: string, listener: (tabID: Browser.TabID) => void) {
/** Agent requests to show a file in this session's Review pane. */
onPreview(server: Server, sessionID: string, listener: (path: string) => void) {
const id = key(server, sessionID)
const listeners = focus.get(id) ?? new Set()
const listeners = preview.get(id) ?? new Set()
listeners.add(listener)
focus.set(id, listeners)
preview.set(id, listeners)
return () => {
listeners.delete(listener)
if (!listeners.size) focus.delete(id)
if (!listeners.size) preview.delete(id)
}
},
command(server: Server, sessionID: string, command: BrowserPaneCommand) {
@@ -28,10 +28,12 @@ function fixture() {
commands: Browser.Action[]
}[] = []
const endpoint = { url: "http://localhost:4096" }
const previews: string[] = []
const connection = createBrowserConnection({
target: () => ({ serverKey: "browser-test", sessionID: "ses_browser", endpoint: { ...endpoint } }),
change: (state) => states.push(state),
focus: () => {},
preview: (path) => previews.push(path),
pane: {
register(target, emit) {
const call = { target, emit, closed: false, commands: [] as Browser.Action[] }
@@ -50,9 +52,21 @@ function fixture() {
})
connection.wake()
calls[0].emit({ type: "state", state: browser })
return { connection, calls, states, endpoint }
return { connection, calls, states, endpoint, previews }
}
test("preview requests reach the session without touching connection state", () => {
const app = fixture()
try {
const before = app.states.length
app.calls[0].emit({ type: "preview", path: "docs/report.pdf" })
expect(app.previews).toEqual(["docs/report.pdf"])
expect(app.states).toHaveLength(before)
} finally {
app.connection.dispose()
}
})
test("suspension retains tabs and reconnects once on demand using the current endpoint", async () => {
const app = fixture()
try {
@@ -19,6 +19,7 @@ export function createBrowserConnection(input: {
target: () => BrowserPaneTarget
change: (state: BrowserConnectionState) => void
focus: (tabID: Browser.TabID) => void
preview: (path: string) => void
}) {
const state: BrowserConnectionState = { browser: null, suspended: false }
let disposed = false
@@ -33,6 +34,7 @@ export function createBrowserConnection(input: {
(event) => {
if (disposed || state.registration !== registration) return
if (event.type === "focus") return input.focus(event.tabID)
if (event.type === "preview") return input.preview(event.path)
if (event.error === "browser.pane.unsupported" || event.error === "browser.pane.replaced") {
blocked = true
registration.close()
+2 -10
View File
@@ -1,4 +1,4 @@
import { batch, createEffect, createMemo, on, onCleanup } from "solid-js"
import { batch, createEffect, createMemo, on } from "solid-js"
import type { Browser } from "@opencode/plugin-browser/rpc"
import { createStore } from "solid-js/store"
import { useLanguage } from "@/runtime/i18n/language"
@@ -33,13 +33,6 @@ export function createSessionBrowser(session: SessionModel) {
attachment()?.browser?.tabs.filter((tab) => session.layout.tabs().all().includes(sessionBrowserTab(tab.id))) ??
[],
)
const focus = (tabID: Browser.TabID) => {
session.layout.view().reviewPanel.open()
const tabs = session.layout.tabs()
const key = sessionBrowserTab(tabID)
if (!tabs.all().includes(key)) tabs.setAll([...tabs.all(), key])
tabs.setActive(key)
}
const command = (command: BrowserPaneCommand) => {
const sessionID = session.identity.sessionID()
if (!sessionID) return
@@ -66,8 +59,7 @@ export function createSessionBrowser(session: SessionModel) {
createEffect(() => {
const sessionID = session.identity.sessionID()
if (!sessionID) return
if (attachments.enabled()) attachments.attach(server, sessionID)
onCleanup(attachments.onFocus(server, sessionID, focus))
if (attachments.enabled()) attachments.attach(server, sessionID, session.layout.sessionKey())
})
createEffect(
on(
+63 -10
View File
@@ -20,13 +20,18 @@ export function SessionBrowserPane(props: { browser: ReturnType<typeof createSes
const command = useCommand()
const state = props.browser.active
const address = () => (state()?.url === "about:blank" ? "" : (state()?.url ?? ""))
const failed = () => !!state()?.loadError
const registration = props.browser.registration
const button = { variant: "ghost", size: "large" } as const
const [store, setStore] = createStore({
address: "",
editing: false,
submitted: false,
// A submitted navigation the browser has not reported yet; keeps the empty state hidden meanwhile.
navigating: false,
visible: typeof document === "undefined" || document.visibilityState === "visible",
})
const empty = () => !address() && !state()?.loading && !store.navigating
let surface: HTMLDivElement | undefined
let addressDisplay: HTMLDivElement | undefined
let frame: number | undefined
@@ -43,7 +48,7 @@ export function SessionBrowserPane(props: { browser: ReturnType<typeof createSes
title: language.t("command.browser.reload"),
category: language.t("command.category.view"),
keybind: "f5",
disabled: !props.visible || !state(),
disabled: !props.visible || !address(),
onSelect: () => {
const tab = state()
if (tab) props.browser.command({ type: "reload", tabID: tab.id })
@@ -71,7 +76,9 @@ export function SessionBrowserPane(props: { browser: ReturnType<typeof createSes
const top = Math.round(rect.top * zoom)
const right = Math.round(rect.right * zoom)
const bottom = Math.round(rect.bottom * zoom)
const visible = props.visible && store.visible && !dialog.active && !covered(rect)
// The desktop page hides blank and loading documents itself; only hide here
// while the pane shows its own empty or failed state over the surface.
const visible = props.visible && store.visible && !empty() && !failed() && !dialog.active && !covered(rect)
// The cutout exposes the app backdrop outside the rounded Review card,
// not the browser surface inside it.
const color = getComputedStyle(
@@ -107,7 +114,25 @@ export function SessionBrowserPane(props: { browser: ReturnType<typeof createSes
if (frame === undefined) frame = requestAnimationFrame(tick)
}
createEffect(() => !store.editing && setStore("address", address()))
createEffect(on([() => state()?.id, address], () => !store.editing && setStore("address", address())))
// Any reported movement, including a rejected or blocked request, ends the submitted navigation.
createEffect(
on(
[() => state()?.id, () => state()?.generation, () => state()?.loading, () => props.browser.error()],
() => setStore("navigating", false),
{ defer: true },
),
)
// A blocked or rejected submission leaves the page where it was; show that page's URL again.
createEffect(
on(
() => props.browser.error(),
(error) => {
if (error && !store.editing) setStore("address", address())
},
{ defer: true },
),
)
createEffect(
on(registration, (current) => {
// Session routes can change before this pane unmounts. Hide the registration
@@ -123,6 +148,8 @@ export function SessionBrowserPane(props: { browser: ReturnType<typeof createSes
() => store.visible,
() => props.visible,
() => state()?.id,
empty,
failed,
registration,
],
() => {
@@ -157,7 +184,7 @@ export function SessionBrowserPane(props: { browser: ReturnType<typeof createSes
return (
<aside id="browser-panel" class="relative size-full min-w-0 overflow-hidden bg-v2-background-bg-base flex flex-col">
<div class="h-10 shrink-0 flex items-center gap-1 px-2 border-b border-v2-border-border-muted">
<div class="h-10 shrink-0 flex items-center gap-1 px-3 border-b border-v2-border-border-muted">
<For each={["back", "forward"] as const}>
{(direction) => (
<Tooltip placement="top" value={language.t(direction === "back" ? "common.goBack" : "common.goForward")}>
@@ -193,7 +220,7 @@ export function SessionBrowserPane(props: { browser: ReturnType<typeof createSes
>
<IconButton
{...button}
disabled={!state()}
disabled={!state()?.loading && !address()}
aria-label={language.t(state()?.loading ? "prompt.action.stop" : "error.page.action.reload")}
onClick={() => {
const tab = state()
@@ -212,8 +239,13 @@ export function SessionBrowserPane(props: { browser: ReturnType<typeof createSes
onSubmit={(event) => {
event.preventDefault()
const tab = state()
if (tab && store.address.trim())
props.browser.command({ type: "navigate", tabID: tab.id, url: store.address })
const url = store.address.trim()
if (!tab) return
if (url || failed()) {
setStore({ submitted: true, address: url, navigating: true })
props.browser.command({ type: "navigate", tabID: tab.id, url: url || "about:blank" })
}
event.currentTarget.querySelector("input")?.blur()
}}
>
<input
@@ -224,8 +256,14 @@ export function SessionBrowserPane(props: { browser: ReturnType<typeof createSes
disabled={!state()}
placeholder={language.t("session.browser.address.placeholder")}
aria-label={language.t("session.browser.address")}
onFocus={() => setStore("editing", true)}
onBlur={() => setStore({ editing: false, address: address() })}
onFocus={(event) => {
setStore("editing", true)
event.currentTarget.select()
}}
onClick={(event) => event.currentTarget.select()}
onBlur={() =>
setStore({ editing: false, address: store.submitted ? store.address : address(), submitted: false })
}
onInput={(event) => setStore("address", event.currentTarget.value)}
onScroll={(event) => {
if (addressDisplay) addressDisplay.scrollLeft = event.currentTarget.scrollLeft
@@ -243,12 +281,27 @@ export function SessionBrowserPane(props: { browser: ReturnType<typeof createSes
</div>
</form>
</div>
<Show when={props.browser.error()}>
<Show when={props.browser.error() && !failed()}>
<div class="shrink-0 px-3 py-1.5 text-12-regular text-text-danger-base border-b border-v2-border-border-muted">
{props.browser.error()}
</div>
</Show>
<div ref={surface} class="min-h-0 flex-1 bg-v2-background-bg-base flex items-center justify-center">
<Show when={(empty() || failed()) && !props.browser.suspended()}>
{/* Add the 40px toolbar to the file empty state's 160px bottom padding to align their centers. */}
<div
dir="auto"
class="flex size-full flex-col items-center justify-center gap-2 p-6 pb-[200px] text-center text-text-weak"
>
<Icon name="globe" size="large" class="mb-2 shrink-0" />
<div class="text-[13px] font-medium leading-[var(--line-height-compact)] text-text-strong">
{language.t(failed() ? "session.browser.failed.title" : "session.browser.empty.title")}
</div>
<div class="text-13-regular leading-[var(--line-height-base)]">
{language.t(failed() ? "session.browser.failed.description" : "session.browser.empty.description")}
</div>
</div>
</Show>
<Show when={props.browser.suspended()}>
<p class="px-6 text-center text-13-regular text-v2-text-text-subtle" role="status">
{language.t("session.browser.suspended")}
+5 -2
View File
@@ -213,7 +213,10 @@ export function createActiveSessionRegion(input: {
export type ActiveSessionRegionModel = ReturnType<typeof createActiveSessionRegion>
export function ActiveSessionComposerRegion(props: { model: SessionComposerController }) {
export function ActiveSessionComposerRegion(props: {
model: SessionComposerController
suggestionBoundary: () => HTMLElement | undefined
}) {
return (
<SessionComposerRegion
controller={props.model.region}
@@ -221,7 +224,7 @@ export function ActiveSessionComposerRegion(props: { model: SessionComposerContr
<div class="relative">
<SessionQueuePanel queue={props.model.queue} />
<div class="relative z-10">
<Composer model={props.model.composer} borderUnderlay />
<Composer model={props.model.composer} borderUnderlay suggestionBoundary={props.suggestionBoundary} />
</div>
</div>
}
@@ -0,0 +1,94 @@
/* The control defaults to a fixed 232px with equal segments; here it hugs its two labels. */
[data-slot="artifact-toolbar"] [data-slot="segmented-control-v2"] {
width: auto;
}
[data-slot="artifact-toolbar"] [data-slot="segmented-control-v2-item"] {
flex: 0 0 auto;
padding: 0 14px;
}
/* Shrink-wrapped segments round the label box down a fraction; the ellipsis overflow then clips glyph edges. */
[data-slot="artifact-toolbar"] [data-slot="segmented-control-v2-item-label"] {
overflow: visible;
max-width: none;
}
[data-slot="artifact-stage"] {
--artifact-check: color-mix(in oklch, var(--v2-text-text-base) 5%, transparent);
background-color: var(--v2-background-bg-deep);
}
[data-slot="artifact-stage"][data-checker] {
background-image:
linear-gradient(45deg, var(--artifact-check) 25%, transparent 25%),
linear-gradient(-45deg, var(--artifact-check) 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, var(--artifact-check) 75%),
linear-gradient(-45deg, transparent 75%, var(--artifact-check) 75%);
background-size: 16px 16px;
background-position:
0 0,
0 8px,
8px -8px,
-8px 0;
}
[data-slot="artifact-media"] {
display: block;
border-radius: 4px;
box-shadow:
0 0 0 0.5px var(--v2-border-border-base),
0 12px 32px -12px color-mix(in oklch, var(--v2-text-text-base) 25%, transparent);
}
[data-slot="artifact-stage"][data-zoom="fit"] [data-slot="artifact-media"] {
max-width: 100%;
max-height: 100%;
object-fit: contain;
}
[data-slot="artifact-stage"][data-zoom="fit"][data-overflow] [data-slot="artifact-media"] {
cursor: zoom-in;
}
[data-slot="artifact-stage"][data-zoom="actual"] [data-slot="artifact-media"] {
cursor: zoom-out;
}
[data-slot="artifact-table"] {
border-collapse: separate;
border-spacing: 0;
font-variant-numeric: tabular-nums;
}
[data-slot="artifact-table"] th,
[data-slot="artifact-table"] td {
padding: 5px 12px;
border-bottom: 1px solid var(--v2-border-border-muted);
border-inline-end: 1px solid var(--v2-border-border-muted);
white-space: pre;
max-width: 40ch;
overflow: hidden;
text-overflow: ellipsis;
text-align: start;
line-height: var(--line-height-compact);
}
[data-slot="artifact-table"] th {
position: sticky;
top: 0;
z-index: 1;
background: var(--v2-background-bg-layer-01);
font-weight: var(--font-weight-medium);
color: var(--v2-text-text-muted);
}
[data-slot="artifact-table"] tbody tr:hover td {
background: color-mix(in oklch, var(--v2-text-text-base) 3%, transparent);
}
[data-slot="artifact-table"] td[data-index] {
color: var(--v2-text-text-faint);
text-align: end;
user-select: none;
}
@@ -0,0 +1,454 @@
import { createEffect, createMemo, For, Match, on, onCleanup, Show, Switch, type JSX } from "solid-js"
import { createStore } from "solid-js/store"
import { createResizeObserver } from "@solid-primitives/resize-observer"
import { Button } from "@opencode/ui/button"
import { FileIcon } from "@opencode/ui/file-icon"
import { SegmentedControl, SegmentedControlItem } from "@opencode/ui/segmented-control"
import { ScrollView } from "@opencode/ui/scroll-view"
import { Markdown } from "@opencode/session-ui/markdown"
import { MarkdownProvider, useMarkdown } from "@opencode/session-ui/context/markdown"
import { getDirectory, getFilename } from "@opencode/util/path"
import type { FileContent } from "@/runtime/server/types"
import { useLanguage } from "@/runtime/i18n/language"
import {
artifactKind,
blobUrlFromContent,
contentBytes,
parseDelimited,
resolveArtifactPath,
type ArtifactKind,
} from "@/workspaces/files/artifact"
import { useArtifactOpener } from "@/session/files/open-artifact"
import "./artifact-view.css"
type ArtifactMode = "preview" | "source"
/** Facts a viewer learns from the decoded media, shown in the toolbar. */
type ArtifactInfo = { width?: number; height?: number; duration?: number; rows?: number; columns?: number }
type MediaProps = {
path: string
content: FileContent
onInfo: (info: ArtifactInfo) => void
/** The browser could not decode the bytes; the host falls back to the binary placeholder. */
onError: () => void
}
/** Kinds that render a preview from their text and can toggle back to highlighted source. */
const previewableKinds = new Set<ArtifactKind>(["svg", "html", "markdown", "mermaid", "table"])
/**
* Renders a loaded non-text file: media, documents, and data get a dedicated viewer with a toolbar;
* previewable text kinds can switch to `source`, which the host supplies (its code view).
*/
export function ArtifactView(props: { path: string; content: FileContent; cacheKey?: string; source: JSX.Element }) {
const language = useLanguage()
const [state, setState] = createStore({
mode: "preview" as ArtifactMode,
info: {} as ArtifactInfo,
// Media the browser could not decode falls back to the binary placeholder.
undecodable: false,
})
createEffect(
on(
() => props.content,
() => setState({ mode: "preview", info: {}, undecodable: false }),
{ defer: true },
),
)
const kind = createMemo<ArtifactKind | "binary">(() => {
if (props.content.type === "binary" && !props.content.mimeType) return "binary"
if (state.undecodable) return "binary"
return artifactKind(props.path)
})
const previewable = createMemo(() => {
const value = kind()
return value !== "binary" && previewableKinds.has(value)
})
const meta = createMemo(() => {
const info = state.info
return [
info.width && info.height ? `${info.width} × ${info.height}` : undefined,
info.duration ? formatDuration(info.duration) : undefined,
info.rows !== undefined ? language.plural("file.view.table.rows", Math.max(0, info.rows - 1)) : undefined,
info.columns !== undefined ? language.plural("file.view.table.columns", info.columns) : undefined,
formatBytes(language.intl(), contentBytes(props.content)),
].filter((item): item is string => !!item)
})
const media = { onInfo: (info: ArtifactInfo) => setState("info", info), onError: () => setState("undecodable", true) }
const rendered = () => (
<ScrollView class="min-h-0 flex-1">
<Show
when={kind() === "markdown"}
fallback={<ArtifactMermaid text={props.content.content} cacheKey={props.cacheKey} />}
>
<ArtifactMarkdown path={props.path} text={props.content.content} cacheKey={props.cacheKey} />
</Show>
</ScrollView>
)
return (
<>
<ArtifactToolbar
mode={state.mode}
onModeChange={previewable() ? (mode) => setState("mode", mode) : undefined}
meta={meta()}
actions={
<Show when={kind() === "html"}>
<OpenInBrowserButton path={props.path} />
</Show>
}
/>
<Show when={!previewable() || state.mode === "preview"} fallback={props.source}>
<Switch>
<Match when={kind() === "image" || kind() === "svg"}>
<ArtifactImage path={props.path} content={props.content} {...media} />
</Match>
<Match when={kind() === "video"}>
<ArtifactVideo path={props.path} content={props.content} {...media} />
</Match>
<Match when={kind() === "audio"}>
<ArtifactAudio path={props.path} content={props.content} {...media} />
</Match>
<Match when={kind() === "pdf" || kind() === "html"}>
<ArtifactFrame path={props.path} content={props.content} kind={kind() === "pdf" ? "pdf" : "html"} />
</Match>
<Match when={kind() === "font"}>
<ArtifactFont path={props.path} content={props.content} />
</Match>
<Match when={kind() === "table"}>
<ArtifactTable path={props.path} text={props.content.content} onInfo={media.onInfo} />
</Match>
<Match when={kind() === "markdown" || kind() === "mermaid"}>{rendered()}</Match>
<Match when={kind() === "binary"}>
<ArtifactBinary path={props.path} size={formatBytes(language.intl(), contentBytes(props.content))} />
</Match>
</Switch>
</Show>
</>
)
}
function formatBytes(locale: string, bytes: number) {
const units = ["byte", "kilobyte", "megabyte", "gigabyte"] as const
const index = Math.min(units.length - 1, bytes > 0 ? Math.floor(Math.log10(bytes) / 3) : 0)
const value = bytes / 1000 ** index
return new Intl.NumberFormat(locale, {
style: "unit",
unit: units[index],
// "short" bytes render as the singular "byte"; the long form pluralizes correctly.
unitDisplay: index === 0 ? "long" : "short",
maximumFractionDigits: value >= 100 || index === 0 ? 0 : 1,
}).format(value)
}
function formatDuration(seconds: number) {
const total = Math.round(seconds)
const minutes = Math.floor(total / 60)
return `${minutes}:${String(total % 60).padStart(2, "0")}`
}
function ArtifactToolbar(props: {
mode?: ArtifactMode
onModeChange?: (mode: ArtifactMode) => void
meta: string[]
actions?: JSX.Element
}) {
const language = useLanguage()
return (
<div data-slot="artifact-toolbar" class="flex h-10 shrink-0 items-center gap-3 px-4">
<Show when={props.onModeChange}>
<SegmentedControl
value={props.mode ?? "preview"}
onChange={(value) => {
if (value === "preview" || value === "source") props.onModeChange?.(value)
}}
>
<SegmentedControlItem value="preview">{language.t("file.view.preview")}</SegmentedControlItem>
<SegmentedControlItem value="source">{language.t("file.view.source")}</SegmentedControlItem>
</SegmentedControl>
</Show>
<div class="ms-auto flex min-w-0 items-center gap-3">
<div class="flex min-w-0 items-center gap-2 text-12-regular text-text-weak">
<For each={props.meta}>
{(item, index) => (
<>
<Show when={index() > 0}>
<span aria-hidden class="text-text-faint">
·
</span>
</Show>
<span class="truncate tabular-nums">{item}</span>
</>
)}
</For>
</div>
{props.actions}
</div>
</div>
)
}
function OpenInBrowserButton(props: { path: string }) {
const language = useLanguage()
const artifacts = useArtifactOpener()
return (
<Show when={artifacts.canOpenInBrowser(props.path)}>
<Button size="small" variant="ghost" icon="globe" onClick={() => artifacts.openInBrowser(props.path)}>
{language.t("file.view.openInBrowser")}
</Button>
</Show>
)
}
function createBlobUrl(content: () => FileContent) {
return createMemo(() => {
const value = blobUrlFromContent(content())
onCleanup(() => URL.revokeObjectURL(value))
return value
})
}
/** Images and SVG previews: fit the pane, click to inspect at 1:1 when the image is larger. */
function ArtifactImage(props: MediaProps) {
const url = createBlobUrl(() => props.content)
const [state, setState] = createStore({ zoom: "fit" as "fit" | "actual", overflow: false, width: 0, height: 0 })
let stage: HTMLDivElement | undefined
const measure = () => {
if (!stage) return
setState("overflow", state.width > stage.clientWidth - 48 || state.height > stage.clientHeight - 48)
}
createResizeObserver(
() => stage,
() => measure(),
)
createEffect(() => {
url()
setState({ zoom: "fit", overflow: false })
})
return (
<div
ref={stage}
data-slot="artifact-stage"
data-checker
data-zoom={state.zoom}
data-overflow={state.overflow || undefined}
class="relative min-h-0 flex-1 overflow-auto"
>
<div
classList={{
"absolute inset-0 flex items-center justify-center p-6": state.zoom === "fit",
"flex min-h-full min-w-full w-max items-center justify-center p-6": state.zoom === "actual",
}}
>
<img
data-slot="artifact-media"
src={url()}
alt={getFilename(props.path)}
draggable={false}
onError={() => props.onError()}
onLoad={(event) => {
const image = event.currentTarget
setState({ width: image.naturalWidth, height: image.naturalHeight })
props.onInfo({ width: image.naturalWidth, height: image.naturalHeight })
measure()
}}
onClick={() => {
if (!state.overflow && state.zoom === "fit") return
setState("zoom", state.zoom === "fit" ? "actual" : "fit")
}}
/>
</div>
</div>
)
}
function ArtifactVideo(props: MediaProps) {
const url = createBlobUrl(() => props.content)
return (
<div data-slot="artifact-stage" data-zoom="fit" class="relative min-h-0 flex-1 overflow-hidden">
<div class="absolute inset-0 flex items-center justify-center p-6">
<video
data-slot="artifact-media"
class="w-full bg-black"
controls
preload="metadata"
playsinline
onError={() => props.onError()}
src={url()}
onLoadedMetadata={(event) => {
const video = event.currentTarget
props.onInfo({ width: video.videoWidth, height: video.videoHeight, duration: video.duration })
}}
/>
</div>
</div>
)
}
function ArtifactAudio(props: MediaProps) {
const url = createBlobUrl(() => props.content)
return (
<div data-slot="artifact-stage" class="relative min-h-0 flex-1 overflow-auto">
<div class="absolute inset-0 flex items-center justify-center p-6">
<div class="flex w-full max-w-lg flex-col items-center gap-5 rounded-xl border border-v2-border-border-muted bg-v2-background-bg-base px-8 py-8 shadow-[var(--v2-elevation-raised)]">
<div class="flex size-14 items-center justify-center rounded-full bg-v2-background-bg-layer-02">
<FileIcon node={{ path: props.path, type: "file" }} class="size-7" />
</div>
<div class="max-w-full truncate text-14-medium text-text-strong">{getFilename(props.path)}</div>
<audio
class="w-full"
onError={() => props.onError()}
controls
preload="metadata"
src={url()}
onLoadedMetadata={(event) => props.onInfo({ duration: event.currentTarget.duration })}
/>
</div>
</div>
</div>
)
}
function ArtifactFrame(props: { path: string; content: FileContent; kind: "pdf" | "html" }) {
const url = createBlobUrl(() => props.content)
// PDF Open Parameters: start with the thumbnail pane closed and the page fitted to the pane width.
const src = () => (props.kind === "pdf" ? `${url()}#navpanes=0&view=FitH` : url())
return (
<iframe
class="block h-full w-full flex-1 border-0 bg-white"
title={getFilename(props.path)}
src={src()}
// The PDF viewer is Chromium's own and does not run in a sandboxed frame. HTML runs as an
// opaque origin: no app storage, cookies, or credentialed requests reach it.
sandbox={props.kind === "html" ? "allow-scripts allow-popups allow-forms allow-modals" : undefined}
referrerPolicy="no-referrer"
/>
)
}
function ArtifactMarkdown(props: { path: string; text: string; cacheKey?: string }) {
const parent = useMarkdown()
const artifacts = useArtifactOpener()
// getDirectory yields "/" for a root-level file, which would make relative links absolute.
const dir = createMemo(() => (props.path.includes("/") || props.path.includes("\\") ? getDirectory(props.path) : ""))
// Absolute references bypass the file's directory; relative ones resolve against it.
const resolve = (href: string) => (/^([a-z]:)?\//i.test(href) ? href : (resolveArtifactPath(dir(), href) ?? href))
return (
<MarkdownProvider
readImage={(src, signal) => parent?.readImage?.(resolve(src), signal) ?? Promise.resolve(undefined)}
openLocalFile={(href) => artifacts.open(href, dir())}
>
<div class="mx-auto w-full max-w-3xl px-8 py-6">
<Markdown text={props.text} cacheKey={props.cacheKey} class="select-text" />
</div>
</MarkdownProvider>
)
}
/** Mermaid sources render through the same fenced-block pipeline the timeline uses. */
function ArtifactMermaid(props: { text: string; cacheKey?: string }) {
return (
<div class="mx-auto w-full max-w-4xl px-8 py-6">
<Markdown text={`\`\`\`mermaid\n${props.text}\n\`\`\``} cacheKey={props.cacheKey} class="select-text" />
</div>
)
}
function ArtifactTable(props: { path: string; text: string; onInfo: (info: ArtifactInfo) => void }) {
const language = useLanguage()
const parsed = createMemo(() => parseDelimited(props.text, props.path.toLowerCase().endsWith(".tsv") ? "\t" : ","))
createEffect(() => props.onInfo({ rows: parsed().total, columns: parsed().columns }))
// Pad the header to the widest row so no data column is dropped.
const header = () => Array.from({ length: parsed().columns }, (_, index) => parsed().rows[0]?.[index] ?? "")
const body = () => parsed().rows.slice(1)
return (
<div class="min-h-0 flex-1 overflow-auto">
<table data-slot="artifact-table" class="min-w-full text-13-regular text-text-base">
<thead>
<tr>
<th data-index />
<For each={header()}>{(cell) => <th>{cell}</th>}</For>
</tr>
</thead>
<tbody>
<For each={body()}>
{(row, index) => (
<tr>
<td data-index>{index() + 1}</td>
<For each={header()}>{(_, column) => <td>{row[column()] ?? ""}</td>}</For>
</tr>
)}
</For>
</tbody>
</table>
<Show when={parsed().total > parsed().rows.length}>
<div class="px-4 py-3 text-12-regular text-text-weak">
{language.t("file.view.table.truncated", { shown: parsed().rows.length - 1, total: parsed().total - 1 })}
</div>
</Show>
</div>
)
}
const specimenSizes = [12, 16, 24, 40, 64]
function ArtifactFont(props: { path: string; content: FileContent }) {
const language = useLanguage()
const url = createBlobUrl(() => props.content)
const family = createMemo(() => `artifact-${Math.random().toString(36).slice(2)}`)
createEffect(() => {
const face = new FontFace(family(), `url(${url()})`)
document.fonts.add(face)
void face.load().catch(() => undefined)
onCleanup(() => document.fonts.delete(face))
})
return (
<div class="min-h-0 flex-1 overflow-auto">
<div class="mx-auto flex w-full max-w-3xl flex-col gap-6 px-8 py-8" style={{ "font-family": `"${family()}"` }}>
<div class="text-text-strong" style={{ "font-size": "56px", "line-height": "1.1" }}>
{getFilename(props.path).replace(/\.[^.]+$/, "")}
</div>
<div class="break-all text-text-base" style={{ "font-size": "22px", "line-height": "1.4" }}>
ABCDEFGHIJKLMNOPQRSTUVWXYZ
<br />
abcdefghijklmnopqrstuvwxyz
<br />
0123456789 !?&@#%(){}[]
</div>
<div class="flex flex-col gap-3 border-t border-v2-border-border-muted pt-6">
<For each={specimenSizes}>
{(size) => (
<div class="flex items-baseline gap-4">
<span
class="w-8 shrink-0 text-12-regular text-text-faint tabular-nums"
style={{ "font-family": "var(--font-family-mono)" }}
>
{size}
</span>
<span class="text-text-base" style={{ "font-size": `${size}px`, "line-height": "1.25" }}>
{language.t("file.view.fontSample")}
</span>
</div>
)}
</For>
</div>
</div>
</div>
)
}
function ArtifactBinary(props: { path: string; size: string }) {
const language = useLanguage()
return (
<div data-slot="artifact-stage" class="relative min-h-0 flex-1">
<div class="absolute inset-0 flex flex-col items-center justify-center gap-3 p-6 text-center">
<FileIcon node={{ path: props.path, type: "file" }} class="size-8 text-text-weak" />
<div class="text-14-medium text-text-strong">{getFilename(props.path)}</div>
<div class="text-13-regular text-text-weak">{language.t("file.view.binary", { size: props.size })}</div>
</div>
</div>
)
}
+39 -30
View File
@@ -1,4 +1,4 @@
import { createEffect, createMemo, createSignal, Match, on, onCleanup, Switch } from "solid-js"
import { createEffect, createMemo, createSignal, Match, on, onCleanup, Show, Switch } from "solid-js"
import { createStore } from "solid-js/store"
import { Dynamic } from "solid-js/web"
import { makeEventListener } from "@solid-primitives/event-listener"
@@ -11,8 +11,9 @@ import { LineCommentOverflowIcon } from "@opencode/ui/line-comment"
import { Menu } from "@opencode/ui/menu"
import { Tabs } from "@opencode/ui/tabs"
import { ScrollView } from "@opencode/ui/scroll-view"
import { showToast } from "@/shell/notifications/toast"
import { selectionFromLines, useFile, type FileSelection, type SelectedLineRange } from "@/workspaces/files/model"
import { artifactKind } from "@/workspaces/files/artifact"
import { ArtifactView } from "@/session/files/artifact-view"
import { useComments } from "@/composer/comments"
import { useLanguage } from "@/runtime/i18n/language"
import { useComposerState } from "@/composer/persistence"
@@ -205,6 +206,11 @@ export function SessionFileView(props: SessionFileViewProps) {
})
const contents = createMemo(() => state()?.content?.content ?? "")
const cacheKey = createMemo(() => sampledChecksum(contents()))
// Plain text keeps the code view; every other kind is rendered by ArtifactView.
const artifact = createMemo(() => {
const content = state()?.content
return content?.type === "binary" || artifactKind(path() ?? "") !== "text"
})
const selectedLines = createMemo<SelectedLineRange | null>(() => {
const p = path()
if (!p) return null
@@ -433,39 +439,42 @@ export function SessionFileView(props: SessionFileViewProps) {
}}
search={search}
class="select-text"
media={{
mode: "auto",
path: path(),
current: state()?.content,
onLoad: scrollSync.queueRestore,
onError: (args: { kind: "image" | "audio" | "svg" }) => {
if (args.kind !== "svg") return
showToast({
variant: "error",
title: language.t("toast.file.loadFailed.title"),
})
},
}}
// Media and previews have their own viewers below; the code view only ever shows text.
media={{ mode: "off" }}
/>
</div>
)
// The code view scrolls inside ScrollView so line state and scroll position persist per tab.
const codeView = (source: string) => (
<ScrollView class="min-h-0 flex-1" viewportRef={scrollSync.setViewport} onScroll={scrollSync.handleScroll}>
{renderFile(source)}
</ScrollView>
)
const content = () => (
<div class="mt-3 relative h-full min-h-0">
<ScrollView class="h-full" viewportRef={scrollSync.setViewport} onScroll={scrollSync.handleScroll}>
<Switch>
<Match when={state()?.loaded}>{renderFile(contents())}</Match>
<Match when={state()?.loading}>
<div class="px-6 py-4 text-text-weak">{language.t("common.loading")}</div>
</Match>
<Match when={state()?.notFound ? state()?.name : undefined}>
{(name) => (
<div class="px-6 py-4 text-text-weak">{language.t("file.error.notFound", { name: name() })}</div>
)}
</Match>
<Match when={state()?.error}>{(err) => <div class="px-6 py-4 text-text-weak">{err()}</div>}</Match>
</Switch>
</ScrollView>
<div class="mt-3 relative h-full min-h-0 flex flex-col">
<Switch>
<Match when={state()?.loaded ? state()?.content : undefined}>
{(value) => (
<Show when={artifact()} fallback={codeView(value().content)}>
<ArtifactView
path={path() ?? ""}
content={value()}
cacheKey={cacheKey()}
source={codeView(value().content)}
/>
</Show>
)}
</Match>
<Match when={state()?.loading}>
<div class="px-6 py-4 text-text-weak">{language.t("common.loading")}</div>
</Match>
<Match when={state()?.notFound ? state()?.name : undefined}>
{(name) => <div class="px-6 py-4 text-text-weak">{language.t("file.error.notFound", { name: name() })}</div>}
</Match>
<Match when={state()?.error}>{(err) => <div class="px-6 py-4 text-text-weak">{err()}</div>}</Match>
</Switch>
</div>
)
@@ -0,0 +1,105 @@
import { createEffect, onCleanup, type ParentProps } from "solid-js"
import { createSimpleContext } from "@opencode/ui/context"
import { MarkdownProvider, useMarkdown } from "@opencode/session-ui/context/markdown"
import { useBrowserAttachments } from "@/session/browser/attachments"
import type { SessionModel } from "@/session/model"
import { useFile } from "@/workspaces/files/model"
import { artifactKind, resolveArtifactPath } from "@/workspaces/files/artifact"
import { encodeFilePath } from "@/workspaces/files/path"
import { useWorkspaceLocation } from "@/workspaces/location"
import { useServer } from "@/runtime/server/current"
import { ServerConnection } from "@/runtime/server/registry"
import { useSessionLayout } from "@/session/session-layout"
import { createOpenSessionFileTab } from "@/session/helpers"
import type { createSessionBrowser } from "@/session/browser/model"
/** Routes local links in timeline markdown to the artifact opener while keeping image loading. */
export function ArtifactMarkdownProvider(props: ParentProps) {
const markdown = useMarkdown()
const artifacts = useArtifactOpener()
return (
<MarkdownProvider readImage={markdown?.readImage} openLocalFile={(path) => artifacts.open(path)}>
{props.children}
</MarkdownProvider>
)
}
/**
* Opens files the agent references as side-panel tabs, inside or outside the workspace, or as
* a browser tab for HTML when the desktop can load the file directly.
*/
export const { use: useArtifactOpener, provider: ArtifactOpenerProvider } = createSimpleContext({
name: "ArtifactOpener",
init: (props: { session: SessionModel; browser: ReturnType<typeof createSessionBrowser> }) => {
const file = useFile()
const server = useServer()
const location = useWorkspaceLocation()
const attachments = useBrowserAttachments()
const { tabs, view } = useSessionLayout()
const root = () => location().directory.replaceAll("\\", "/").replace(/\/+$/, "")
/**
* Turn a link into a path `useFile` can load: workspace-relative when it is under the root,
* otherwise absolute. Relative links resolve against `base`; ones that climb past the root
* become absolute too, so a `../../shared/report.pdf` still opens.
*/
const resolve = (href: string, base?: string) => {
// Agents cite locations as path:line or path:line:col; the file is what opens.
const value = href.replaceAll("\\", "/").replace(/:\d+(?::\d+)?$/, "")
if (/^[a-z]:\//i.test(value) || value.startsWith("/")) return file.normalize(value)
const relative = resolveArtifactPath(base ?? "", value)
if (relative !== undefined) return file.normalize(relative)
// Climbing past the workspace root: resolve from the referencing folder's absolute location.
const dir = base ? `${root()}/${base.replace(/\/+$/, "")}` : root()
return file.normalize(resolveArtifactPath(dir, value) ?? value)
}
const showTab = createOpenSessionFileTab({
normalizeTab: (tab) => tab,
openTab: (tab) => tabs().open(tab),
pathFromTab: file.pathFromTab,
loadFile: () => undefined,
openReviewPanel: () => {
if (!view().reviewPanel.opened()) view().reviewPanel.open()
},
setActive: (tab) => tabs().setActive(tab),
})
// Inline paths are guessed from text, so confirm the file exists before a tab appears for it.
const openTab = (path: string) => {
void file.load(path).then(() => {
if (file.get(path)?.loaded) showTab(file.tab(path))
})
}
// The desktop's own sidecar shares this disk, and its browser pane accepts file:// URLs inside the
// session workspace only. Forwarded loopback servers do not qualify, matching the desktop policy.
const canOpenInBrowser = (path?: string) =>
ServerConnection.builtin(server.conn) &&
props.browser.available() &&
props.browser.attached() &&
(path === undefined || !file.absolute(path))
const openInBrowser = (path: string) => {
props.browser.command({ type: "tabs.open", url: `file://${encodeFilePath(`${root()}/${path}`)}` })
}
/** Open `href` as referenced from `base` (a workspace-relative directory, "" for the root). */
const open = (href: string, base?: string) => {
const path = resolve(href, base)
if (!path) return
if (artifactKind(path) === "html" && canOpenInBrowser(path)) return openInBrowser(path)
openTab(path)
}
// The agent's browser.preview tool arrives through the desktop browser pane attachment.
createEffect(() => {
const sessionID = props.session.identity.sessionID()
if (!sessionID) return
onCleanup(attachments.onPreview(server, sessionID, (path) => open(path)))
})
return { canOpenInBrowser, openInBrowser, open }
},
})
@@ -173,7 +173,9 @@ export function SessionFileBrowserTab(props: {
<SessionFilePanelV2Empty>
<div class="flex flex-col items-center gap-2 text-center text-text-weak">
<Icon name="file-tree" size="large" class="mb-2" />
<div class="text-[13px] font-medium leading-[13px] text-text-strong">{language.t("command.file.open")}</div>
<div class="text-[13px] font-medium leading-[13px] text-text-strong">
{language.t("command.file.open")}
</div>
<div class="h-5 text-13-regular leading-5">{language.t("session.files.selectToOpen")}</div>
</div>
</SessionFilePanelV2Empty>
+15 -3
View File
@@ -1,6 +1,8 @@
import { children, createMemo, Show } from "solid-js"
import type { JSX } from "solid-js"
import { useSortable } from "@dnd-kit/solid/sortable"
import { Icon } from "@opencode/ui/icon"
import { IconButton } from "@opencode/ui/icon-button"
import { Keybind } from "@opencode/ui/keybind"
import { Tooltip } from "@opencode/ui/tooltip"
import { Tabs } from "@opencode/ui/tabs"
@@ -69,9 +71,19 @@ export function SortableTab(props: {
placement="bottom"
gutter={10}
>
<Tabs.CloseButton
class="h-5 w-5"
onClick={() => props.onTabClose(props.tab)}
<IconButton
size="small"
variant="ghost-muted"
onPointerDown={(event) => {
event.preventDefault()
event.stopPropagation()
}}
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
props.onTabClose(props.tab)
}}
icon={<Icon name="xmark-small" />}
aria-label={language.t("common.closeTab")}
/>
</Tooltip>
+1
View File
@@ -137,6 +137,7 @@ export function useSessionModel() {
tabs: layout.tabs,
view: layout.view,
tabKey: layout.tabKey,
sessionKey: layout.sessionKey,
},
ownership: createSessionOwnership(layout.sessionKey),
tabs: {
+3
View File
@@ -155,6 +155,9 @@ export function createSessionReview(input: {
const count = () => diffs().length
const hasChanges = () => count() > 0
const ready = () => {
// A project without VCS never enables vcsQuery, so its status stays "pending" forever.
const project = input.session.project()
if (project && !project.vcs) return true
if (mode() === "git" || mode() === "branch") return !vcsQuery.isPending
return true
}
+2 -2
View File
@@ -249,7 +249,7 @@ function ReviewTitle(props: { review: SessionReviewModel }) {
function ReviewEmpty(props: { review: SessionReviewModel; loadingClass: string }) {
const language = useLanguage()
const loading = () => (props.review.mode() === "git" || props.review.mode() === "branch") && !props.review.ready()
const noGit = () => props.review.mode() === "turn" && props.review.noGit()
const noGit = () => props.review.noGit()
const text = () => {
if (props.review.mode() === "git") return language.t("session.review.noUncommittedChanges")
if (props.review.mode() === "branch") return language.t("session.review.noBranchChanges")
@@ -282,7 +282,7 @@ function ReviewEmpty(props: { review: SessionReviewModel; loadingClass: string }
function ReviewPanelEmpty(props: { review: SessionReviewModel }) {
const language = useLanguage()
const loading = () => (props.review.mode() === "git" || props.review.mode() === "branch") && !props.review.ready()
const noGit = () => props.review.mode() === "turn" && props.review.noGit()
const noGit = () => props.review.noGit()
return (
<Switch>
<Match when={loading()}>
+15 -2
View File
@@ -38,6 +38,7 @@ import { SessionReviewToggle } from "./header/session-header-actions"
import { createAnimatedPresence } from "@/runtime/animated-presence"
import { createSessionBrowser } from "./browser/model"
import { createTimelineCache } from "./timeline/cache"
import { ArtifactMarkdownProvider, ArtifactOpenerProvider } from "./files/open-artifact"
const SessionMobileFiles = lazy(async () => {
const { SessionMobileFiles } = await import("./files/session-mobile-files")
@@ -50,14 +51,26 @@ const SessionSummaryPanel = lazy(async () => {
})
export function SessionScreen(props: { session: SessionModel }) {
// The timeline cache captures its owner when created, so link handling must be provided above it.
const browser = createSessionBrowser(props.session)
return (
<ArtifactOpenerProvider session={props.session} browser={browser}>
<ArtifactMarkdownProvider>
<SessionScreenContent session={props.session} browser={browser} />
</ArtifactMarkdownProvider>
</ArtifactOpenerProvider>
)
}
function SessionScreenContent(props: { session: SessionModel; browser: ReturnType<typeof createSessionBrowser> }) {
const session = props.session
const browser = props.browser
const server = useServer()
const detailsProject = createMemo(() => {
const info = session.data.info()
return info ? projectForSession(info, server.ctx.sync.data.project) : undefined
})
const isDesktop = session.isDesktop
const browser = createSessionBrowser(session)
const screen = createSessionScreenLayout(session)
const timeline = createSessionTimelineInteraction(session)
const timelineSearch = createTimelineSearchController({
@@ -325,7 +338,7 @@ export function SessionScreen(props: { session: SessionModel }) {
</div>
<Show when={composer.active()} keyed>
{(model) => <ActiveSessionComposerRegion model={model} />}
{(model) => <ActiveSessionComposerRegion model={model} suggestionBoundary={timeline.scroller} />}
</Show>
</>
)
-4
View File
@@ -1102,10 +1102,6 @@
padding-block: 20px;
}
.settings-server-connection .settings-servers-lead {
gap: 4px;
}
.settings-servers-row:not(:last-child) {
padding-bottom: 16px;
margin-bottom: 16px;
+10 -1
View File
@@ -9,7 +9,8 @@
justify-content: center;
}
[data-titlebar-tab] {
[data-titlebar-tab],
[data-titlebar-tab-action] {
--tab-base: var(--v2-background-bg-deep);
--tab-overlay: transparent;
background: linear-gradient(var(--tab-overlay), var(--tab-overlay)), var(--tab-base);
@@ -68,6 +69,14 @@
--tab-overlay: var(--v2-overlay-simple-overlay-pressed);
}
[data-titlebar-tab-action]:is(:hover, :focus-visible) {
--tab-overlay: var(--v2-overlay-simple-overlay-hover);
}
[data-titlebar-tab-action]:is(:active, [data-state="pressed"]) {
--tab-overlay: var(--v2-overlay-simple-overlay-pressed);
}
[data-titlebar-tab]:is(:hover, [data-state="pressed"]) [data-slot="tab-close"] {
background: linear-gradient(var(--tab-overlay), var(--tab-overlay)), var(--tab-base);
}
+4 -2
View File
@@ -351,9 +351,10 @@ export function Titlebar(props: {
>
<button
type="button"
data-titlebar-tab-action
data-action="vertical-tabs-home"
data-state={layout.route().type === "home" ? "pressed" : undefined}
class="group mb-1 flex h-7 w-full shrink-0 items-center gap-1.5 rounded-[6px] ps-1.5 pe-2 text-[13px] leading-4 text-v2-text-text-faint hover:bg-v2-background-bg-layer-02 hover:text-v2-text-text-base data-[state=pressed]:bg-v2-background-bg-layer-02 data-[state=pressed]:text-v2-text-text-base"
class="group mb-1 flex h-7 w-full shrink-0 items-center gap-1.5 rounded-[6px] ps-1.5 pe-2 text-[13px] leading-4 text-v2-text-text-faint hover:text-v2-text-text-base data-[state=pressed]:text-v2-text-text-base"
onClick={toggleHome}
aria-label={language.t("home.title")}
aria-pressed={layout.route().type === "home"}
@@ -650,8 +651,9 @@ export function Titlebar(props: {
{homeButton(true)}
<button
type="button"
data-titlebar-tab-action
data-action="vertical-tabs-new-session"
class="group flex h-7 w-full shrink-0 items-center gap-1.5 rounded-[6px] ps-1.5 pe-2 text-[13px] leading-4 text-v2-text-text-faint hover:bg-v2-background-bg-layer-02 hover:text-v2-text-text-base"
class="group flex h-7 w-full shrink-0 items-center gap-1.5 rounded-[6px] ps-1.5 pe-2 text-[13px] leading-4 text-v2-text-text-faint hover:text-v2-text-text-base"
onClick={openNewTab}
aria-label={language.t("command.session.new")}
>
@@ -0,0 +1,118 @@
import { describe, expect, test } from "bun:test"
import {
artifactKind,
bytesToBase64,
contentBytes,
fileContentFromBytes,
MAX_MEDIA_BYTES,
parseDelimited,
resolveArtifactPath,
} from "./artifact"
describe("artifactKind", () => {
test.each([
["shot.PNG", "image"],
["logo.svg", "svg"],
["song.mp3", "audio"],
["demo.mp4", "video"],
["clip.webm", "video"],
["paper.pdf", "pdf"],
["out/index.html", "html"],
["README.md", "markdown"],
["flow.mmd", "mermaid"],
["data.csv", "table"],
["data.tsv", "table"],
["Inter.woff2", "font"],
["src/app.ts", "text"],
["Makefile", "text"],
[".env", "text"],
["archive.tar.gz", "text"],
] as const)("classifies %s as %s", (path, kind) => {
expect(artifactKind(path)).toBe(kind)
})
})
describe("fileContentFromBytes", () => {
test("keeps media as base64 with a mime type", () => {
const content = fileContentFromBytes("a.png", new Uint8Array([137, 80, 78, 71]))
expect(content).toEqual({ type: "binary", content: "iVBORw==", encoding: "base64", mimeType: "image/png" })
})
test("decodes text and svg with a mime type", () => {
expect(fileContentFromBytes("a.svg", new TextEncoder().encode("<svg/>"))).toEqual({
type: "text",
content: "<svg/>",
mimeType: "image/svg+xml",
})
expect(fileContentFromBytes("a.ts", new TextEncoder().encode("const a = 1"))).toEqual({
type: "text",
content: "const a = 1",
mimeType: undefined,
})
})
test("keeps only the size of media above the cap", () => {
const content = fileContentFromBytes("big.mp4", new Uint8Array(MAX_MEDIA_BYTES + 1))
expect(content).toEqual({ type: "binary", content: "", size: MAX_MEDIA_BYTES + 1 })
})
test("marks unknown binaries without keeping bytes", () => {
expect(fileContentFromBytes("a.bin", new Uint8Array([1, 0, 2]))).toEqual({ type: "binary", content: "", size: 3 })
})
test("encodes large buffers in chunks", () => {
const bytes = new Uint8Array(70_000).fill(65)
expect(bytesToBase64(bytes)).toBe(Buffer.from(bytes).toString("base64"))
})
})
describe("contentBytes", () => {
test("recovers byte counts from base64 and text", () => {
expect(contentBytes({ type: "binary", content: "iVBORw==", encoding: "base64" })).toBe(4)
expect(contentBytes({ type: "binary", content: "iVBORwA=", encoding: "base64" })).toBe(5)
expect(contentBytes({ type: "text", content: "héllo" })).toBe(6)
})
})
describe("parseDelimited", () => {
test("handles quotes, embedded delimiters, newlines, and CRLF", () => {
const parsed = parseDelimited('name,note\r\n"Smith, J","says ""hi""\nand more"\nplain,\n', ",")
expect(parsed.rows).toEqual([
["name", "note"],
["Smith, J", 'says "hi"\nand more'],
["plain", ""],
])
expect(parsed.total).toBe(3)
expect(parsed.columns).toBe(2)
})
test("counts rows past the limit without keeping them", () => {
const parsed = parseDelimited("a\tb\n1\t2\n3\t4\n5\t6", "\t", 2)
expect(parsed.rows).toHaveLength(2)
expect(parsed.total).toBe(4)
})
})
describe("resolveArtifactPath", () => {
test.each([
["docs", "guide.md", "docs/guide.md"],
["docs", "./img/a.png", "docs/img/a.png"],
["docs/api", "../index.md", "docs/index.md"],
["", "src/app.ts", "src/app.ts"],
["docs", "sub\\win.md", "docs/sub/win.md"],
["", "docs/guide.md", "docs/guide.md"],
["/tmp/notes/", "../out/a.pdf", "/tmp/out/a.pdf"],
["C:/tmp/notes/", "img.png", "C:/tmp/notes/img.png"],
["/repo", "../shared/report.pdf", "/shared/report.pdf"],
])("resolves %s + %s", (base, href, expected) => {
expect(resolveArtifactPath(base, href)).toBe(expected)
})
test.each([
["docs", "../../etc/passwd"],
["", "../x"],
["docs", "/abs/path"],
])("rejects %s + %s", (base, href) => {
expect(resolveArtifactPath(base, href)).toBeUndefined()
})
})
@@ -0,0 +1,211 @@
import type { FileContent } from "@/runtime/server/types"
export type ArtifactKind =
| "image"
| "svg"
| "audio"
| "video"
| "pdf"
| "html"
| "markdown"
| "mermaid"
| "table"
| "font"
| "text"
const mimes = new Map([
["png", "image/png"],
["jpg", "image/jpeg"],
["jpeg", "image/jpeg"],
["gif", "image/gif"],
["webp", "image/webp"],
["avif", "image/avif"],
["bmp", "image/bmp"],
["ico", "image/x-icon"],
["tif", "image/tiff"],
["tiff", "image/tiff"],
["heic", "image/heic"],
["svg", "image/svg+xml"],
["mp3", "audio/mpeg"],
["wav", "audio/wav"],
["ogg", "audio/ogg"],
["oga", "audio/ogg"],
["m4a", "audio/mp4"],
["aac", "audio/aac"],
["flac", "audio/flac"],
["opus", "audio/ogg"],
["weba", "audio/webm"],
["mp4", "video/mp4"],
["m4v", "video/mp4"],
["webm", "video/webm"],
["mov", "video/quicktime"],
["ogv", "video/ogg"],
["mkv", "video/x-matroska"],
["pdf", "application/pdf"],
["html", "text/html"],
["htm", "text/html"],
["md", "text/markdown"],
["markdown", "text/markdown"],
["mdx", "text/markdown"],
["mmd", "text/vnd.mermaid"],
["mermaid", "text/vnd.mermaid"],
["csv", "text/csv"],
["tsv", "text/tab-separated-values"],
["ttf", "font/ttf"],
["otf", "font/otf"],
["woff", "font/woff"],
["woff2", "font/woff2"],
])
export function artifactExtension(path: string) {
const name = path.split(/[\\/]/).pop() ?? ""
const index = name.lastIndexOf(".")
if (index <= 0) return ""
return name.slice(index + 1).toLowerCase()
}
export function artifactMime(path: string) {
return mimes.get(artifactExtension(path))
}
export function artifactKind(path: string): ArtifactKind {
const mime = artifactMime(path)
if (!mime) return "text"
if (mime === "image/svg+xml") return "svg"
if (mime === "application/pdf") return "pdf"
if (mime === "text/html") return "html"
if (mime === "text/markdown") return "markdown"
if (mime === "text/vnd.mermaid") return "mermaid"
if (mime === "text/csv" || mime === "text/tab-separated-values") return "table"
if (mime.startsWith("image/")) return "image"
if (mime.startsWith("audio/")) return "audio"
if (mime.startsWith("font/")) return "font"
return "video"
}
/** Kinds whose bytes are kept as base64 so media elements can play them without a text round trip. */
const binaryKinds = new Set<ArtifactKind>(["image", "audio", "video", "pdf", "font"])
/** Text files never contain NUL; a NUL in the first 8 KiB marks an unknown binary. */
function isBinaryBytes(bytes: Uint8Array) {
return bytes.subarray(0, 8192).includes(0)
}
export function bytesToBase64(bytes: Uint8Array) {
const parts: string[] = []
for (let index = 0; index < bytes.length; index += 0x8000) {
parts.push(String.fromCharCode(...bytes.subarray(index, index + 0x8000)))
}
return btoa(parts.join(""))
}
/** Media above this stays a placeholder: base64 encoding on the main thread and the LRU budget both suffer. */
export const MAX_MEDIA_BYTES = 25 * 1024 * 1024
export function fileContentFromBytes(path: string, bytes: Uint8Array): FileContent {
const kind = artifactKind(path)
const mimeType = artifactMime(path)
if (binaryKinds.has(kind)) {
if (bytes.length > MAX_MEDIA_BYTES) return { type: "binary", content: "", size: bytes.length }
return { type: "binary", content: bytesToBase64(bytes), encoding: "base64", mimeType }
}
// Unknown binaries keep no bytes: the viewer only shows a placeholder for them.
if (kind === "text" && isBinaryBytes(bytes)) return { type: "binary", content: "", size: bytes.length }
return { type: "text", content: new TextDecoder().decode(bytes), mimeType }
}
/** Approximate on-disk size of loaded content. */
export function contentBytes(content: FileContent) {
if (content.size !== undefined) return content.size
if (content.encoding === "base64") {
const padding = content.content.endsWith("==") ? 2 : content.content.endsWith("=") ? 1 : 0
return Math.floor((content.content.length * 3) / 4) - padding
}
return new TextEncoder().encode(content.content).length
}
/**
* Parse RFC 4180 style delimited text. Quoted fields may contain the delimiter, newlines, and
* doubled quotes. Rows beyond `limit` are counted but not returned.
*/
export function parseDelimited(text: string, delimiter: string, limit = 1000) {
const rows: string[][] = []
let row: string[] = []
let field = ""
let quoted = false
let total = 0
const endRow = () => {
row.push(field)
field = ""
const blank = row.length === 1 && row[0] === ""
if (!blank) {
total++
if (rows.length < limit) rows.push(row)
}
row = []
}
for (let index = 0; index < text.length; index++) {
const char = text[index]!
if (quoted) {
if (char !== '"') {
field += char
continue
}
if (text[index + 1] === '"') {
field += '"'
index++
continue
}
quoted = false
continue
}
if (char === '"' && field === "") {
quoted = true
continue
}
if (char === delimiter) {
row.push(field)
field = ""
continue
}
if (char === "\r") continue
if (char === "\n") {
endRow()
continue
}
field += char
}
if (field !== "" || row.length > 0) endRow()
const columns = rows.reduce((max, current) => Math.max(max, current.length), 0)
return { rows, total, columns }
}
/** Build a blob URL from loaded content. Callers revoke it when the viewer unmounts. */
export function blobUrlFromContent(content: FileContent) {
const type = content.mimeType ?? "application/octet-stream"
if (content.encoding !== "base64") return URL.createObjectURL(new Blob([content.content], { type }))
const raw = atob(content.content)
const bytes = Uint8Array.from(raw, (char) => char.charCodeAt(0))
return URL.createObjectURL(new Blob([bytes], { type }))
}
/**
* Resolve a relative link against a directory. A relative base yields a workspace-relative path and
* an absolute base an absolute one; undefined when the link climbs past the base's root.
*/
export function resolveArtifactPath(base: string, href: string) {
const target = href.replaceAll("\\", "/")
if (target.startsWith("/")) return undefined
const dir = base.replaceAll("\\", "/")
const segments = [...dir.split("/").filter(Boolean)]
for (const segment of target.split("/")) {
if (!segment || segment === ".") continue
if (segment !== "..") {
segments.push(segment)
continue
}
if (segments.length === 0) return undefined
segments.pop()
}
return `${dir.startsWith("/") ? "/" : ""}${segments.join("/")}`
}
+10 -5
View File
@@ -5,11 +5,12 @@ import { createSimpleContext } from "@opencode/ui/context"
import { showToast } from "@/shell/notifications/toast"
import { useParams } from "@solidjs/router"
import { base64Encode } from "@opencode/util/encode"
import { getFilename } from "@opencode/util/path"
import { getDirectory, getFilename } from "@opencode/util/path"
import { useWorkspaceLocation } from "@/workspaces/location"
import { useLanguage } from "@/runtime/i18n/language"
import { useLayout } from "@/shell/state/layout"
import { createPathHelpers } from "./path"
import { fileContentFromBytes } from "./artifact"
import {
approxBytes,
evictContentLru,
@@ -185,14 +186,17 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
setLoading(file)
// Files outside the workspace are read from their own directory, like markdown images.
// The trailing separator from getDirectory keeps "/" and "C:/" valid, like readLocalImage.
const request = path.absolute(file)
? { path: getFilename(file), location: { directory: getDirectory(file) } }
: { path: file, location: { directory } }
const promise = serverSDK.api.file
.read({ path: file, location: { directory } })
.read(request)
.then((data) => {
if (scope() !== directory) return
const content = { type: "text" as const, content: new TextDecoder().decode(data) }
const content = fileContentFromBytes(file, data)
setLoaded(file, content)
if (!content) return
touchFileContent(file, approxBytes(content))
evictContent(new Set([file]))
})
@@ -281,6 +285,7 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
return {
ready: () => view().ready(),
normalize: path.normalize,
absolute: path.absolute,
tab: path.tab,
pathFromTab: path.pathFromTab,
tree: {
@@ -21,6 +21,24 @@ describe("file path helpers", () => {
expect(path.normalize("c:\\repo\\src\\app.ts")).toBe("src\\app.ts")
})
test("keeps files outside the workspace absolute and round-trips them through tabs", () => {
const posix = createPathHelpers(() => "/repo")
expect(posix.normalize("/tmp/out/report.pdf")).toBe("/tmp/out/report.pdf")
expect(posix.absolute("/tmp/out/report.pdf")).toBe(true)
expect(posix.absolute("src/app.ts")).toBe(false)
expect(posix.tab("/tmp/out/report.pdf")).toBe("file:///tmp/out/report.pdf")
expect(posix.pathFromTab("file:///tmp/out/report.pdf")).toBe("/tmp/out/report.pdf")
expect(posix.normalize("/repository/x.ts")).toBe("/repository/x.ts")
const windows = createPathHelpers(() => "C:\\repo")
expect(windows.normalize("C:\\tmp\\font.ttf")).toBe("C:\\tmp\\font.ttf")
expect(windows.normalize("file:///C:/tmp/font.ttf")).toBe("C:/tmp/font.ttf")
expect(windows.absolute("C:/tmp/font.ttf")).toBe(true)
expect(windows.tab("C:/tmp/font.ttf")).toBe("file:///C:/tmp/font.ttf")
expect(windows.pathFromTab("file:///C:/tmp/font.ttf")).toBe("C:/tmp/font.ttf")
expect(windows.pathFromTab("file:///C:/repo/src/app.ts")).toBe("src/app.ts")
})
test("normalizes Windows directory separators", () => {
const path = createPathHelpers(() => "C:\\repo")
expect(path.normalizeDir("frontend\\")).toBe("frontend")
+12 -6
View File
@@ -105,7 +105,11 @@ export function createPathHelpers(scope: () => string) {
const normalize = (input: string) => {
const root = scope()
let path = unquoteGitPath(decodeFilePath(stripQueryAndHash(stripFileProtocol(input))))
// file:///C:/dir becomes /C:/dir once the protocol is gone; restore the drive form.
let path = unquoteGitPath(decodeFilePath(stripQueryAndHash(stripFileProtocol(input)))).replace(
/^[/\\]([A-Za-z]:)/,
"$1",
)
// Separator-agnostic prefix stripping for Cygwin/native Windows compatibility
// Only case-insensitive on Windows (drive letter or UNC paths)
@@ -116,20 +120,21 @@ export function createPathHelpers(scope: () => string) {
canonPath.startsWith(canonRoot) &&
(canonRoot.endsWith("/") || canonPath === canonRoot || canonPath[canonRoot.length] === "/")
) {
// Slice from original path to preserve native separators
path = path.slice(root.length)
// Slice from original path to preserve native separators, then drop the separator itself.
path = path.slice(root.length).replace(/^[/\\]/, "")
}
if (path.startsWith("./") || path.startsWith(".\\")) {
path = path.slice(2)
}
if (path.startsWith("/") || path.startsWith("\\")) {
path = path.slice(1)
}
// An absolute path that is not under the root stays absolute; it is a file outside the workspace.
return path
}
/** Whether a normalized path points outside the workspace root. */
const absolute = (path: string) => /^[A-Za-z]:[/\\]/.test(path) || path.startsWith("/") || path.startsWith("\\\\")
const tab = (input: string) => {
const path = normalize(input)
return `file://${encodeFilePath(path)}`
@@ -149,6 +154,7 @@ export function createPathHelpers(scope: () => string) {
return {
normalize,
absolute,
tab,
pathFromTab,
normalizeDir,
+21 -1
View File
@@ -1,4 +1,5 @@
import { readFileSync } from "node:fs"
import { createRequire } from "node:module"
import solidPlugin from "vite-plugin-solid"
import tailwindcss from "@tailwindcss/vite"
import { fileURLToPath } from "url"
@@ -17,6 +18,24 @@ if (tailwindGenerate && typeof tailwindHotUpdate === "function") {
}
}
// The markdown worker imports these directly, so they are served unbundled to keep worker startup
// stable. Vite applies `exclude` to every import inside a pre-bundle too, which would leave a bare
// `import "marked"` in mermaid's chunk that the browser cannot resolve from this package.
const workerDeps = ["@shikijs/stream", "marked", "marked-shiki", "remend"]
/** @type {import("rolldown").Plugin} */
const bundleNestedWorkerDeps = {
name: "opencode-desktop:bundle-nested-worker-deps",
resolveId(id, importer) {
if (!importer || !workerDeps.includes(id) || !importer.includes("node_modules")) return
try {
return createRequire(importer).resolve(id)
} catch {
return
}
},
}
export const channel = (() => {
const raw = process.env.OPENCODE_CHANNEL
if (raw === "local" || raw === "dev" || raw === "beta" || raw === "prod") return raw
@@ -44,8 +63,9 @@ export default [
format: "es",
},
optimizeDeps: {
exclude: ["@shikijs/stream", "marked", "marked-shiki", "remend"],
exclude: workerDeps,
include: ["@opencode/session-ui > mermaid", "@opencode/session-ui > mermaid > katex"],
rolldownOptions: { plugins: [bundleNestedWorkerDeps] },
},
}
},
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/cli",
"version": "2.0.10",
"version": "2.0.12",
"type": "module",
"license": "MIT",
"bin": {
+68 -25
View File
@@ -5,34 +5,77 @@ import { Runtime } from "../../framework/runtime"
import { Updater } from "../../services/updater"
import { handlePromptErrors } from "../../ui/prompt"
import { OPENCODE_VERSION } from "../../version"
import { stripVTControlCharacters } from "node:util"
export default Runtime.handler(
Commands.commands.upgrade,
Effect.fn("cli.upgrade")(function* (input) {
intro("Upgrade")
const updater = yield* Updater.Service
const method = Option.getOrUndefined(input.method) ?? (yield* updater.method())
if (!method)
return yield* Effect.fail(
new Error("Could not detect the installation method. Pass --method to choose how to upgrade OpenCode."),
Effect.fn("cli.upgrade")(
function* (input) {
intro("Upgrade")
const updater = yield* Updater.Service
const method = Option.getOrUndefined(input.method) ?? (yield* updater.method())
if (!method)
return yield* Effect.fail(
new Error("Could not detect the installation method. Pass --method to choose how to upgrade OpenCode."),
)
log.info(`Using method: ${method}`)
const target = Option.getOrUndefined(input.target) ?? (yield* updater.latest())
const version = target.trim().replace(/^v/, "")
if (version === OPENCODE_VERSION) {
log.warn(`OpenCode upgrade skipped: ${version} is already installed`)
outro("Done")
return
}
log.info(`From ${OPENCODE_VERSION}${version}`)
const progress = spinner()
progress.start("Upgrading...")
yield* updater.upgrade(method, target).pipe(
Effect.tap(() => Effect.sync(() => progress.stop("Upgrade complete"))),
Effect.tapCause(() => Effect.sync(() => progress.stop("Upgrade failed", 1))),
)
log.info(`Using method: ${method}`)
const target = Option.getOrUndefined(input.target) ?? (yield* updater.latest())
const version = target.trim().replace(/^v/, "")
if (version === OPENCODE_VERSION) {
log.warn(`OpenCode upgrade skipped: ${version} is already installed`)
outro("Done")
return
}
log.info(`From ${OPENCODE_VERSION}${version}`)
const progress = spinner()
progress.start("Upgrading...")
yield* updater.upgrade(method, target).pipe(
Effect.tap(() => Effect.sync(() => progress.stop("Upgrade complete"))),
Effect.tapCause(() => Effect.sync(() => progress.stop("Upgrade failed", 1))),
)
outro("Done")
}, handlePromptErrors),
},
(effect) =>
handlePromptErrors(
effect.pipe(
Effect.mapError((error) =>
error instanceof Updater.UpgradeError ? new Error(formatUpgradeError(error), { cause: error }) : error,
),
),
),
),
)
function formatUpgradeError(error: Updater.UpgradeError) {
const clean = (value: string) => stripVTControlCharacters(value).replaceAll("\r", "").trim()
const line = (value: string) => clean(value).replace(/\s+/g, " ")
const detail = wrap(clean(error.detail), Math.max(24, (process.stdout.columns ?? 80) - 7)).join("\n")
return [
line(error.title),
"",
detail,
...(error.command ? ["", field("Command", line(error.command))] : []),
field("Retry", line(error.retry)),
].join("\n")
}
function field(label: string, value: string) {
const prefix = label.padEnd(9)
const width = Math.max(24, Math.min(61, (process.stdout.columns ?? 80) - prefix.length - 10))
const lines = wrap(value, width)
return lines.map((line, index) => `${index === 0 ? prefix : " ".repeat(prefix.length)}${line}`).join("\n")
}
function wrap(value: string, width: number) {
return value.split("\n").flatMap((source) => {
if (source.length <= width) return [source]
return source.split(/\s+/).reduce<string[]>((result, word) => {
const index = result.length - 1
if (index < 0 || result[index].length + word.length + 1 > width) result.push(word)
else result[index] += ` ${word}`
return result
}, [])
})
}
+13 -2
View File
@@ -1,7 +1,8 @@
#!/usr/bin/env bun
import { NodeRuntime, NodeServices } from "@effect/platform-node"
import { Effect } from "effect"
import { Cause, Effect } from "effect"
import { getErrorReported } from "effect/Runtime"
import { Commands } from "./commands/commands"
import { Runtime } from "./framework/runtime"
import { Observability } from "@opencode/util/observability"
@@ -130,5 +131,15 @@ Effect.gen(function* () {
Effect.provide(NodeServices.layer),
Effect.scoped,
Effect.tap(() => Effect.sync(() => process.exit(process.exitCode ?? 0))),
NodeRuntime.runMain,
// runMain's default reporter logs the fatal cause to stdout. Write it to stderr instead: the
// desktop and `Service.ensure` only capture stderr from `serve --service`, so this is the only
// channel through which a startup failure's reason reaches the user.
Effect.tapCause((cause) =>
Effect.sync(() => {
if (Cause.hasInterruptsOnly(cause)) return
if (!getErrorReported(Cause.squash(cause))) return
process.stderr.write(Cause.pretty(cause) + "\n")
}),
),
NodeRuntime.runMain({ disableErrorReporting: true }),
)
+187 -27
View File
@@ -5,7 +5,9 @@ import { Context, Duration, Effect, FileSystem, Layer, Option, Ref, Schema } fro
import { ChildProcess } from "effect/unstable/process"
import { parse, type ParseError } from "jsonc-parser"
import path from "node:path"
import { stripVTControlCharacters } from "node:util"
import { action, parseReleaseVersion, type Policy } from "./updater-action"
import { errorMessage } from "../util/error"
export const methods = ["curl", "npm", "pnpm", "bun", "yarn", "vp", "brew"] as const
@@ -13,10 +15,82 @@ export type Method = (typeof methods)[number]
export type RunResult = { readonly type: "available" | "installed"; readonly version: string }
export type CheckResult = RunResult | { readonly type: "unavailable"; readonly message: string }
export class UpgradeError extends Error {
readonly title: string
readonly detail: string
readonly command?: string
readonly retry: string
constructor(
input: {
readonly title: string
readonly detail: string
readonly command?: string
readonly retry: string
},
options?: ErrorOptions,
) {
super(input.detail, options)
this.name = "UpgradeError"
this.title = input.title
this.detail = input.detail
this.command = input.command
this.retry = input.retry
}
}
const decodeVpPackages = Schema.decodeUnknownOption(
Schema.fromJsonString(Schema.Array(Schema.Struct({ name: Schema.String }))),
)
const installNames: Record<Method, string> = {
curl: "The OpenCode installer",
npm: "npm",
pnpm: "pnpm",
bun: "Bun",
yarn: "Yarn",
vp: "Vite+",
brew: "Homebrew",
}
function conciseDetail(input: string) {
const lines = stripVTControlCharacters(input)
.trim()
.replaceAll("\r", "\n")
.split("\n")
.map((line) => line.trimEnd())
.filter((line) => line.trim().length > 0)
const tail = lines.slice(-12).join("\n")
const clipped = tail.length > 2_000
const detail = clipped ? `${tail.slice(-1_999)}` : tail
if (!detail) return
if (lines.length <= 12 && !clipped) return detail
return `${detail}\n\nOutput shortened to the last 12 lines.`
}
function errorDetail(cause: unknown): string {
if (cause instanceof AppProcess.AppProcessError) {
const stderr = conciseDetail(cause.stderr ?? "")
if (stderr) return stderr
if (cause.cause !== undefined) return errorDetail(cause.cause)
return cause.message
}
if (cause instanceof Error) {
const detail = cause.cause === undefined ? undefined : errorDetail(cause.cause)
if (!detail || detail === cause.message) return cause.message
return `${cause.message}: ${detail}`
}
return errorMessage(cause)
}
function resultDetail(result: { code: number; stdout: string; stderr: string }) {
return (
conciseDetail(result.stderr) ??
conciseDetail(result.stdout) ??
`The command exited with code ${result.code} without any error output.`
)
}
export interface Interface {
readonly run: (onInstall?: (version: string) => void) => Effect.Effect<RunResult | undefined>
readonly check: () => Effect.Effect<CheckResult | undefined, Error>
@@ -24,9 +98,9 @@ export interface Interface {
readonly method: () => Effect.Effect<Method | undefined>
readonly latest: () => Effect.Effect<string, Error>
readonly upgrade: (method: Method, version: string) => Effect.Effect<void, Error>
readonly removal: (method: Method) =>
| { readonly command: ReadonlyArray<string>; readonly run: Effect.Effect<void, Error> }
| undefined
readonly removal: (
method: Method,
) => { readonly command: ReadonlyArray<string>; readonly run: Effect.Effect<void, Error> } | undefined
}
export class Service extends Context.Service<Service, Interface>()("@opencode/cli/Updater") {}
@@ -89,7 +163,6 @@ const make = Effect.gen(function* () {
stdout: result.stdout.toString("utf8"),
stderr: result.stderr.toString("utf8"),
})),
Effect.orElseSucceed(() => ({ code: 1, stdout: "", stderr: "" })),
)
})
@@ -119,7 +192,11 @@ const make = Effect.gen(function* () {
]
const results = yield* Effect.forEach(
checks,
(check) => exec(check.command).pipe(Effect.map((result) => ({ check, result }))),
(check) =>
exec(check.command).pipe(
Effect.orElseSucceed(() => ({ code: 1, stdout: "", stderr: "" })),
Effect.map((result) => ({ check, result })),
),
{ concurrency: "unbounded" },
)
return results.find((result) => {
@@ -144,11 +221,7 @@ const make = Effect.gen(function* () {
return {
command,
run: exec(command, "5 minutes").pipe(
Effect.flatMap((result) =>
result.code === 0
? Effect.void
: Effect.fail(new Error(result.stderr.trim() || `Failed to uninstall with ${method}`)),
),
Effect.flatMap((result) => (result.code === 0 ? Effect.void : Effect.fail(new Error(resultDetail(result))))),
),
}
}
@@ -163,14 +236,44 @@ const make = Effect.gen(function* () {
signal: AbortSignal.any([signal, AbortSignal.timeout(10_000)]),
},
),
catch: (cause) => new Error("Failed to check for updates", { cause }),
catch: (cause) =>
new UpgradeError(
{
title: "Could not check for OpenCode updates",
detail: errorDetail(cause),
retry: "Check your network, then run opencode upgrade again.",
},
{ cause },
),
})
if (!response.ok) return yield* Effect.fail(new Error(`Update check failed with status ${response.status}`))
if (!response.ok)
return yield* Effect.fail(
new UpgradeError({
title: "Could not check for OpenCode updates",
detail: `The update service returned HTTP ${response.status}.`,
retry: "Try again in a few minutes.",
}),
)
const data: { version: string; metadata?: { package?: string } } = yield* Effect.tryPromise({
try: () => response.json(),
catch: (cause) => new Error("Failed to read update information", { cause }),
catch: (cause) =>
new UpgradeError(
{
title: "Could not read the OpenCode update information",
detail: errorDetail(cause),
retry: "Try again in a few minutes.",
},
{ cause },
),
})
if (!data.metadata?.package) return yield* Effect.fail(new Error("Update information did not include a package"))
if (!data.metadata?.package)
return yield* Effect.fail(
new UpgradeError({
title: "Could not read the OpenCode update information",
detail: "The update service returned incomplete release information.",
retry: "Try again in a few minutes.",
}),
)
return { package: data.metadata.package, version: data.version }
})
@@ -185,6 +288,40 @@ const make = Effect.gen(function* () {
fs.remove(directory, { recursive: true, force: true }).pipe(Effect.ignore),
)
const runUpgrade = (input: {
readonly method: Method
readonly command: string[]
readonly displayCommand?: string[]
readonly title?: string
readonly retry?: string
}) => {
const failure = (detail: string, cause?: unknown) =>
new UpgradeError(
{
title: input.title ?? `${installNames[input.method]} could not install OpenCode`,
detail,
command: (input.displayCommand ?? input.command).join(" "),
retry: input.retry ?? "Fix the issue above, then run opencode upgrade again.",
},
cause === undefined ? undefined : { cause },
)
return exec(input.command, "5 minutes").pipe(
Effect.flatMap((result) =>
result.code === 0 ? Effect.succeed(result) : Effect.fail(failure(resultDetail(result))),
),
Effect.mapError((cause) =>
cause instanceof UpgradeError
? cause
: failure(
cause instanceof AppProcess.AppProcessError && cause.stderr === undefined && cause.cause === undefined
? `Failed to update with ${input.method}`
: errorDetail(cause),
cause,
),
),
)
}
const upgrade = Effect.fnUntraced(function* (method: Method, input: string) {
if (!parseReleaseVersion(input)) return yield* Effect.fail(new Error(`Invalid version: ${input}`))
const version = input.trim().replace(/^v/, "")
@@ -212,31 +349,54 @@ const make = Effect.gen(function* () {
? ["vp", "install", "-g", "--force", target]
: ["vp", "update", "-g", target],
}
const result = yield* Effect.scoped(
yield* Effect.scoped(
Effect.gen(function* () {
if (method === "bun") {
// Bun does not prune old versions from its shared package cache.
yield* fs.makeDirectory(global.cache, { recursive: true })
const cache = yield* temporaryDirectory("update-")
return yield* exec(["bun", "install", "--global", "--trust", "--cache-dir", cache, target], "5 minutes")
return yield* runUpgrade({
method,
command: ["bun", "install", "--global", "--trust", "--cache-dir", cache, target],
displayCommand: ["bun", "install", "--global", "--trust", target],
})
}
if (method === "curl") {
yield* fs.makeDirectory(global.cache, { recursive: true })
const directory = yield* temporaryDirectory("update-")
const installer = path.join(directory, "install")
const download = yield* exec(
["curl", "-fsSL", "-o", installer, "https://opencode.ai/v2/install"],
"5 minutes",
)
if (download.code !== 0) return download
return yield* exec(["bash", installer, "--version", version, "--no-modify-path"], "5 minutes")
yield* runUpgrade({
method,
command: ["curl", "-fsSL", "-o", installer, "https://opencode.ai/v2/install"],
displayCommand: ["curl", "-fsSL", "https://opencode.ai/v2/install"],
title: "Could not download the OpenCode installer",
retry: "Check your network, then run opencode upgrade again.",
})
return yield* runUpgrade({
method,
command: ["bash", installer, "--version", version, "--no-modify-path"],
displayCommand: ["opencode", "upgrade", version, "--method", "curl"],
title: "The OpenCode installer failed",
})
}
if (method === "brew") return yield* exec(["brew", "upgrade", packageName], "5 minutes")
return yield* exec(commands[method], "5 minutes")
if (method === "brew") return yield* runUpgrade({ method, command: ["brew", "upgrade", packageName] })
return yield* runUpgrade({ method, command: commands[method] })
}),
).pipe(Effect.mapError((cause) => new Error(`Failed to update with ${method}`, { cause })))
if (result.code === 0) return
return yield* Effect.fail(new Error(result.stderr.trim() || `Failed to update with ${method}`))
).pipe(
Effect.mapError((cause) =>
cause instanceof UpgradeError
? cause
: new UpgradeError(
{
title: "Could not prepare the OpenCode upgrade",
detail: errorDetail(cause),
retry: "Fix the issue above, then run opencode upgrade again.",
},
{ cause },
),
),
Effect.asVoid,
)
})
const inspect = Effect.fnUntraced(function* () {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/client",
"version": "2.0.10",
"version": "2.0.12",
"type": "module",
"license": "MIT",
"repository": {
+54 -33
View File
@@ -24,7 +24,7 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
and Map, RegExp, and generators serialize as `{}`. A bare `undefined` result is `null`.
Tool results come back the way `JSON.parse(JSON.stringify(result))` would. The table, where a value cannot
be JSON but what the program meant is clear: a promise is awaited (a rejection fails the program), a Set
crosses as an array, a URLSearchParams as its query string, an Error as `{ name, message, ...own }`, a
crosses as an array, a URLSearchParams as its query string, an Error as `{ name, message, ...own enumerable }`, a
Uint8Array is rejected with a hint to encode as text, and own `__proto__` keys are dropped so merging tool
inputs or results cannot replace a prototype. In-program `JSON.stringify` keeps JS behavior except for the
Error form and a promise, which is a `TypeError` with an await hint rather than a silent `{}`.
@@ -38,8 +38,11 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
10,000,000 elements (`Array(n)`, `length =`, `Array.from`, `split`, `matchAll`, `concat`, `flat`; below the JS
maximum of 2^32 - 1), and 10,000 pending promises at once. Exceeding one throws a `RangeError`. A single regular
expression match can still run long on a pathological pattern; the host regex engine has no interrupt hook.
- [ ] Strict-mode early errors: duplicate parameter names, `yield` as an identifier, and a trailing comma after a
rest parameter are accepted unless the program itself begins with `"use strict"`.
- [x] A trailing comma after a rest parameter is a syntax error, with or without `"use strict"`.
- [x] A program that begins with `"use strict"` rejects `yield` as an identifier and duplicate parameter names at
parse time. Without it, `yield` is an ordinary binding.
- [ ] Duplicate parameter names in non-strict code throw when the function is called, instead of binding the last
parameter as JavaScript does.
## Values and literals
@@ -53,7 +56,7 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
- [x] `NaN` and `Infinity` globals.
- [ ] BigInt literals and in-interpreter BigInt arithmetic; BigInt remains invalid at JSON-like host boundaries.
- [ ] Arbitrary Symbol primitive values and symbol-keyed properties. The confined `Symbol.iterator` and
`Symbol.asyncIterator` keys are available only for custom iterator protocols.
`Symbol.asyncIterator` keys are available only for the iterator protocols.
- [ ] Tagged-template calls.
- [ ] Getter and setter definitions in object literals.
@@ -92,9 +95,10 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
- [x] `if`/`else` and conditional expressions.
- [x] `switch`, including default clauses and fallthrough.
- [x] `for`, `while`, and `do...while`.
- [x] `for...of` over arrays, strings, Maps, Sets, URLSearchParams, Headers, custom synchronous iterators, and
confined synchronous generators. Abrupt completion invokes the iterator's optional `return()`.
- [x] `for...in` over own keys of plain objects, arrays, strings, and tool references; other values iterate nothing.
- [x] `for...of` over arrays, strings, Maps, Sets, URLSearchParams, Headers, Uint8Arrays, built-in iterators, custom
synchronous iterators, and confined synchronous generators. Abrupt completion invokes the iterator's optional `return()`.
- [x] `for...in` over own keys of plain objects, arrays, strings, and tool references. `null`, `undefined`, and other
non-objects iterate nothing. An un-awaited promise throws rather than iterating.
- [x] Unlabeled `break` and `continue`.
- [x] `try`, `catch`, optional catch bindings, and `finally`.
- [x] `throw` with arbitrary values.
@@ -108,7 +112,8 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
## Functions and callbacks
- [x] Function declarations, function expressions, and arrow functions.
- [x] Synchronous and `async` functions.
- [x] Synchronous and `async` functions. A line break between `function` and the name is allowed, as in JavaScript;
a line break between `async` and `function` is not an async function.
- [x] Closures, recursion, default parameters, rest parameters, and destructured parameters.
- [x] A call depth limit of 10000: deeper nesting throws a catchable `RangeError: Maximum call stack size exceeded`
at the overflowing call instead of running until the timeout. Callbacks invoked by built-ins count below the
@@ -143,7 +148,6 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
`Array.prototype.push.name === "push"`).
- [ ] A named function expression's name is not bound inside its own body.
- [ ] Redeclaring a function in the same scope is rejected; in JavaScript the last declaration wins.
- [ ] A line terminator between `async function` and the function name.
- [ ] Generator and async generator functions evaluate parameter defaults and destructuring at the first `next()`
rather than at the call, so their errors are not thrown synchronously.
- [x] Synchronous and async generator declarations/expressions, `yield`, and `yield*`, including lazy bodies,
@@ -180,7 +184,8 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
on any other value throws a catchable `TypeError` naming the callee: other built-in functions such as `Number`
say `new` is unsupported and point at the plain call, user-defined functions report the constructor gap below,
and non-callable values are not constructors. Error constructors take the ES2022 options object, so
`new Error(message, { cause })` installs a non-enumerable `cause` when the option is present.
`new Error(message, { cause })` installs a non-enumerable `cause` when the option is present. `Error.isError`
is true for every Error value.
- [x] Arithmetic operators: `+`, `-`, `*`, `/`, `%`, and `**`.
- [x] Equality and ordering: `==`, `!=`, `===`, `!==`, `<`, `<=`, `>`, and `>=`.
- [x] Bitwise operators: `&`, `|`, `^`, `~`, `<<`, `>>`, and `>>>`.
@@ -270,8 +275,8 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
- [x] `Object.is` for supported data values.
- [x] `Object.groupBy` over finite collections and custom synchronous iterators/generators, with string-key coercion
and plain-object results.
- [x] `Object.prototype` methods on values: `toString` (`"[object Array]"`), `toLocaleString`, `valueOf`,
`hasOwnProperty`, `isPrototypeOf`, and `propertyIsEnumerable`.
- [x] `Object.prototype` methods on values: `toString` (`"[object Array]"`), `toLocaleString` (calls the value's
`toString`, as in JS), `valueOf`, `hasOwnProperty`, `isPrototypeOf`, and `propertyIsEnumerable`.
## Arrays
@@ -285,9 +290,14 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
`lastIndexOf`.
- [x] Aggregation: `reduce` and `reduceRight`.
- [x] Ordering: `sort`, `toSorted`, `reverse`, and `toReversed`.
- [x] Access/copying: `at`, `slice`, `concat`, `flat`, `with`, and `join`.
- [x] Access/copying: `at`, `slice`, `concat`, `flat`, `with`, `join`, and `toLocaleString` (each element's
`toLocaleString`, holes and nullish elements as empty strings).
- [x] Mutation: `push`, `pop`, `shift`, `unshift`, `splice`, `fill`, and `copyWithin`.
- [x] Materialized iteration helpers: `keys`, `values`, and `entries` return arrays rather than iterators.
- [x] `keys`, `values`, `entries`, and `[Symbol.iterator]` (the same function as `values`) return live iterator objects
with `next()` and `[Symbol.iterator]`, as in JS. Iterator objects are opaque references: they print as
`[opaque reference]`, serialize to `{}`, and cannot be passed to extensions. Every built-in collection iterator
shares one prototype. JavaScript gives each collection its own; the difference is not observable here because
`Object.getPrototypeOf` is not exposed.
- [x] `length`, numeric indexing, index assignment, spread, and `for...of`.
- [x] The `thisArg` argument of `Array.from` is accepted and ignored, like JS arrows.
- [x] `Array.prototype.toSpliced`.
@@ -300,13 +310,13 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
form, like `JSON.stringify`.
- [ ] Argument coercion for `indexOf`, `lastIndexOf`, `includes`, `fill`, `flat`, `copyWithin`, and the `join`
separator: JavaScript applies ToIntegerOrInfinity/ToString (including `valueOf`, strings, and `undefined`), the
interpreter requires numbers and strings; `includes()`/`indexOf()` with no argument should search for
`undefined`.
- [ ] Iterator objects from `keys`, `values`, and `entries` with a live `next()`.
interpreter requires numbers and strings. `indexOf()` and `lastIndexOf()` with no argument already search for
`undefined`; `includes()` still requires a value.
## Strings
- [x] Case/normalization: `toLowerCase`, `toUpperCase`, `normalize`.
- [x] Case/normalization: `toLowerCase`, `toUpperCase`, `normalize`, and the `toLocaleLowerCase`/`toLocaleUpperCase`
aliases, which ignore their locale argument and apply the default Unicode casing.
- [x] Trimming: `trim`, `trimStart`, and `trimEnd`, plus the Annex B `trimLeft` and `trimRight` aliases.
- [x] Searching/tests: `includes`, `startsWith`, `endsWith`, `indexOf`, `lastIndexOf`, and `search`.
- [x] Slicing/access: `slice`, `substring`, Annex B `substr`, `at`, `charAt`, `charCodeAt`, and `codePointAt`.
@@ -314,7 +324,7 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
- [x] Regular-expression integration: `match`, materialized `matchAll`, `replace`, `replaceAll`, `split`, and `search`.
- [x] `localeCompare`; locale and options arguments are currently ignored.
- [x] `isWellFormed` and `toWellFormed`.
- [x] `toString`, `length`, numeric indexing, spread, and `for...of` by Unicode code point.
- [x] `toString`, `length`, numeric indexing, spread, `for...of`, and `[Symbol.iterator]` by Unicode code point.
- [x] Static `String.fromCharCode` and `String.fromCodePoint`.
- [x] Native argument coercion for supported String methods; for example, `includes(1)` and `slice("1")` coerce like
native JS, `split(undefined)` returns the whole string, and `includes`/`startsWith`/`endsWith` reject regular
@@ -330,7 +340,8 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
- [x] Coercion functions: `Number`, `parseInt`, and `parseFloat`.
- [x] Number predicates/parsers: `Number.isInteger`, `Number.isFinite`, `Number.isNaN`, `Number.isSafeInteger`,
`Number.parseInt`, and `Number.parseFloat`.
- [x] Number formatting: `toFixed`, `toPrecision`, `toExponential`, `toString`, and `valueOf`.
- [x] Number formatting: `toFixed`, `toPrecision`, `toExponential`, `toString`, `valueOf`, and `toLocaleString`, which
always formats as `en-US` (`"1,234.5"`) so output does not depend on the host.
- [x] Number constants: `MAX_SAFE_INTEGER`, `MIN_SAFE_INTEGER`, `MAX_VALUE`, `MIN_VALUE`, `EPSILON`, `NaN`,
`POSITIVE_INFINITY`, and `NEGATIVE_INFINITY`.
- [x] Math constants: `PI`, `E`, `LN2`, `LN10`, `LOG2E`, `LOG10E`, `SQRT2`, and `SQRT1_2`.
@@ -343,7 +354,8 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
use their epoch time) and reject opaque runtime references as data errors.
- [x] Unknown static members on global namespaces and on `Number`/`String`/the coercion functions read as `undefined`
for feature detection. Calling any undefined value reports a native-style `TypeError` naming the callee, for
example `Math.sum is not a function.` Unknown `Promise` statics keep their descriptive error.
example `Math.sum is not a function.` or `search(...).catch is not a function.` Unknown `Promise` statics keep
their descriptive error.
- [x] `Math.sumPrecise` over finite collections and custom synchronous iterators/generators, rejecting non-number
elements without coercion.
- [x] Global coercing `isFinite` and `isNaN`; opaque runtime references reject as data errors, like `Number(...)`.
@@ -377,6 +389,8 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
`TimeClip` behavior.
- [x] `Date.prototype.toUTCString` and its `toGMTString` alias.
- [x] `toDateString` and `toTimeString` in the host's local timezone.
- [x] `toLocaleString`, `toLocaleDateString`, and `toLocaleTimeString` always format as `en-US` in UTC
(`"1/1/1970, 12:00:00 AM"`) so output does not depend on the host.
- [x] Native one-argument Date coercion for supported values, including booleans, null, arrays, and plain objects.
- [ ] Date setters and multi-argument construction coerce object arguments through `valueOf`/`toString` and surface
their throws.
@@ -402,10 +416,11 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
- [x] Static `Map.groupBy` over finite collections and custom synchronous iterators/generators, preserving key identity.
- [x] `new Map()` from synchronous iterables of entries.
- [x] Map `get`, `set`, `has`, `delete`, `clear`, `size`, and `forEach`.
- [x] Map `get`, `set`, `has`, `delete`, `clear`, `size`, `forEach`, `getOrInsert`, and `getOrInsertComputed`.
- [x] `new Set()` from synchronous iterables.
- [x] Set `add`, `has`, `delete`, `clear`, `size`, and `forEach`.
- [x] Materialized `keys`, `values`, and `entries` arrays for Map and Set.
- [x] Live `keys`, `values`, `entries`, and `[Symbol.iterator]` iterators for Map and Set; a Set-like operand's `keys()`
may return a built-in iterator or an array.
- [x] Spread, `for...of`, `Array.from`, and `Object.fromEntries` integration.
- [x] Map and Set values serialize to `{}` at host/JSON boundaries.
- [x] Set composition and relation methods: `union`, `intersection`, `difference`, `symmetricDifference`, `isSubsetOf`,
@@ -421,8 +436,9 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
- [x] Writable URL fields except `origin`.
- [x] `new URLSearchParams()` from query strings, data objects, synchronous iterables of pairs, and URLSearchParams.
- [x] URLSearchParams `append`, `delete`, `get`, `getAll`, `has`, `set`, `sort`, `forEach`, `keys`, `values`,
`entries`, `toString`, and `size`.
- [x] URL values serialize to their href; URLSearchParams serialize to `{}`.
`entries`, `[Symbol.iterator]`, `toString`, and `size`.
- [x] URL values are their href in `JSON.stringify` and at the host boundary. URLSearchParams are `{}` in
`JSON.stringify` and their query string at the host boundary.
## Uint8Array
@@ -434,7 +450,8 @@ with a hint to encode as text first (`TextDecoder`, `toBase64`, `toHex`).
- [x] Index reads and writes with JS byte semantics: values wrap modulo 256, out-of-range writes are ignored, indexes
cannot be deleted. `length` is a prototype accessor, so `Object.keys` lists only indexes.
- [x] `at`, `slice`, `subarray` (a view on the same bytes), `set`, `fill`, `reverse`, `indexOf`, `lastIndexOf`,
`includes`, `join`, `toString`, `toBase64`, `toHex`, and materialized `keys`, `values`, and `entries` arrays.
`includes`, `join`, `toString`, `toBase64`, `toHex`, and live `keys`, `values`, `entries`, and `[Symbol.iterator]`
iterators.
- [x] Spread, destructuring, `for...of`, `yield*`, `Array.from`, and `new Set(bytes)`. `Array.isArray` is false.
- [x] String coercion joins with commas; `JSON.stringify` gives `{"0":1,...}`; `console.log` prints
`Uint8Array(n) [...]`.
@@ -450,8 +467,8 @@ with a hint to encode as text first (`TextDecoder`, `toBase64`, `toHex`).
`fatal` and `ignoreBOM` options; `decode` takes a Uint8Array or nothing.
- [x] `new Headers()` from records, synchronous iterables of pairs, and Headers, wrapping the host's `Headers`: names
fold to lowercase, values are normalized and combined, and invalid names or values throw a `TypeError`.
- [x] Headers `append`, `delete`, `get`, `getSetCookie`, `has`, `set`, `forEach`, `keys`, `values`, and `entries`;
iteration is live and sorted by name, with `set-cookie` values kept apart.
- [x] Headers `append`, `delete`, `get`, `getSetCookie`, `has`, `set`, `forEach`, `keys`, `values`, `entries`, and
`[Symbol.iterator]`; iteration is live and sorted by name, with `set-cookie` values kept apart.
- [x] Headers serialize to a `{ name: value }` object in JSON, in results, and in tool arguments.
- [ ] `Request`, `Response`, and `Blob`.
- [ ] `crypto.subtle` and `TextDecoder` streaming or non-UTF-8 encodings.
@@ -466,7 +483,8 @@ Nothing is exposed unless a host provides it; extension calls are not tool calls
- [x] Every value crossing in either direction is converted, never shared: plain objects and arrays are copied,
`Date`, `RegExp`, `URL`, `URLSearchParams`, `Headers`, `Map`, `Set`, and `Uint8Array` become fresh copies with
their contents converted (a host `ArrayBuffer` comes in as a `Uint8Array`; other typed arrays cannot come out),
errors cross as errors with their name and message, and a `__proto__` key is dropped. Functions, generators,
errors cross as errors with their name, message, `cause`, and own enumerable data, and a `__proto__` key is
dropped. Functions, generators,
un-awaited promises, and symbols cannot be passed in; a class instance, a symbol, or a BigInt cannot come out.
- [x] A host function inside a result becomes a program function whose calls cross the same way, so a result can
carry methods (`res.json()`) whose host closures keep the host state. Diagnostics name it by its path
@@ -491,8 +509,10 @@ Nothing is exposed unless a host provides it; extension calls are not tool calls
- [x] `AggregateError` with the `(errors, message?)` signature and an own `errors` array, constructed directly or by
an all-rejected `Promise.any`; direct construction accepts custom synchronous iterators and generators.
- [x] Error `name`/`message`, error inheritance through `instanceof`, and plain-data serialization. `message` is an own
non-enumerable property and `name` is inherited, as in JS, so `Object.keys(err)` is `[]` while the host still
receives `{ name, message }`. Errors have no `stack`; the diagnostic carries the source location instead.
non-enumerable property and `name` is inherited, as in JS, so `Object.keys(err)` is `[]` for a plain error. The
result boundary still emits `{ name, message, ...own enumerable }`, so a field such as `code` crosses. `cause` is
non-enumerable: an extension Error carries it, and this JSON form does not. Errors have no `stack`; the diagnostic
carries a 1-based line and column in the submitted source instead.
- [x] `instanceof` against any constructor with a `prototype`, including every built-in and `Function`.
- [x] Catchable user throws, runtime failures raised during interpreted evaluation, awaited tool failures, and awaited
tool-call-limit failures; parse/compile failures, cooperative timeout, and output bounding remain outside program
@@ -501,8 +521,9 @@ Nothing is exposed unless a host provides it; extension calls are not tool calls
short orientation to the supported subset; this matrix is the full reference.
- [x] Model-visible host failure messages and underlying causes, including output-validation errors.
- [x] Caught errors do not distinguish user throws, interpreter failures, and tool failures; a program sees one
Error-shaped value with `name` and `message` in `catch`, rejection handlers, and `Promise.allSettled` reasons.
This is deliberate: the program should handle a failure the same way regardless of where it originated.
Error-shaped value in `catch`, rejection handlers, and `Promise.allSettled` reasons. It always has `name` and
`message`, plus `cause` and own data when the failure carried them. This is deliberate: the program should
handle a failure the same way regardless of where it originated.
- [x] Failures raised by the interpreter are `TypeError`s unless JavaScript names them otherwise (`RangeError`,
`ReferenceError`, `SyntaxError`, `URIError`), so `e instanceof TypeError` and `e.constructor === TypeError`
hold. Unsupported syntax reached at runtime is a `SyntaxError`; awaited tool failures stay plain `Error`.
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/codemode",
"version": "2.0.10",
"version": "2.0.12",
"description": "Effect-native confined code execution over schema-described tools",
"type": "module",
"license": "MIT",
@@ -1,6 +1,7 @@
import { Effect, Exit } from "effect"
import { coerceToNumber, coerceToString } from "../stdlib/value.js"
import type { Interpreter } from "./interpreter.js"
import { primitivePrototype } from "./intrinsics.js"
import { typeError } from "./model.js"
import { Callable, get, Native, DateObj, Obj } from "./objects.js"
import { typeofValue } from "./references.js"
@@ -43,6 +44,13 @@ export const toPrimitive = <R>(
})
}
/** Invoke(value, name): calls the method the value would find through its prototype. */
export const invoke = <R>(ctx: Interpreter<R>, value: unknown, name: string, label: string) => {
const target = value instanceof Obj ? value : primitivePrototype(ctx.builtins, value)
if (target === undefined) throw typeError(`${label} called on null or undefined.`)
return ctx.call(get(target, name), value, [])
}
export const toPrimitiveString = <R>(ctx: Interpreter<R>, value: unknown) =>
Effect.map(toPrimitive(ctx, value, "string"), coerceToString)
@@ -168,6 +168,7 @@ export const errorGlobal = <R>(type: ErrorType, ctx: Interpreter<R>) => {
methods(builtins, prototype, [
["toString", 0, (thisValue) => errorToString(receiver(Obj, thisValue, "Error.prototype.toString"))],
])
methods(builtins, ctor, [["isError", 1, (_, args) => args[0] instanceof ErrorObj]])
}
return ctor
}
@@ -20,6 +20,7 @@ import {
DateObj,
ErrorObj,
GeneratorObj,
IteratorObj,
MapObj,
Obj,
PromiseObj,
@@ -61,6 +62,7 @@ export const extensionGlobals = <R>(
!(value instanceof Obj) ||
value instanceof Callable ||
value instanceof GeneratorObj ||
value instanceof IteratorObj ||
value instanceof PromiseObj
) {
throw typeError(`${label} contains ${describeValue(value)}, which cannot be passed to an extension.`)
@@ -12,6 +12,7 @@ import { regexpGlobal } from "../stdlib/regexp.js"
import { stringGlobal } from "../stdlib/string.js"
import { uriGlobal, urlGlobal, urlSearchParamsGlobal } from "../stdlib/url.js"
import { headersGlobal } from "../stdlib/headers.js"
import { iteratorGlobals } from "../stdlib/iterator.js"
import { coercion } from "../stdlib/value.js"
import { base64Global, cryptoGlobal } from "../stdlib/web.js"
import { ToolReference } from "../tool-runtime.js"
@@ -101,5 +102,6 @@ export const globalNames: ReadonlySet<string> = new Set(Object.keys(table))
/** The immutable global bindings of every program, in declaration order. */
export const globals = <R>(ctx: Interpreter<R>): ReadonlyArray<readonly [string, unknown]> => {
generatorGlobals(ctx)
iteratorGlobals(ctx)
return Object.entries(table).map(([name, factory]) => [name, factory(ctx)] as const)
}
@@ -62,7 +62,7 @@ import {
} from "./model.js"
import { checkStringLength } from "./limits.js"
import { locate, materialize } from "./errors.js"
import type { Builtins } from "./intrinsics.js"
import { type Builtins, primitivePrototype } from "./intrinsics.js"
import { globals } from "./globals.js"
import {
assign,
@@ -79,6 +79,7 @@ import {
DateObj,
Fn,
GeneratorObj,
IteratorObj,
MapObj,
Obj,
PromiseObj,
@@ -109,20 +110,25 @@ const loopExit = (result: StatementResult, labels: ReadonlySet<string> | undefin
return undefined
}
const calleeDescription = (callee: Expression | Super | undefined): string => {
if (callee?.type === "Identifier") return callee.name
if (callee?.type === "MemberExpression") {
const object = callee.object
const property = callee.property
const key =
!callee.computed && property.type === "Identifier"
? property.name
: property.type === "Literal" && typeof property.value === "string"
? property.value
: undefined
if (object.type === "Identifier" && key !== undefined) return `${object.name}.${key}`
// Native engines name the callee (`search(...).catch is not a function`), including call chains. Returns
// undefined when a link cannot be named, so a chain is either named completely or not at all.
const calleeDescription = (node: Expression | Super | undefined): string | undefined => {
if (node?.type === "Identifier") return node.name
if (node?.type === "CallExpression") {
const target = calleeDescription(node.callee)
return target === undefined ? undefined : `${target}(...)`
}
return "The called value"
if (node?.type !== "MemberExpression") return undefined
const property = node.property
const key =
!node.computed && property.type === "Identifier"
? property.name
: property.type === "Literal" && typeof property.value === "string"
? property.value
: undefined
if (key === undefined) return undefined
const object = calleeDescription(node.object)
return object === undefined ? undefined : `${object}.${key}`
}
// OrdinaryHasInstance: walk the left operand's chain looking for the constructor's `prototype`.
@@ -650,8 +656,8 @@ class Frame<R> {
if (declared?.lexical) self.predeclarePattern(declared.pattern, declared.mutable, left)
const right = yield* self.evaluateExpression(node.right)
const iterator = yield* self.customIterator(right, node, awaiting)
const cursor = iterator === undefined ? yield* self.iterate(right, node) : undefined
const cursor = self.hostCursor(right)
const iterator = cursor === undefined ? yield* self.customIterator(right, node, awaiting) : undefined
if (iterator === undefined && cursor === undefined) {
throw invalidData(
`${awaiting ? "for await...of" : "for...of"} requires an array, string, Map, Set, URLSearchParams, or Headers, or custom iterator value.`,
@@ -746,6 +752,20 @@ class Frame<R> {
}
iterate(value: unknown, node?: AstNode) {
const cursor = this.hostCursor(value)
if (cursor !== undefined) return Effect.succeed(cursor)
const self = this
return Effect.map(this.customIterator(value, node, false), (iterator) =>
iterator === undefined
? undefined
: {
next: self.nextIteratorResult(iterator, node, false),
close: Effect.suspend(() => self.closeIterator(iterator, node, false)),
},
)
}
private hostCursor(value: unknown) {
const iterator =
value instanceof Arr
? value.items[Symbol.iterator]()
@@ -761,29 +781,21 @@ class Frame<R> {
? value.headers.entries()
: value instanceof Bytes
? value.bytes.values()
: undefined
if (iterator !== undefined) {
const proto = this.ctx.builtins.Array
return Effect.succeed({
next: Effect.sync(() => {
const step = iterator.next()
return {
done: Boolean(step.done),
value: Array.isArray(step.value) ? new Arr(proto, step.value) : step.value,
}
}),
close: Effect.void,
})
: value instanceof IteratorObj
? value.iterator
: undefined
if (iterator === undefined) return undefined
const proto = this.ctx.builtins.Array
return {
next: Effect.sync(() => {
const step = iterator.next()
return {
done: Boolean(step.done),
value: Array.isArray(step.value) ? new Arr(proto, step.value) : step.value,
}
}),
close: Effect.void,
}
const self = this
return Effect.map(this.customIterator(value, node, false), (iterator) =>
iterator === undefined
? undefined
: {
next: self.nextIteratorResult(iterator, node, false),
close: Effect.suspend(() => self.closeIterator(iterator, node, false)),
},
)
}
private customIterator(value: unknown, node: AstNode | undefined, allowAsync = true) {
@@ -1298,7 +1310,7 @@ class Frame<R> {
// `new` itself is supported, so a non-constructible callee is a TypeError like JS rather than
// unsupported syntax. Built-ins like Number are real constructors in JS, so do not claim
// otherwise; say `new` is unsupported for them and point at the plain call.
const name = calleeDescription(node.callee)
const name = calleeDescription(node.callee) ?? "The called value"
const message =
callee instanceof Fn
? `${name} cannot be constructed: user-defined constructors and classes are not supported. Call it as a function that returns a plain object instead.`
@@ -1601,7 +1613,7 @@ class Frame<R> {
if (callable instanceof Native) {
return yield* self.native(() => (callable as Native<R>).call(thisValue, args), node)
}
throw typeError(`${calleeDescription(callee)} is not a function.`, callee ?? node)
throw typeError(`${calleeDescription(callee) ?? "The called value"} is not a function.`, callee ?? node)
})
}
@@ -2064,16 +2076,14 @@ class Frame<R> {
if (objectValue instanceof Obj) return { target: objectValue, key, receiver: objectValue }
// Primitives read through their wrapper prototype without being boxed; strings own length and indexes.
const builtins = self.ctx.builtins
// Strings own length and indexes; every other primitive property reads through the wrapper prototype.
if (typeof objectValue === "string") {
if (key === "length") return { value: objectValue.length }
const index = typeof key === "symbol" ? undefined : parseArrayIndex(key)
if (index !== undefined) return { value: objectValue[index] }
return { target: builtins.String, key, receiver: objectValue }
}
if (typeof objectValue === "number") return { target: builtins.Number, key, receiver: objectValue }
if (typeof objectValue === "boolean") return { target: builtins.Boolean, key, receiver: objectValue }
const proto = primitivePrototype(self.ctx.builtins, objectValue)
if (proto !== undefined) return { target: proto, key, receiver: objectValue }
if (objectValue === null || objectValue === undefined) {
throw typeError(`Cannot read properties of ${objectValue} (reading '${String(key)}').`, objectNode)
@@ -52,6 +52,14 @@ export const createErrorValue = (prototype: Obj, message: string | undefined): E
return value
}
/** The prototype a primitive reads its methods from without being boxed; none for null, undefined, and symbols. */
export const primitivePrototype = (builtins: Builtins, value: unknown): Obj | undefined => {
if (typeof value === "string") return builtins.String
if (typeof value === "number") return builtins.Number
if (typeof value === "boolean") return builtins.Boolean
return undefined
}
export const createBuiltins = (): Builtins => {
const object = new Obj(null)
// Function.prototype is itself callable and returns undefined.
+3 -2
View File
@@ -86,9 +86,10 @@ export const unsupportedSyntax = (kind: string, node: AstNode): PendingThrow =>
export const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null
// Acorn lines are 1-based and its columns are 0-based. Diagnostics use 1-based columns of the submitted source.
export const sourceLocation = (node: AstNode): { readonly line: number; readonly column: number } => ({
line: Math.max(1, (node.loc?.start.line ?? 2) - 1),
column: Math.max(1, (node.loc?.start.column ?? 4) - 3),
line: node.loc?.start.line ?? 1,
column: (node.loc?.start.column ?? 0) + 1,
})
export const formatLocation = (node?: AstNode): string => {
@@ -122,6 +122,16 @@ export class GeneratorObj extends Obj {
}
}
/** A built-in collection iterator: live over the host collection, yielding program values. */
export class IteratorObj extends Obj {
constructor(
proto: Obj,
readonly iterator: IteratorObject<unknown>,
) {
super(proto)
}
}
export class DateObj extends Obj {
constructor(
proto: Obj,
@@ -9,6 +9,7 @@ import {
Bytes,
DateObj,
GeneratorObj,
IteratorObj,
MapObj,
Obj,
PromiseObj,
@@ -23,6 +24,7 @@ import {
export const isRuntimeReference = (value: unknown): boolean =>
value instanceof Callable ||
value instanceof GeneratorObj ||
value instanceof IteratorObj ||
value instanceof ToolReference ||
value instanceof PromiseObj ||
isWrapper(value)
@@ -89,6 +91,7 @@ export const describeValue = (value: unknown): string => {
if (value instanceof HeadersObj) return "a Headers"
if (value instanceof Bytes) return "a Uint8Array"
if (value instanceof GeneratorObj) return "a generator"
if (value instanceof IteratorObj) return "an iterator"
if (isRuntimeReference(value)) return "a function"
if (typeof value === "object") return "a data object"
return `a ${typeof value}`
+25 -6
View File
@@ -1,10 +1,10 @@
import { Effect } from "effect"
import { constructor, type Method, methods, prototypeFrom, receiver } from "../interpreter/native.js"
import { checkArrayLength, checkStringLength, MAX_ARRAY_LENGTH } from "../interpreter/limits.js"
import { invalidData, rangeError, typeError } from "../interpreter/model.js"
import { get, Arr, GeneratorObj, Obj } from "../interpreter/objects.js"
import { invalidData, IteratorSymbol, rangeError, typeError } from "../interpreter/model.js"
import { define, get, hidden, Arr, GeneratorObj, IteratorObj, Obj } from "../interpreter/objects.js"
import { describeValue, rejectCircularInsertion } from "../interpreter/references.js"
import { applyCollectionCallback, preserveConsumerError } from "../interpreter/callback.js"
import { applyCollectionCallback, invoke, preserveConsumerError } from "../interpreter/callback.js"
import type { Interpreter } from "../interpreter/interpreter.js"
import { compareText } from "../tool-runtime.js"
import { coerceToNumber, coerceToString } from "./value.js"
@@ -340,13 +340,31 @@ export const arrayGlobal = <R>(ctx: Interpreter<R>) => {
return target
},
],
["keys", 0, (thisValue) => wrap(Array.from(self(thisValue, "keys").items.keys()))],
["values", 0, (thisValue) => wrap([...self(thisValue, "values").items])],
[
"toLocaleString",
0,
(thisValue) =>
Effect.map(
Effect.forEach(self(thisValue, "toLocaleString").items, (item) =>
item === null || item === undefined
? Effect.succeed("")
: Effect.map(invoke(ctx, item, "toLocaleString", "Array.prototype.toLocaleString"), coerceToString),
),
(parts) => parts.join(","),
),
],
["keys", 0, (thisValue) => new IteratorObj(builtins.Iterator, self(thisValue, "keys").items.keys())],
["values", 0, (thisValue) => new IteratorObj(builtins.Iterator, self(thisValue, "values").items.values())],
[
"entries",
0,
(thisValue) =>
wrap(Array.from(self(thisValue, "entries").items.entries(), ([index, item]) => wrap([index, item]))),
new IteratorObj(
builtins.Iterator,
self(thisValue, "entries")
.items.entries()
.map(([index, item]) => wrap([index, item])),
),
],
iterate("map", 1, (target, receiver, apply) =>
Effect.gen(function* () {
@@ -490,5 +508,6 @@ export const arrayGlobal = <R>(ctx: Interpreter<R>) => {
}),
),
])
define(proto, IteratorSymbol, get(proto, "values"), hidden)
return array
}
+11 -5
View File
@@ -1,8 +1,8 @@
import { Effect } from "effect"
import { checkArrayLength, checkStringLength } from "../interpreter/limits.js"
import { constructor, methods, prototypeFrom, receiver, requiresNew } from "../interpreter/native.js"
import { rangeError, syntaxError, typeError } from "../interpreter/model.js"
import { defineAccessor, get, Arr, Bytes, Obj } from "../interpreter/objects.js"
import { IteratorSymbol, rangeError, syntaxError, typeError } from "../interpreter/model.js"
import { define, defineAccessor, get, hidden, Arr, Bytes, IteratorObj, Obj } from "../interpreter/objects.js"
import { describeValue } from "../interpreter/references.js"
import type { Interpreter } from "../interpreter/interpreter.js"
import { coerceToNumber, coerceToString } from "./value.js"
@@ -173,15 +173,21 @@ export const uint8ArrayGlobal = <R>(ctx: Interpreter<R>) => {
["toString", 0, (thisValue) => self(thisValue, "toString").bytes.join(",")],
["toBase64", 0, (thisValue) => self(thisValue, "toBase64").bytes.toBase64()],
["toHex", 0, (thisValue) => self(thisValue, "toHex").bytes.toHex()],
["keys", 0, (thisValue) => wrapAll(Array.from(self(thisValue, "keys").bytes.keys()))],
["values", 0, (thisValue) => wrapAll(Array.from(self(thisValue, "values").bytes.values()))],
["keys", 0, (thisValue) => new IteratorObj(builtins.Iterator, self(thisValue, "keys").bytes.keys())],
["values", 0, (thisValue) => new IteratorObj(builtins.Iterator, self(thisValue, "values").bytes.values())],
[
"entries",
0,
(thisValue) =>
wrapAll(Array.from(self(thisValue, "entries").bytes.entries(), ([index, byte]) => wrapAll([index, byte]))),
new IteratorObj(
builtins.Iterator,
self(thisValue, "entries")
.bytes.entries()
.map(([index, byte]) => wrapAll([index, byte])),
),
],
])
define(proto, IteratorSymbol, get(proto, "values"), hidden)
return uint8Array
}
+47 -8
View File
@@ -1,6 +1,6 @@
import { Effect } from "effect"
import { constructor, fn, type Method, methods, prototypeFrom, receiver, requiresNew } from "../interpreter/native.js"
import { invalidData, typeError } from "../interpreter/model.js"
import { invalidData, IteratorSymbol, typeError } from "../interpreter/model.js"
import {
define,
defineAccessor,
@@ -9,6 +9,7 @@ import {
hidden,
isWrapper,
Arr,
IteratorObj,
MapObj,
Obj,
PromiseObj,
@@ -144,6 +145,29 @@ export const mapGlobal = <R>(ctx: Interpreter<R>) => {
return target
},
],
[
"getOrInsert",
2,
(thisValue, args) => {
const target = self(thisValue, "getOrInsert").map
if (!target.has(args[0])) target.set(args[0], args[1])
return target.get(args[0])
},
],
[
"getOrInsertComputed",
2,
(thisValue, args) => {
const target = self(thisValue, "getOrInsertComputed").map
const apply = applyCollectionCallback(ctx, args[1], "Map.getOrInsertComputed")
if (target.has(args[0])) return target.get(args[0])
// The callback sees the stored key (-0 is +0) and its result wins over anything it inserted itself.
return Effect.map(apply([args[0] === 0 ? 0 : args[0]]), (value) => {
target.set(args[0], value)
return value
})
},
],
["delete", 1, (thisValue, args) => self(thisValue, "delete").map.delete(args[0])],
[
"clear",
@@ -153,12 +177,18 @@ export const mapGlobal = <R>(ctx: Interpreter<R>) => {
return undefined
},
],
["keys", 0, (thisValue) => wrap(Array.from(self(thisValue, "keys").map.keys()))],
["values", 0, (thisValue) => wrap(Array.from(self(thisValue, "values").map.values()))],
["keys", 0, (thisValue) => new IteratorObj(builtins.Iterator, self(thisValue, "keys").map.keys())],
["values", 0, (thisValue) => new IteratorObj(builtins.Iterator, self(thisValue, "values").map.values())],
[
"entries",
0,
(thisValue) => wrap(Array.from(self(thisValue, "entries").map.entries(), ([key, item]) => wrap([key, item]))),
(thisValue) =>
new IteratorObj(
builtins.Iterator,
self(thisValue, "entries")
.map.entries()
.map(([key, item]) => wrap([key, item])),
),
],
[
"forEach",
@@ -173,6 +203,7 @@ export const mapGlobal = <R>(ctx: Interpreter<R>) => {
},
],
])
define(proto, IteratorSymbol, get(proto, "entries"), hidden)
return map
}
@@ -218,7 +249,8 @@ const loadSetRecord = <R>(
size: Math.max(Math.trunc(size), 0),
has: (item: unknown) => Effect.map(ctx.call(has, source, [item]), Boolean),
keys: () =>
Effect.flatMap(ctx.call(keys, source, []), (result) => {
Effect.flatMap(ctx.call(keys, source, []), (result): Effect.Effect<Iterable<unknown>> => {
if (result instanceof IteratorObj) return Effect.succeed(result.iterator)
if (result instanceof Arr) return Effect.succeed(result.items)
throw typeError(`Set.${name} expected 'keys' to return an iterator.`)
}),
@@ -338,12 +370,18 @@ export const setGlobal = <R>(ctx: Interpreter<R>) => {
return undefined
},
],
["keys", 0, (thisValue) => wrap(Array.from(self(thisValue, "keys").set.values()))],
["values", 0, (thisValue) => wrap(Array.from(self(thisValue, "values").set.values()))],
["keys", 0, (thisValue) => new IteratorObj(builtins.Iterator, self(thisValue, "keys").set.values())],
["values", 0, (thisValue) => new IteratorObj(builtins.Iterator, self(thisValue, "values").set.values())],
[
"entries",
0,
(thisValue) => wrap(Array.from(self(thisValue, "entries").set.values(), (item) => wrap([item, item]))),
(thisValue) =>
new IteratorObj(
builtins.Iterator,
self(thisValue, "entries")
.set.values()
.map((item) => wrap([item, item])),
),
],
[
"forEach",
@@ -365,5 +403,6 @@ export const setGlobal = <R>(ctx: Interpreter<R>) => {
operation("isSupersetOf"),
operation("isDisjointFrom"),
])
define(proto, IteratorSymbol, get(proto, "values"), hidden)
return set
}
+7
View File
@@ -105,6 +105,13 @@ export const dateGlobal = <R>(ctx: Interpreter<R>) => {
["toTimeString", 0, (thisValue) => new Date(self(thisValue, "toTimeString").time).toTimeString()],
["toUTCString", 0, (thisValue) => new Date(self(thisValue, "toUTCString").time).toUTCString()],
["toGMTString", 0, (thisValue) => new Date(self(thisValue, "toGMTString").time).toUTCString()],
...(["toLocaleString", "toLocaleDateString", "toLocaleTimeString"] as const).map(
(name): Method => [
name,
0,
(thisValue) => new Date(self(thisValue, name).time)[name]("en-US", { timeZone: "UTC" }),
],
),
...getters.map((name): Method => [name, 0, (thisValue) => new Date(self(thisValue, name).time)[name]()]),
...setters.map(
([name, length]): Method => [
+18 -5
View File
@@ -1,7 +1,7 @@
import { Effect } from "effect"
import { constructor, methods, prototypeFrom, receiver, requiresNew } from "../interpreter/native.js"
import { typeError } from "../interpreter/model.js"
import { entries, Arr, HeadersObj, Obj } from "../interpreter/objects.js"
import { IteratorSymbol, typeError } from "../interpreter/model.js"
import { define, entries, get, hidden, Arr, HeadersObj, IteratorObj, Obj } from "../interpreter/objects.js"
import { applyCollectionCallback } from "../interpreter/callback.js"
import { isRuntimeReference } from "../interpreter/references.js"
import type { Interpreter } from "../interpreter/interpreter.js"
@@ -93,13 +93,25 @@ export const headersGlobal = <R>(ctx: Interpreter<R>) => {
return attempt(() => target.set(arg(args, 0), arg(args, 1)))
},
],
["keys", 0, (thisValue) => wrap(Array.from(self(thisValue, "keys").headers.keys()))],
["values", 0, (thisValue) => wrap(Array.from(self(thisValue, "values").headers.values()))],
// Iterator.from because Bun's Headers typings predate iterator helpers; the runtime iterators already have them.
[
"keys",
0,
(thisValue) => new IteratorObj(builtins.Iterator, Iterator.from(self(thisValue, "keys").headers.keys())),
],
[
"values",
0,
(thisValue) => new IteratorObj(builtins.Iterator, Iterator.from(self(thisValue, "values").headers.values())),
],
[
"entries",
0,
(thisValue) =>
wrap(Array.from(self(thisValue, "entries").headers.entries(), ([key, value]) => wrap([key, value]))),
new IteratorObj(
builtins.Iterator,
Iterator.from(self(thisValue, "entries").headers.entries()).map(([key, value]) => wrap([key, value])),
),
],
[
"forEach",
@@ -115,5 +127,6 @@ export const headersGlobal = <R>(ctx: Interpreter<R>) => {
},
],
])
define(proto, IteratorSymbol, get(proto, "entries"), hidden)
return headers
}
+19
View File
@@ -0,0 +1,19 @@
import { methods, receiver } from "../interpreter/native.js"
import { IteratorObj, record } from "../interpreter/objects.js"
import type { Interpreter } from "../interpreter/interpreter.js"
// Every built-in collection iterator shares the Iterator prototype; JS gives each collection its own, which is only
// observable through getPrototypeOf.
export const iteratorGlobals = <R>(ctx: Interpreter<R>): void => {
const builtins = ctx.builtins
methods(builtins, builtins.Iterator, [
[
"next",
0,
(thisValue) => {
const step = receiver(IteratorObj, thisValue, "Iterator.prototype.next").iterator.next()
return record(builtins.Object, { value: step.value, done: Boolean(step.done) })
},
],
])
}
+1
View File
@@ -50,6 +50,7 @@ export const numberGlobal = <R>(ctx: Interpreter<R>) => {
}
methods(builtins, builtins.Number, [
["toFixed", 1, (thisValue, args) => self(thisValue, "toFixed").toFixed(optNum("toFixed", args[0]))],
["toLocaleString", 0, (thisValue) => self(thisValue, "toLocaleString").toLocaleString("en-US")],
[
"toExponential",
1,
+2 -2
View File
@@ -29,7 +29,7 @@ import {
set,
} from "../interpreter/objects.js"
import { containsOpaqueReference, describeValue, rejectCircularInsertion } from "../interpreter/references.js"
import { preserveConsumerError } from "../interpreter/callback.js"
import { invoke, preserveConsumerError } from "../interpreter/callback.js"
import type { Interpreter } from "../interpreter/interpreter.js"
import { ToolReference } from "../tool-runtime.js"
import { groupBy } from "./collections.js"
@@ -196,7 +196,7 @@ export const objectGlobal = <R>(ctx: Interpreter<R>) => {
true,
],
["toString", 0, (thisValue) => `[object ${classTag(thisValue)}]`],
["toLocaleString", 0, (thisValue) => `[object ${classTag(thisValue)}]`],
["toLocaleString", 0, (thisValue) => invoke(ctx, thisValue, "toString", "Object.prototype.toLocaleString")],
[
"valueOf",
0,
+16 -3
View File
@@ -1,8 +1,8 @@
import { Effect } from "effect"
import { constructor, type Method, methods } from "../interpreter/native.js"
import { constructor, fn, type Method, methods } from "../interpreter/native.js"
import { checkArrayLength, checkStringLength } from "../interpreter/limits.js"
import { invalidData, rangeError, typeError } from "../interpreter/model.js"
import { Arr, PromiseObj, RegExpObj, record } from "../interpreter/objects.js"
import { invalidData, IteratorSymbol, rangeError, typeError } from "../interpreter/model.js"
import { define, hidden, Arr, IteratorObj, PromiseObj, RegExpObj, record } from "../interpreter/objects.js"
import { containsOpaqueReference, typeofValue } from "../interpreter/references.js"
import { applyCollectionCallback, isSupportedCallback } from "../interpreter/callback.js"
import type { Interpreter } from "../interpreter/interpreter.js"
@@ -150,6 +150,8 @@ export const stringGlobal = <R>(ctx: Interpreter<R>) => {
simple("valueOf", 0, (value) => value),
simple("toLowerCase", 0, (value) => value.toLowerCase()),
simple("toUpperCase", 0, (value) => value.toUpperCase()),
simple("toLocaleLowerCase", 0, (value) => value.toLowerCase()),
simple("toLocaleUpperCase", 0, (value) => value.toUpperCase()),
simple("trim", 0, (value) => value.trim()),
simple("trimStart", 0, (value) => value.trimStart()),
simple("trimLeft", 0, (value) => value.trimStart()),
@@ -258,5 +260,16 @@ export const stringGlobal = <R>(ctx: Interpreter<R>) => {
return joined
}),
])
define(
builtins.String,
IteratorSymbol,
fn(
builtins,
"[Symbol.iterator]",
0,
(thisValue) => new IteratorObj(builtins.Iterator, self(thisValue, "[Symbol.iterator]")[Symbol.iterator]()),
),
hidden,
)
return string
}
+23 -5
View File
@@ -1,7 +1,19 @@
import { Effect } from "effect"
import { constructor, fn, type Method, methods, prototypeFrom, receiver, requiresNew } from "../interpreter/native.js"
import { PendingThrow, typeError, uriError } from "../interpreter/model.js"
import { defineAccessor, entries, isWrapper, Arr, Obj, URLObj, URLSearchParamsObj } from "../interpreter/objects.js"
import { IteratorSymbol, PendingThrow, typeError, uriError } from "../interpreter/model.js"
import {
define,
defineAccessor,
entries,
get,
hidden,
isWrapper,
Arr,
IteratorObj,
Obj,
URLObj,
URLSearchParamsObj,
} from "../interpreter/objects.js"
import { isRuntimeReference } from "../interpreter/references.js"
import { applyCollectionCallback, preserveConsumerError } from "../interpreter/callback.js"
import type { Interpreter } from "../interpreter/interpreter.js"
@@ -258,13 +270,18 @@ export const urlSearchParamsGlobal = <R>(ctx: Interpreter<R>) => {
return undefined
},
],
["keys", 0, (thisValue) => wrap(Array.from(self(thisValue, "keys").params.keys()))],
["values", 0, (thisValue) => wrap(Array.from(self(thisValue, "values").params.values()))],
["keys", 0, (thisValue) => new IteratorObj(builtins.Iterator, self(thisValue, "keys").params.keys())],
["values", 0, (thisValue) => new IteratorObj(builtins.Iterator, self(thisValue, "values").params.values())],
[
"entries",
0,
(thisValue) =>
wrap(Array.from(self(thisValue, "entries").params.entries(), ([key, value]) => wrap([key, value]))),
new IteratorObj(
builtins.Iterator,
self(thisValue, "entries")
.params.entries()
.map(([key, value]) => wrap([key, value])),
),
],
["toString", 0, (thisValue) => self(thisValue, "toString").params.toString()],
[
@@ -281,5 +298,6 @@ export const urlSearchParamsGlobal = <R>(ctx: Interpreter<R>) => {
},
],
])
define(proto, IteratorSymbol, get(proto, "entries"), hidden)
return searchParams
}
+4
View File
@@ -192,6 +192,8 @@ export type Prepared<R = never> = {
export type SearchEntry = {
readonly description: ToolDescription
/** The path split into words, so `zones` matches `get_zones` as a word rather than as a substring of `timezones`. */
readonly pathWords: ReadonlyArray<string>
readonly searchText: string
}
@@ -247,6 +249,7 @@ const makeSearchTool = (searchIndex: ReadonlyArray<SearchEntry>): Tool => ({
(total, forms) =>
total +
(forms.some((form) => path === form || path.endsWith(`.${form}`)) ? 20 : 0) +
(forms.some((form) => entry.pathWords.includes(form)) ? 12 : 0) +
(forms.some((form) => path.includes(form)) ? 8 : 0) +
(forms.some((form) => description.includes(form)) ? 4 : 0) +
(forms.some((form) => entry.searchText.includes(form)) ? 2 : 0),
@@ -281,6 +284,7 @@ export const searchSignature = (() => {
const toSearchEntry = <R>(visible: VisibleTool<R>): SearchEntry => ({
description: describeTool(visible),
pathWords: tokenize(visible.path),
searchText: [
visible.path,
visible.tool.description,
@@ -298,21 +298,18 @@ const cases = [
},
{
path: "test/built-ins/Array/prototype/keys/iteration.js",
code: `return ["a", "b", "c"].keys()`,
expected: [0, 1, 2],
code: `const it = ["a", "b", "c"].keys(); return [it.next(), it.next(), it.next(), it.next()]`,
expected: [{ value: 0, done: false }, { value: 1, done: false }, { value: 2, done: false }, { done: true }],
},
{
path: "test/built-ins/Array/prototype/values/iteration.js",
code: `return ["a", "b", "c"].values()`,
expected: ["a", "b", "c"],
code: `const it = ["a", "b", "c"].values(); return [it.next(), it.next(), it.next(), it.next()]`,
expected: [{ value: "a", done: false }, { value: "b", done: false }, { value: "c", done: false }, { done: true }],
},
{
path: "test/built-ins/Array/prototype/entries/iteration.js",
code: `return ["a", "b"].entries()`,
expected: [
[0, "a"],
[1, "b"],
],
code: `const it = ["a", "b"].entries(); return [it.next(), it.next(), it.next()]`,
expected: [{ value: [0, "a"], done: false }, { value: [1, "b"], done: false }, { done: true }],
},
{
path: "test/built-ins/Array/isArray/15.4.3.2-0-3.js",
+26
View File
@@ -1039,6 +1039,32 @@ describe("CodeMode public contract", () => {
}
})
test("a query term that is a whole word of the path outranks a substring of it", async () => {
const simple = (description: string) =>
Tool.make({
description,
input: Schema.Struct({}),
output: Schema.String,
execute: () => Effect.succeed("ok"),
})
const runtime = CodeMode.make({
tools: {
// Declared so that alphabetical order would put the substring match first.
cloudflare: { get_timezones: simple("List timezones"), get_zones: simple("List zones") },
},
})
const ranked = await Effect.runPromise(runtime.execute(`return search({ query: "zones" })`))
expect(ranked.ok).toBe(true)
if (ranked.ok) {
const value = ranked.value as { items: Array<{ path: string }> }
expect(value.items.map((item) => item.path)).toStrictEqual([
"tools.cloudflare.get_zones",
"tools.cloudflare.get_timezones",
])
}
})
test("a plural query term matches singular-only tool text", async () => {
const simple = (description: string) =>
Tool.make({
+35 -7
View File
@@ -22,6 +22,16 @@ describe("source syntax", () => {
})
describe("error identity", () => {
test("Error.isError is true for every Error value and nothing else", async () => {
expect(
await value(`
const caught = (() => { try { null.foo } catch (error) { return error } })()
return [Error.isError(new Error("x")), Error.isError(new RangeError("x")), Error.isError(caught),
Error.isError({ name: "Error", message: "x" }), Error.isError("Error"), Error.isError(null)]
`),
).toEqual([true, true, true, false, false, false])
})
test("awaiting the same rejected promise twice yields the same error object", async () => {
expect(
await value(`
@@ -95,6 +105,24 @@ describe("uncaught program throws", () => {
})
})
describe("source locations", () => {
test("uses the submitted line and 1-based column", async () => {
const failure = await error("const value = 1\nreturn value()")
expect(failure.location).toEqual({ line: 2, column: 8 })
expect(failure.message).toBe("TypeError: value is not a function. (line 2, col 8)")
})
test("names a missing method on a call instead of the previous line", async () => {
const failure = await error(`// Try search with different namespaces
for (const ns of ["github", "tools.github", "tools", ""]) {
const s = await search({query: "star", namespace: ns, limit: 100}).catch(e=>({items:[],error:String(e)}));
return s
}`)
expect(failure.location).toEqual({ line: 3, column: 19 })
expect(failure.message).toBe("TypeError: search(...).catch is not a function. (line 3, col 19)")
})
})
describe("host errors escaping built-ins", () => {
test("become the same-named program error", async () => {
expect(
@@ -107,7 +135,7 @@ describe("host errors escaping built-ins", () => {
test("report the location of the call that raised them", async () => {
const failure = await error(`return [1].map((n) => n.toFixed(200))`)
expect(failure.kind).toBe("ExecutionFailure")
expect(failure.message).toBe("RangeError: toFixed() argument must be between 0 and 100 (line 1, col 19)")
expect(failure.message).toBe("RangeError: toFixed() argument must be between 0 and 100 (line 1, col 23)")
})
test("a built-in that rejects its arguments before doing any work is located at the call", async () => {
@@ -115,13 +143,13 @@ describe("host errors escaping built-ins", () => {
})
test("a rejection born inside a promise the built-in created is located at the creating call", async () => {
expect((await error(`return await Promise.all(1)`)).message).toEndWith("(line 1, col 10)")
expect((await error(`return await Promise.race([])`)).message).toEndWith("(line 1, col 10)")
expect((await error(`return await Promise.all(1)`)).message).toEndWith("(line 1, col 14)")
expect((await error(`return await Promise.race([])`)).message).toEndWith("(line 1, col 14)")
expect((await error(`return await Promise.all({ [Symbol.iterator]: () => ({ next: 1 }) })`)).message).toEndWith(
"(line 1, col 10)",
"(line 1, col 14)",
)
expect((await error(`let p; p = Promise.resolve().then(() => p); return await p`)).message).toEndWith(
"(line 1, col 8)",
"(line 1, col 12)",
)
})
@@ -134,7 +162,7 @@ describe("host errors escaping built-ins", () => {
test("a failure inside a built-in called by another built-in is located at the outer call", async () => {
const failure = await error(`return Array.from({ [Symbol.iterator]: () => ({ next: 1 }) })`)
expect(failure.message).toBe("TypeError: Iterator next must be a function. (line 1, col 4)")
expect(failure.message).toBe("TypeError: Iterator next must be a function. (line 1, col 8)")
})
})
@@ -153,7 +181,7 @@ describe("call depth", () => {
test("uncaught overflow reports the call that overflowed", async () => {
const failure = await error(`const f = (n) => f(n + 1); return f(0)`)
expect(failure.kind).toBe("ExecutionFailure")
expect(failure.message).toBe("RangeError: Maximum call stack size exceeded (line 1, col 14)")
expect(failure.message).toBe("RangeError: Maximum call stack size exceeded (line 1, col 18)")
})
test("the limit is 10000 nested calls", async () => {
+2 -1
View File
@@ -220,8 +220,9 @@ describe("values are converted at the boundary, never shared", () => {
expect(held[1]).toBeInstanceOf(Error)
})
test("functions, promises, and symbols cannot be passed in", async () => {
test("functions, promises, iterators, and symbols cannot be passed in", async () => {
expect((await failure(`keep(() => 1)`)).message).toContain("Argument 1 to keep contains a function")
expect((await failure(`keep([1].keys())`)).message).toContain("Argument 1 to keep contains an iterator")
expect((await failure(`keep(later(1))`)).message).toContain("un-awaited Promise")
expect((await failure(`keep(Symbol.iterator)`)).message).toContain("Argument 1 to keep contains a symbol")
})
@@ -122,10 +122,10 @@ describe("Map.groupBy Test262 parity", () => {
grouped.get(1),
grouped.get("1"),
grouped.has(stringable),
grouped.keys().length,
[...grouped.keys()].length,
parity.get("even"),
parity.get("odd"),
lengths.keys(),
[...lengths.keys()],
lengths.get(5),
lengths.get(4),
]
@@ -162,7 +162,7 @@ describe("Map.groupBy Test262 parity", () => {
await value(`
const grouped = Map.groupBy("🥰💩🙏😈", (char) => char < "🙏" ? "before" : "after")
const empty = Map.groupBy([], () => { throw new Error("not called") })
return [grouped.keys(), grouped.get("before"), grouped.get("after"), empty.size]
return [[...grouped.keys()], grouped.get("before"), grouped.get("after"), empty.size]
`),
).toEqual([["after", "before"], ["💩", "😈"], ["🥰", "🙏"], 0])
})
+25 -2
View File
@@ -502,9 +502,15 @@ describe("CodeMode-specific array behavior", () => {
expect(err.message).toContain("circular")
})
test("keys/values/entries return arrays usable with for...of and spread", async () => {
test("indexOf and lastIndexOf with no argument search for undefined", async () => {
expect(await value(`return [1, undefined, 3].indexOf()`)).toBe(1)
expect(await value(`return [1, undefined, 3].lastIndexOf()`)).toBe(1)
expect(await value(`return [1, 2, 3].indexOf()`)).toBe(-1)
})
test("keys/values/entries return iterators usable with for...of and spread", async () => {
expect(await value(`return [...["x","y","z"].keys()]`)).toEqual([0, 1, 2])
expect(await value(`return ["x","y"].values()`)).toEqual(["x", "y"])
expect(await value(`return [...["x","y"].values()]`)).toEqual(["x", "y"])
expect(
await value(`
const out = []
@@ -940,6 +946,12 @@ describe("coercion parity: unknown static members read as undefined", () => {
expect(await value(`try { JSON.rawJSON("1") } catch (e) { return e.message }`)).toBe(
"JSON.rawJSON is not a function.",
)
expect(await value(`try { search({ query: "star" }).catch(() => 1) } catch (e) { return e.message }`)).toBe(
"search(...).catch is not a function.",
)
expect(
await value(`const foo = () => ({ bar: () => ({}) }); try { foo().bar().baz() } catch (e) { return e.message }`),
).toBe("foo(...).bar(...).baz is not a function.")
})
test("built-ins are objects on a real prototype chain", async () => {
@@ -961,6 +973,17 @@ describe("coercion parity: unknown static members read as undefined", () => {
})
})
describe("async function line breaks", () => {
test("a line break between function and the name is an async function", async () => {
expect(await value(`async function\nfoo() { return 1 }\nreturn await foo()`)).toBe(1)
})
test("a line break between async and function is not an async function", async () => {
const failure = await error(`async\nfunction foo() { return 1 }\nreturn foo()`)
expect(failure.message).toContain("Unknown identifier 'async'")
})
})
describe("functions are objects", () => {
test("name follows NamedEvaluation and length counts required parameters", async () => {
expect(
@@ -198,10 +198,10 @@ describe("Set composition Test262 parity", () => {
keys: () => [-0],
}
return [
1 / new Set([1]).union(setlike).values()[1] === Infinity,
1 / new Set([0, 1, 2]).intersection(setlike).values()[0] === Infinity,
1 / [...new Set([1]).union(setlike)][1] === Infinity,
1 / [...new Set([0, 1, 2]).intersection(setlike)][0] === Infinity,
[...new Set([0, 1]).difference(setlike)],
1 / new Set([1, 2]).symmetricDifference(setlike).values()[2] === Infinity,
1 / [...new Set([1, 2]).symmetricDifference(setlike)][2] === Infinity,
]
`),
).toEqual([true, true, [1], true])
+202 -11
View File
@@ -20,9 +20,9 @@ import { CodeMode, Tool } from "../src/index.js"
// Standard-library value types: Date, RegExp, Map, Set. Programs use them as ordinary JS;
// intra-CodeMode checkpoints (Object.* helpers, spread, coercion inputs) preserve the live
// values, while at the host boundary (final result, tool arguments, JSON.stringify) they
// serialize exactly as JSON.stringify would: Date -> ISO string (invalid -> null),
// URL -> href, and RegExp/Map/Set/URLSearchParams -> {}.
// values. JSON.stringify keeps Date -> ISO string (invalid -> null), URL -> href, and
// RegExp/Map/Set/URLSearchParams -> {}. The host boundary matches that except URLSearchParams,
// which cross as their query string, and Set, which crosses as an array.
const run = (code: string) => Effect.runPromise(CodeMode.execute({ code, tools: {} }))
const value = async (code: string) => {
const result = await run(code)
@@ -655,9 +655,9 @@ describe("Headers", () => {
copied: [headers.get("content-type"), copy.get("content-type")],
pairs: [...new Headers([["b", "2"], ["A", "1"]])],
map: [...new Headers(new Map([["k", "v"]]))],
keys: headers.keys(),
values: headers.values(),
entries: headers.entries(),
keys: [...headers.keys()],
values: [...headers.values()],
entries: [...headers.entries()],
}
`),
).toEqual({
@@ -816,18 +816,28 @@ describe("Map", () => {
expect((await error(`return new Map(["flat"])`)).message).toMatch(/\[key, value\] pairs/)
})
test("keys/values/entries return arrays", async () => {
test("keys/values/entries return live iterators", async () => {
expect(
await value(`
const m = new Map([["a", 1], ["b", 2]])
return { keys: m.keys(), values: m.values(), entries: m.entries() }
const keys = m.keys()
const first = keys.next()
m.set("c", 3)
return { first, rest: [...keys], values: [...m.values()], entries: [...m.entries()], same: [...m[Symbol.iterator]()] }
`),
).toEqual({
keys: ["a", "b"],
values: [1, 2],
first: { value: "a", done: false },
rest: ["b", "c"],
values: [1, 2, 3],
entries: [
["a", 1],
["b", 2],
["c", 3],
],
same: [
["a", 1],
["b", 2],
["c", 3],
],
})
})
@@ -888,6 +898,42 @@ describe("Map", () => {
).toEqual({ a: 3, b: 1, c: 1 })
})
test("getOrInsert and getOrInsertComputed insert only when the key is missing", async () => {
expect(
await value(`
const groups = new Map()
groups.getOrInsert("a", []).push(1)
groups.getOrInsert("a", []).push(2)
let calls = 0
const computed = (key) => { calls++; return key + "!" }
const first = groups.getOrInsertComputed("b", computed)
const second = groups.getOrInsertComputed("b", computed)
const zero = groups.getOrInsertComputed(-0, (key) => 1 / key === Infinity)
return [[...groups], first, second, calls, zero]
`),
).toEqual([
[
["a", [1, 2]],
["b", "b!"],
[0, true],
],
"b!",
"b!",
1,
true,
])
expect(
await value(`
const m = new Map()
const outer = m.getOrInsertComputed("k", () => { m.set("k", "inner"); return "outer" })
let thrown
try { m.getOrInsertComputed("j", () => { throw new Error("boom") }) } catch (error) { thrown = error.message }
return [outer, m.get("k"), thrown, m.has("j")]
`),
).toEqual(["outer", "outer", "boom", false])
expect((await error(`new Map().getOrInsertComputed("k", 5)`)).message).toContain("expects a function callback")
})
test("maps serialize to {} at the boundary, like JSON", async () => {
expect(await value(`return new Map([["a", 1]])`)).toEqual({})
expect(await value(`return JSON.stringify(new Map([["a", 1]]))`)).toBe("{}")
@@ -1051,7 +1097,7 @@ describe("Uint8Array", () => {
console.log(b, new Uint8Array())
return [String(b), b + "", +new Uint8Array([5]), Number.isNaN(Number(b)), b == "1,2", JSON.stringify(b), b.toLocaleString(), typeof b, b instanceof Uint8Array]
`),
).toEqual(["1,2", "1,2", 5, true, true, '{"0":1,"1":2}', "[object Uint8Array]", "object", true])
).toEqual(["1,2", "1,2", 5, true, true, '{"0":1,"1":2}', "1,2", "object", true])
expect((await run(`console.log(new Uint8Array([1, 2]), new Uint8Array())`)).logs).toEqual([
"Uint8Array(2) [1,2] Uint8Array(0) []",
])
@@ -1119,6 +1165,151 @@ describe("TextEncoder and TextDecoder", () => {
})
})
describe("built-in iterators", () => {
test("keys/values/entries and [Symbol.iterator] step with next() and stay live", async () => {
expect(
await value(`
const items = ["a"]
const it = items.entries()
items.push("b")
const steps = [it.next(), it.next(), it.next()]
items.push("c")
return { steps, after: it.next(), same: items[Symbol.iterator] === items.values }
`),
).toEqual({
steps: [{ value: [0, "a"], done: false }, { value: [1, "b"], done: false }, { done: true }],
after: { done: true },
same: true,
})
expect(
await value(`
const s = new Set([1, 2])
const u = new URLSearchParams("a=1&b=2")
const h = new Headers({ b: "2", a: "1" })
const bytes = new Uint8Array([7, 8])
return [
[...s.entries()], [...s[Symbol.iterator]()], s[Symbol.iterator] === s.values,
[...u.keys()], [...u[Symbol.iterator]()], u[Symbol.iterator] === u.entries,
[...h.values()], [...h[Symbol.iterator]()], h[Symbol.iterator] === h.entries,
[...bytes.entries()], [...bytes[Symbol.iterator]()], bytes[Symbol.iterator] === bytes.values,
[..."ab"[Symbol.iterator]()],
]
`),
).toEqual([
[
[1, 1],
[2, 2],
],
[1, 2],
true,
["a", "b"],
[
["a", "1"],
["b", "2"],
],
true,
["1", "2"],
[
["a", "1"],
["b", "2"],
],
true,
[
[0, 7],
[1, 8],
],
[7, 8],
true,
["a", "b"],
])
})
test("iterators are consumed once by every iteration site", async () => {
expect(
await value(`
const it = [1, 2, 3, 4].values()
const picked = []
for (const item of it) { picked.push(item); if (item === 2) break }
const [third] = it
return { picked, third, rest: [...it], spent: Array.from(it), again: it[Symbol.iterator]() === it }
`),
).toEqual({ picked: [1, 2], third: 3, rest: [4], spent: [], again: true })
expect(
await value(`
const m = new Map([["a", 1], ["b", 2]])
return [
Object.fromEntries(m.entries()), Array.from(m.keys(), (k) => k + "!"), new Set(m.values()).size,
await Promise.all([Promise.resolve(1), 2].values()),
]
`),
).toEqual([{ a: 1, b: 2 }, ["a!", "b!"], 2, [1, 2]])
expect(await value(`let s = 0; for await (const v of [Promise.resolve(1), 2].values()) s += v; return s`)).toBe(3)
expect(
await value(`return new Set([1, 2]).union({ size: 1, has: () => false, keys: () => new Set([3]).keys() })`),
).toEqual([1, 2, 3])
})
test("iterators are opaque references", async () => {
expect(await value(`return [1].keys()`)).toEqual({})
expect(await value(`return JSON.stringify({ it: [1].keys() })`)).toBe('{"it":{}}')
expect(await value(`return [typeof [1].keys(), Array.isArray([1].keys()), Object.keys([1].keys())]`)).toEqual([
"object",
false,
[],
])
const logged = await run(`console.log([1].keys()); return null`)
expect(logged.logs?.[0]).toBe("[opaque reference]")
expect((await error(`return [1].keys() + ""`)).message).toContain("Binary operators require data values")
expect((await error(`return [1].keys().next.call({})`)).message).toContain("is not a function")
expect((await error(`const it = [1].keys(); const next = it.next; return next()`)).message).toContain(
"Iterator.prototype.next called on incompatible receiver undefined",
)
})
})
describe("toLocaleString", () => {
test("numbers and dates format as en-US in UTC; everything else falls back to toString", async () => {
expect(
await value(`
return [
(1234567.891).toLocaleString(), new Date(0).toLocaleString(), new Date(0).toLocaleDateString(),
new Date(0).toLocaleTimeString(), "a".toLocaleString(), true.toLocaleString(), ({}).toLocaleString(),
({ toString: () => "custom" }).toLocaleString(), new Uint8Array([1, 2]).toLocaleString(),
]
`),
).toEqual([
"1,234,567.891",
"1/1/1970, 12:00:00 AM",
"1/1/1970",
"12:00:00 AM",
"a",
"true",
"[object Object]",
"custom",
"1,2",
])
})
test("arrays join each element's toLocaleString, skipping holes and nullish elements", async () => {
expect(
await value(`
let calls = 0
const item = { toLocaleString() { calls++; return "o" } }
return [[1234.5, "x", null, undefined, item, new Date(0)].toLocaleString(), [, item, , item].toLocaleString(), calls]
`),
).toEqual(["1,234.5,x,,,o,1/1/1970, 12:00:00 AM", ",o,,o", 3])
expect((await error(`const f = ({}).toLocaleString; f()`)).message).toContain(
"Object.prototype.toLocaleString called on null or undefined",
)
})
test("toLocaleLowerCase and toLocaleUpperCase ignore the locale argument", async () => {
expect(
await value(`return ["ABC".toLocaleLowerCase("tr"), "abc".toLocaleUpperCase(), "İ".toLocaleLowerCase()]`),
).toEqual(["abc", "ABC", "i̇"])
})
})
describe("stdlib integration", () => {
test("constructor follows own keys, shadowing, writes, and new", async () => {
expect(
@@ -13,8 +13,6 @@ built-ins/Array/prototype/copyWithin/coerced-values-target.js # Array.copyWithi
built-ins/Array/prototype/copyWithin/return-abrupt-from-end.js # Expected a Test262Error but got a TypeError
built-ins/Array/prototype/copyWithin/return-abrupt-from-start.js # Expected a Test262Error but got a TypeError
built-ins/Array/prototype/copyWithin/return-abrupt-from-target.js # Expected a Test262Error but got a TypeError
built-ins/Array/prototype/entries/iteration-mutable.js # .next is not a function.
built-ins/Array/prototype/entries/iteration.js # .next is not a function.
built-ins/Array/prototype/every/15.4.4.16-7-6.js # res Expected SameValue(«true», «false») to be true
built-ins/Array/prototype/every/15.4.4.16-7-c-i-8.js # [, , , ].every(callbackfn) Expected SameValue(«true», «false») to be true
built-ins/Array/prototype/every/15.4.4.16-8-10.js # … cannot be constructed: user-defined constructors and classes are not supported. Call it as a funct
@@ -86,8 +84,6 @@ built-ins/Array/prototype/join/S15.4.4.5_A3.1_T2.js # Array.join expects zero a
built-ins/Array/prototype/join/S15.4.4.5_A3.2_T2.js # x.join() must return "*" Expected SameValue(«"[object Object]"», «"*"») to be true
built-ins/Array/prototype/join/S15.4.4.5_A4_T3.js # Array.prototype.join called on incompatible receiver a data object.
built-ins/Array/prototype/join/S15.4.4.5_A5_T1.js # #1: Array.prototype[1] = 1; x = [0]; x.length = 2; x.join() === "0,1". Actual: 0,
built-ins/Array/prototype/keys/iteration-mutable.js # .next is not a function.
built-ins/Array/prototype/keys/iteration.js # .next is not a function.
built-ins/Array/prototype/lastIndexOf/15.4.4.15-5-1.js # Array.lastIndexOf expects start index to be a number.
built-ins/Array/prototype/lastIndexOf/15.4.4.15-5-15.js # Array.lastIndexOf expects start index to be a number.
built-ins/Array/prototype/lastIndexOf/15.4.4.15-5-16.js # Array.lastIndexOf expects start index to be a number.
@@ -229,7 +225,6 @@ built-ins/Array/prototype/splice/S15.4.4.12_A4_T1.js # Array.prototype.splice c
built-ins/Array/prototype/splice/S15.4.4.12_A4_T2.js # Array.prototype.splice called on incompatible receiver a data object.
built-ins/Array/prototype/splice/S15.4.4.12_A4_T3.js # Array.prototype.splice called on incompatible receiver a data object.
built-ins/Array/prototype/splice/create-ctor-non-object.js # null value Expected a TypeError to be thrown but no exception was thrown at all
built-ins/Array/prototype/toLocaleString/S15.4.4.3_A1_T1.js # #1: var n = 0; var obj = {toLocaleString: function() {n++}}; var arr = [undefined, obj, null, obj, o
built-ins/Array/prototype/toLocaleString/S15.4.4.3_A3_T1.js # #1: var n = 0; var obj = {toLocaleString: function() {n++}}; Array.prototype[1] = obj; x = [obj]; x.
built-ins/Array/prototype/toReversed/holes-not-preserved.js # Actual [4, undefined, 2, undefined, 0] and expected [4, 3, 2, undefined, 0] should have the same con
built-ins/Array/prototype/toSorted/holes-not-preserved.js # Actual [1, 3, 4, undefined, undefined] and expected [1, 2, 3, 4, undefined] should have the same con
@@ -241,9 +236,6 @@ built-ins/Array/prototype/unshift/S15.4.4.13_A2_T3.js # Array.prototype.unshift
built-ins/Array/prototype/unshift/S15.4.4.13_A3_T2.js # Array.prototype.unshift called on incompatible receiver a data object.
built-ins/Array/prototype/unshift/S15.4.4.13_A4_T1.js # Array.prototype.unshift called on incompatible receiver a data object.
built-ins/Array/prototype/unshift/S15.4.4.13_A4_T2.js # #3: Array.prototype[0] = 1; x = []; x.length = 1; x.unshift(0); x[1] === 1. Actual: undefined
built-ins/Array/prototype/values/iteration-mutable.js # .next is not a function.
built-ins/Array/prototype/values/iteration.js # .next is not a function.
language/statements/async-function/rest-params-trailing-comma-early-error.js # expected SyntaxError but the program ran
language/statements/async-generator/dflt-params-abrupt.js # Expected a Test262Error to be thrown but no exception was thrown at all
language/statements/async-generator/dflt-params-ref-later.js # Expected a ReferenceError to be thrown but no exception was thrown at all
language/statements/async-generator/dflt-params-ref-self.js # Expected a ReferenceError to be thrown but no exception was thrown at all
@@ -291,16 +283,13 @@ language/statements/async-generator/dstr/obj-ptrn-prop-id-init-throws.js # Expe
language/statements/async-generator/dstr/obj-ptrn-prop-id-init-unresolvable.js # Expected a ReferenceError to be thrown but no exception was thrown at all
language/statements/async-generator/dstr/obj-ptrn-prop-obj-value-null.js # Expected a TypeError to be thrown but no exception was thrown at all
language/statements/async-generator/dstr/obj-ptrn-prop-obj-value-undef.js # Expected a TypeError to be thrown but no exception was thrown at all
language/statements/async-generator/rest-params-trailing-comma-early-error.js # expected SyntaxError but the program ran
language/statements/async-generator/return-undefined-implicit-and-explicit.js # Actual ["tick 1", "tick 2", "g1 ret", "g2 ret", "g3 ret", "g4 ret"] and expected ["tick 1", "g1 ret"
language/statements/const/dstr/ary-init-iter-get-err-array-prototype.js # Expected a TypeError to be thrown but no exception was thrown at all
language/statements/for-await-of/async-func-decl-dstr-array-elem-init-in.js # Failed to parse TypeScript: '…' expected.
language/statements/for-await-of/async-func-decl-dstr-obj-empty-bool.js # TypeError: Object destructuring requires a data object or array value, received a boolean.
language/statements/for-await-of/async-func-decl-dstr-obj-empty-num.js # TypeError: Object destructuring requires a data object or array value, received a number.
language/statements/for-await-of/async-func-decl-dstr-obj-empty-string.js # TypeError: Object destructuring requires a data object or array value, received a string.
language/statements/for-await-of/async-func-decl-dstr-obj-rest-number.js # TypeError: Object destructuring requires a data object or array value, received a number.
language/statements/for-await-of/async-func-decl-dstr-obj-rest-str-val.js # TypeError: Object destructuring requires a data object or array value, received a string.
language/statements/for-await-of/async-gen-decl-dstr-array-elem-init-in.js # Failed to parse TypeScript: '…' expected.
language/statements/for-await-of/async-gen-decl-dstr-array-elem-iter-rtrn-close-null.js # "Promise incorrectly fulfilled."
language/statements/for-await-of/async-gen-decl-dstr-obj-empty-bool.js # TypeError: Object destructuring requires a data object or array value, received a boolean.
language/statements/for-await-of/async-gen-decl-dstr-obj-empty-num.js # TypeError: Object destructuring requires a data object or array value, received a number.
@@ -308,8 +297,6 @@ language/statements/for-await-of/async-gen-decl-dstr-obj-empty-string.js # Type
language/statements/for-await-of/async-gen-decl-dstr-obj-rest-number.js # TypeError: Object destructuring requires a data object or array value, received a number.
language/statements/for-await-of/async-gen-decl-dstr-obj-rest-str-val.js # TypeError: Object destructuring requires a data object or array value, received a string.
language/statements/for-in/head-lhs-member.js # Unsupported for...in binding.
language/statements/for-of/Array.prototype.Symbol.iterator.js # The called value is not a function.
language/statements/for-of/dstr/array-elem-init-in.js # Failed to parse TypeScript: '…' expected.
language/statements/for-of/dstr/array-elem-iter-rtrn-close-err.js # Iterator next must be a function.
language/statements/for-of/dstr/array-elem-iter-rtrn-close-null.js # Expected a TypeError to be thrown but no exception was thrown at all
language/statements/for-of/dstr/array-elem-iter-thrw-close-err.js # Expected SameValue(«1», «0») to be true
@@ -355,7 +342,6 @@ language/statements/function/S14_A5_T1.js # Identifier '…' has already been d
language/statements/function/S14_A5_T2.js # Identifier '…' has already been declared.
language/statements/function/dstr/ary-init-iter-get-err-array-prototype.js # Expected a TypeError to be thrown but no exception was thrown at all
language/statements/function/dstr/dflt-ary-init-iter-get-err-array-prototype.js # Expected a TypeError to be thrown but no exception was thrown at all
language/statements/function/rest-params-trailing-comma-early-error.js # expected SyntaxError but the program ran
language/statements/generators/dflt-params-abrupt.js # Expected a Test262Error to be thrown but no exception was thrown at all
language/statements/generators/dflt-params-ref-later.js # Expected a ReferenceError to be thrown but no exception was thrown at all
language/statements/generators/dflt-params-ref-self.js # Expected a ReferenceError to be thrown but no exception was thrown at all
@@ -406,8 +392,6 @@ language/statements/generators/dstr/obj-ptrn-prop-obj-value-undef.js # Expected
language/statements/generators/has-instance.js # The right-hand side of '…' has no '…' object.
language/statements/generators/prototype-typeof.js # Expected SameValue(«"undefined"», «"object"») to be true
language/statements/generators/prototype-uniqueness.js # Expected true but got false
language/statements/generators/rest-params-trailing-comma-early-error.js # expected SyntaxError but the program ran
language/statements/labeled/value-await-non-module-escaped.js # Failed to parse TypeScript: Keywords cannot contain escape characters.
language/statements/labeled/value-await-non-module.js # Failed to parse TypeScript: Expression expected.
language/statements/let/dstr/ary-init-iter-get-err-array-prototype.js # Expected a TypeError to be thrown but no exception was thrown at all
language/statements/return/S12.9_A1_T1.js # expected SyntaxError but the program ran
+42 -11
View File
@@ -8,8 +8,8 @@
* Copyright © web-platform-tests contributors. Governed by the 3-Clause BSD license in LICENSE.wpt.
*
* `assert_throws_dom("InvalidCharacterError", …)` becomes a check for a TypeError: CodeMode has no DOMException.
* Headers cases that need `Symbol.iterator`, iterator objects from `keys()`/`values()`/`entries()` (CodeMode returns
* arrays), or a custom iterator on a Headers instance are left out.
* `checkIteratorProperties` (prototype chain and property descriptors) and the custom iterator on a Headers
* instance are left out.
*/
import { describe, expect, test } from "bun:test"
import { Effect } from "effect"
@@ -253,33 +253,64 @@ describe("Headers WPT parity (fetch/api/headers)", () => {
}).sort()
test(function() {
var headers = new Headers(headerEntriesDict)
assert_array_equals(headers.keys(), sortedHeaderKeys)
var actual = headers.keys()
sortedHeaderKeys.forEach(function(key) {
const entry = actual.next()
assert_false(entry.done)
assert_equals(entry.value, key)
})
assert_true(actual.next().done)
assert_true(actual.next().done)
for (const key of headers.keys()) assert_true(sortedHeaderKeys.indexOf(key) != -1)
}, "Check keys method")
test(function() {
var headers = new Headers(headerEntriesDict)
assert_array_equals(headers.values(), sortedHeaderKeys.map((key) => sortedHeaderDict[key]))
var actual = headers.values()
sortedHeaderKeys.forEach(function(key) {
const entry = actual.next()
assert_false(entry.done)
assert_equals(entry.value, sortedHeaderDict[key])
})
assert_true(actual.next().done)
assert_true(actual.next().done)
for (const value of headers.values()) assert_true(headerValues.indexOf(value) != -1)
}, "Check values method")
test(function() {
var headers = new Headers(headerEntriesDict)
assert_array_equals(headers.entries(), sortedHeaderKeys.map((key) => [key, sortedHeaderDict[key]]))
var actual = headers.entries()
sortedHeaderKeys.forEach(function(key) {
const entry = actual.next()
assert_false(entry.done)
assert_equals(entry.value[0], key)
assert_equals(entry.value[1], sortedHeaderDict[key])
})
assert_true(actual.next().done)
assert_true(actual.next().done)
for (const entry of headers.entries()) assert_equals(entry[1], sortedHeaderDict[entry[0]])
}, "Check entries method")
test(function() {
var headers = new Headers(headerEntriesDict)
assert_array_equals([...headers], sortedHeaderKeys.map((key) => [key, sortedHeaderDict[key]]))
var actual = headers[Symbol.iterator]()
sortedHeaderKeys.forEach(function(key) {
const entry = actual.next()
assert_false(entry.done)
assert_equals(entry.value[0], key)
assert_equals(entry.value[1], sortedHeaderDict[key])
})
assert_true(actual.next().done)
assert_true(actual.next().done)
}, "Check Symbol.iterator method")
test(function() {
var headers = new Headers(headerEntriesDict)
var index = 0
var reference = sortedHeaderKeys[Symbol.iterator]()
headers.forEach(function(value, key, container) {
assert_equals(headers, container)
assert_equals(key, sortedHeaderKeys[index])
assert_equals(value, sortedHeaderDict[sortedHeaderKeys[index]])
index++
const entry = reference.next()
assert_false(entry.done)
assert_equals(key, entry.value)
assert_equals(value, sortedHeaderDict[entry.value])
})
assert_equals(index, sortedHeaderKeys.length)
assert_true(reference.next().done)
}, "Check forEach method")
test(() => {
const headers = new Headers({"foo": "2", "baz": "1", "BAR": "0"})
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/console-app",
"version": "2.0.10",
"version": "2.0.12",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/console-core",
"version": "2.0.10",
"version": "2.0.12",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/console-function",
"version": "2.0.10",
"version": "2.0.12",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/console-mail",
"version": "2.0.10",
"version": "2.0.12",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/console-support",
"version": "2.0.10",
"version": "2.0.12",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "2.0.10",
"version": "2.0.12",
"name": "@opencode/core",
"type": "module",
"license": "MIT",
File diff suppressed because one or more lines are too long
+10 -7
View File
@@ -57,16 +57,19 @@ export const ModelsDevPlugin = define({
})
}
})
const apply = (data: readonly ModelsDev.Snapshot[]) => {
loaded.data = snapshots(data)
return ctx.integration.reload().pipe(Effect.andThen(ctx.provider.reload()))
}
yield* bus.subscribe(ModelsDev.Event.Refreshed).pipe(
Stream.runForEach(() =>
modelsDev.get().pipe(
Effect.tap((data) => Effect.sync(() => (loaded.data = snapshots(data)))),
Effect.andThen(ctx.integration.reload()),
Effect.andThen(ctx.provider.reload()),
),
),
Stream.runForEach(() => modelsDev.get().pipe(Effect.flatMap(apply))),
Effect.forkScoped({ startImmediately: true }),
)
// A refresh that landed between the initial read and the subscription above published
// Refreshed to nobody here. On a cold cache that read served the bundled snapshot, so
// re-read now instead of waiting for the next TTL refresh.
const latest = yield* modelsDev.get()
if (snapshots(latest) !== loaded.data) yield* apply(latest)
}),
})
+28 -21
View File
@@ -20,9 +20,10 @@ import { SessionSchema } from "./schema.js"
import { webSocketConstructor } from "../effect/app-node-platform.js"
const ROTATE_AFTER_MS = 55 * 60 * 1000
const INBOUND_CAPACITY = 128
const CONNECT_TIMEOUT = "10 seconds"
const CONNECT_TIMEOUT = "15 seconds"
const IDLE_TIMEOUT = "5 minutes"
/** Consecutive exchanges lost to the socket before the Session stays on HTTP. */
const MAX_STREAM_FAILURES = 5
const events = Metric.counter("opencode_session_websocket_events_total", {
description: "Session WebSocket lifecycle events",
incremental: true,
@@ -50,6 +51,7 @@ interface State {
readonly lock: Semaphore.Semaphore
closed: boolean
httpFallback: boolean
streamFailures: number
channel?: Channel
}
@@ -126,7 +128,7 @@ export const makeLayer = (connector: WebSocketConnector) =>
const state = (sessionID: SessionSchema.ID) => {
const current = states.get(sessionID)
if (current) return current
const created = { lock: Semaphore.makeUnsafe(1), closed: false, httpFallback: false }
const created = { lock: Semaphore.makeUnsafe(1), closed: false, httpFallback: false, streamFailures: 0 }
states.set(sessionID, created)
return created
}
@@ -168,15 +170,27 @@ export const makeLayer = (connector: WebSocketConnector) =>
code: error.reason._tag === "Transport" ? error.reason.code : error.reason._tag,
active: channel.active !== undefined,
})
if (channel.active) Queue.failCauseUnsafe(channel.active.queue, Cause.fail(error))
yield* metric(
error.reason._tag === "Transport" && error.reason.code === "queue-overflow"
? "queue_overflow"
: "protocol_failure",
)
if (channel.active) {
Queue.failCauseUnsafe(channel.active.queue, Cause.fail(error))
yield* streamFailure(owner)
}
yield* metric("protocol_failure")
yield* channel.connection.close
})
// A socket that keeps dying mid-exchange costs a retry every step; after enough consecutive
// losses the Session stays on HTTP.
const streamFailure = Effect.fn("SessionModelTransport.streamFailure")(function* (owner: State) {
owner.streamFailures++
if (owner.streamFailures < MAX_STREAM_FAILURES) return
owner.httpFallback = true
yield* Effect.logWarning("session websocket failed repeatedly; using http", {
sessionTransport: "websocket",
failures: owner.streamFailures,
})
yield* metric("fallback", { reason: "stream_failures" })
})
const open = Effect.fn("SessionModelTransport.open")(function* (
owner: State,
exchange: WebSocketChannelExchange,
@@ -235,14 +249,7 @@ export const makeLayer = (connector: WebSocketConnector) =>
code: "message",
phase: "receive",
})
if (Queue.offerUnsafe(active.queue, message)) return undefined
return yield* transportError("Session WebSocket inbound queue overflow", {
url: exchange.connect.url,
operation: "read",
code: "queue-overflow",
phase: "receive",
delivery: "accepted",
})
Queue.offerUnsafe(active.queue, message)
}),
),
Effect.catch((error) =>
@@ -255,9 +262,7 @@ export const makeLayer = (connector: WebSocketConnector) =>
phase:
error.reason._tag === "Transport" && error.reason.phase === "close" ? "close" : "receive",
delivery:
channel.active?.delivery === "provider-observed" ||
channel.active?.delivery === "terminal" ||
(error.reason._tag === "Transport" && error.reason.code === "queue-overflow")
channel.active?.delivery === "provider-observed" || channel.active?.delivery === "terminal"
? "accepted"
: error.reason._tag === "Transport" && error.reason.code === "1009"
? "rejected"
@@ -370,7 +375,7 @@ export const makeLayer = (connector: WebSocketConnector) =>
mode: create.mode,
})
const active: Active = {
queue: yield* Queue.bounded<string, AIError>(INBOUND_CAPACITY),
queue: yield* Queue.unbounded<string, AIError>(),
delivery: "send-attempted",
}
channel.active = active
@@ -395,6 +400,7 @@ export const makeLayer = (connector: WebSocketConnector) =>
return fallback(exchange)
}
yield* metric("ambiguous_delivery")
yield* streamFailure(owner)
return yield* annotate(failure, { phase: "send", delivery: "ambiguous" })
}
yield* metric("send")
@@ -435,6 +441,7 @@ export const makeLayer = (connector: WebSocketConnector) =>
const pending = yield* Queue.size(active.queue)
yield* Queue.shutdown(active.queue)
if (terminal && pending === 0) {
owner.streamFailures = 0
yield* metric("terminal", { type: terminal.type })
if (terminal.type === "rejected") yield* metric("rejection", { recovery: terminal.recovery })
// The Codex backend stops serving a connection after any error frame: the next request is
+28 -13
View File
@@ -27,6 +27,7 @@ export function resolve(model: Model.Info, supports: readonly Support[] = [{ typ
const EFFORTS = ["low", "medium", "high"]
const ENCRYPTED_REASONING = ["reasoning.encrypted_content"]
const ADAPTIVE_THINKING = { type: "adaptive", display: "summarized" }
const ANTHROPIC_OUTPUT_TOKEN_MAX = 32_000
const variant = (id: string, overlay: Overlay): Variants[number] => ({ id: Model.VariantID.make(id), ...overlay })
@@ -39,8 +40,9 @@ function budgets(
model: Model.Info,
support: Extract<Support, { type: "budget_tokens" }>,
spell: (tokens: number) => Overlay,
ceiling = model.limit.output,
): Variants {
const maximum = Math.min(support.max ?? model.limit.output - 1, model.limit.output - 1)
const maximum = Math.min(support.max ?? ceiling - 1, model.limit.output - 1, ceiling - 1)
if (maximum <= 0) return []
const high = Math.min(Math.max(support.min ?? 0, Math.floor((maximum + 1) / 2)), maximum)
return [variant("high", spell(high)), variant("max", spell(maximum))]
@@ -273,9 +275,12 @@ const anthropicMessages: Protocol = (model, support) => {
return toggle({ settings: { thinking: { type: "disabled" } } }, thinking)
}
case "budget_tokens":
return budgets(model, support, (tokens) => ({
settings: { thinking: { type: "enabled", budgetTokens: tokens } },
}))
return budgets(
model,
support,
(tokens) => ({ settings: { thinking: { type: "enabled", budgetTokens: tokens } } }),
ANTHROPIC_OUTPUT_TOKEN_MAX,
)
}
}
@@ -381,10 +386,14 @@ const bedrockConverse: Protocol = (model, support) => {
? toggle(fields({ thinking: { type: "disabled" } }), fields({ thinking: ADAPTIVE_THINKING }))
: toggle(fields({ reasoningConfig: { type: "disabled" } }), fields({ reasoningConfig: { type: "enabled" } }))
case "budget_tokens":
return budgets(model, support, (tokens) =>
claude
? fields({ thinking: { type: "enabled", budget_tokens: tokens } })
: fields({ reasoningConfig: { type: "enabled", budgetTokens: tokens } }),
return budgets(
model,
support,
(tokens) =>
claude
? fields({ thinking: { type: "enabled", budget_tokens: tokens } })
: fields({ reasoningConfig: { type: "enabled", budgetTokens: tokens } }),
claude ? ANTHROPIC_OUTPUT_TOKEN_MAX : model.limit.output,
)
}
}
@@ -436,9 +445,12 @@ const bedrockAISDK: Protocol = (model, support) => {
{ settings: { additionalModelRequestFields: { reasoningConfig: { type: "enabled" } } } },
)
case "budget_tokens":
return budgets(model, support, (tokens) => ({
settings: { reasoningConfig: { type: "enabled", budgetTokens: tokens } },
}))
return budgets(
model,
support,
(tokens) => ({ settings: { reasoningConfig: { type: "enabled", budgetTokens: tokens } } }),
claude ? ANTHROPIC_OUTPUT_TOKEN_MAX : model.limit.output,
)
}
}
@@ -489,8 +501,11 @@ const sapAICore: Protocol = (model, support) => {
return []
case "budget_tokens":
if (id.includes("anthropic"))
return budgets(model, support, (tokens) =>
sap({ additionalModelRequestFields: { thinking: { type: "enabled", budget_tokens: tokens } } }),
return budgets(
model,
support,
(tokens) => sap({ additionalModelRequestFields: { thinking: { type: "enabled", budget_tokens: tokens } } }),
ANTHROPIC_OUTPUT_TOKEN_MAX,
)
if (id.includes("gemini"))
return budgets(model, support, (tokens) =>
+33 -2
View File
@@ -290,6 +290,37 @@ describe("ModelsDevPlugin", () => {
}),
)
isolated.effect("adopts a refresh that completes between its initial read and its subscription", () =>
Effect.gen(function* () {
const bundled = richSnapshot("Acme Bundled")
const fresh = richSnapshot("Acme Fresh")
const current = { snapshot: bundled.snapshot }
const location = yield* owner
// Cold cache: the first read serves the bundled snapshot, and the boot-time
// ModelsDev.refresh() lands right after it, before the plugin subscribes.
const source = ModelsDev.Service.of({
get: () =>
Effect.gen(function* () {
const data = current.snapshot
if (data !== bundled.snapshot) return data
current.snapshot = fresh.snapshot
yield* location.bus.publish(ModelsDev.Event.Refreshed, {})
return data
}),
refresh: () => Effect.void,
})
yield* ModelsDevPlugin.effect(location.host).pipe(
Effect.provideService(ModelsDev.Service, source),
Effect.provideContext(location.context),
)
yield* TestClock.adjust("500 millis")
yield* TestClock.adjust("500 millis")
yield* TestClock.adjust("500 millis")
expect(required(yield* location.providers.get(bundled.providerID)).name).toBe("Acme Fresh")
}),
)
real.effect("keeps the retained definition unchanged across model replay", () =>
Effect.gen(function* () {
const providers = yield* Provider.Service
@@ -987,11 +1018,11 @@ describe("ModelsDevPlugin", () => {
const budgetModel = yield* modelState.get(Provider.ID.anthropic, Model.ID.make("claude-budget"))
expect(budgetModel?.variants).toContainEqual({
id: Model.VariantID.make("high"),
settings: { thinking: { type: "enabled", budgetTokens: 32000 } },
settings: { thinking: { type: "enabled", budgetTokens: 16000 } },
})
expect(budgetModel?.variants).toContainEqual({
id: Model.VariantID.make("max"),
settings: { thinking: { type: "enabled", budgetTokens: 63999 } },
settings: { thinking: { type: "enabled", budgetTokens: 31999 } },
})
const anthropicEffortModel = yield* modelState.get(Provider.ID.anthropic, Model.ID.make("claude-opus-4.7"))
@@ -0,0 +1,55 @@
import { expect } from "bun:test"
import { Agent } from "@opencode/core/agent"
import { Plugin } from "@opencode/core/plugin"
import { PluginHost } from "@opencode/core/plugin/host"
import { PluginPromise } from "@opencode/core/plugin/promise"
import { Tool } from "@opencode/core/tool"
import { Session } from "@opencode/schema/session"
import { SessionMessage } from "@opencode/schema/session-message"
import { Cause, Deferred, Effect, Exit, Fiber } from "effect"
import { testEffect } from "../lib/effect"
import { PluginTestLayer } from "./fixture"
const it = testEffect(PluginTestLayer)
it.live("Promise tool executors receive interruption through their AbortSignal", () =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
const tools = yield* Tool.Service
const started = yield* Deferred.make<AbortSignal>()
yield* PluginPromise.fromPromise({
id: "cancel-tool",
async setup(context) {
await context.tool.transform((editor) =>
editor.add({
name: "wait",
description: "Wait until cancelled",
input: { type: "object", properties: {}, additionalProperties: false },
options: { codemode: false },
execute: (_input, context) =>
new Promise<never>((_resolve, reject) => {
context.signal.addEventListener("abort", () => reject(new Error("cancelled")), { once: true })
Effect.runSync(Deferred.succeed(started, context.signal))
}),
}),
)
},
}).effect(yield* PluginHost.make(plugins))
const snapshot = yield* tools.snapshot()
const fiber = yield* snapshot
.execute({
sessionID: Session.ID.make("ses_promise_tool_cancel"),
agent: Agent.ID.make("build"),
messageID: SessionMessage.ID.make("msg_promise_tool_cancel"),
call: { type: "tool-call", id: "call_promise_tool_cancel", name: "wait", input: {} },
})
.pipe(Effect.forkScoped)
const signal = yield* Deferred.await(started)
expect(signal.aborted).toBe(false)
yield* Fiber.interrupt(fiber)
const exit = yield* Fiber.await(fiber)
expect(signal.aborted).toBe(true)
expect(Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause)).toBe(true)
}),
)
@@ -593,7 +593,7 @@ describe("SessionModelTransport", () => {
Effect.forkChild({ startImmediately: true }),
)
yield* Effect.yieldNow
yield* TestClock.adjust("10 seconds")
yield* TestClock.adjust("15 seconds")
expect(yield* Fiber.join(running)).toEqual(["fallback:slow"])
}),
)
@@ -731,6 +731,40 @@ describe("SessionModelTransport", () => {
)
})
test("keeps the Session on HTTP after repeated mid-stream socket losses", async () => {
let opens = 0
const connector: WebSocketConnector = {
open: () =>
Effect.gen(function* () {
opens++
const messages = yield* Queue.unbounded<string | Uint8Array, AIError>()
return {
sendText: () =>
Effect.sync(() => {
Queue.failCauseUnsafe(messages, Cause.fail(error("socket dropped")))
}),
messages: Stream.fromQueue(messages),
close: Queue.shutdown(messages).pipe(Effect.asVoid),
}
}),
}
await run(
connector,
Effect.gen(function* () {
const transport = yield* SessionModelTransport.Service
const executor = transport.bind(session)
for (let attempt = 0; attempt < 5; attempt++) {
const result = yield* Effect.result(collect(executor, exchange(`attempt-${attempt}`)))
expect(result._tag).toBe("Failure")
}
expect(opens).toBe(5)
expect(yield* collect(executor, exchange("sixth"))).toEqual(["fallback:sixth"])
expect(opens).toBe(5)
}),
)
})
test("falls back to HTTP after close code 1009 and keeps the Session on HTTP", async () => {
const messages = queue<string | Uint8Array, AIError>()
let opened = 0
@@ -915,24 +949,22 @@ describe("SessionModelTransport", () => {
)
})
test("poisons instead of dropping data when the inbound queue overflows", async () => {
test("buffers a synchronous burst of frames larger than any fixed capacity", async () => {
// Bun dispatches every frame in a read buffer in one tick; a large tool call streams thousands
// of small argument deltas, so the exchange must absorb the whole burst before it can consume.
const burst = 1500
const messages = queue<string | Uint8Array, AIError>()
const poisoned = Deferred.makeUnsafe<void>()
let closed = 0
const connector: WebSocketConnector = {
open: () =>
Effect.succeed({
sendText: () =>
// Hold consumption at the send boundary until the reader fills and poisons the inbound queue.
Effect.sync(() => {
for (let index = 0; index <= 129; index++) Queue.offerUnsafe(messages, `frame:${index}`)
}).pipe(Effect.andThen(Deferred.await(poisoned))),
messages: Stream.fromQueue(messages).pipe(Stream.tap(() => Effect.yieldNow)),
close: Effect.sync(() => closed++).pipe(
Effect.andThen(Deferred.succeed(poisoned, undefined)),
Effect.andThen(Queue.shutdown(messages)),
Effect.asVoid,
),
for (let index = 0; index < burst; index++) Queue.offerUnsafe(messages, `frame:${index}`)
Queue.offerUnsafe(messages, "completed")
}),
messages: Stream.fromQueue(messages),
close: Effect.sync(() => closed++).pipe(Effect.andThen(Queue.shutdown(messages)), Effect.asVoid),
}),
}
@@ -941,20 +973,21 @@ describe("SessionModelTransport", () => {
Effect.gen(function* () {
const transport = yield* SessionModelTransport.Service
const item = exchange("first")
const result = yield* Effect.result(
collect(transport.bind(session), {
...item,
driver: {
create: item.driver.create,
observe: (_create, frame) => Effect.succeed({ type: "frame" as const, frame }),
},
}),
)
expect(result).toMatchObject({
_tag: "Failure",
failure: { reason: { _tag: "Transport", code: "queue-overflow", delivery: "accepted" } },
const frames = yield* collect(transport.bind(session), {
...item,
driver: {
create: item.driver.create,
observe: (_create, frame) =>
Effect.succeed(
frame === "completed" ? { type: "completed" as const, frame } : { type: "frame" as const, frame },
),
},
})
expect(closed).toBe(1)
expect(frames).toHaveLength(burst + 1)
expect(frames.slice(0, 3)).toEqual(["frame:0", "frame:1", "frame:2"])
expect(frames.at(-2)).toBe(`frame:${burst - 1}`)
expect(frames.at(-1)).toBe("completed")
expect(closed).toBe(0)
}),
)
})
+9
View File
@@ -93,6 +93,15 @@ test("recognizes Claude version spellings and future models", () => {
settings: { effort, thinking: { type: "adaptive", display: "summarized" } },
})),
)
expect(
resolve(model("@opencode/ai/providers/anthropic", "claude-haiku-4-5", 64_000), [
{ type: "budget_tokens", min: 1_024, max: 64_000 },
]),
).toEqual([
{ id: "high", settings: { thinking: { type: "enabled", budgetTokens: 16_000 } } },
{ id: "max", settings: { thinking: { type: "enabled", budgetTokens: 31_999 } } },
])
})
test("spells Cloudflare AI Gateway variants for their upstream routes", () => {

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