Compare commits

...
Author SHA1 Message Date
Brendan Allan c82d8bccd7 fix(app): prevent comment action clipping 2026-09-29 14:54:24 +08:00
Brendonovich 7c50519264 chore: merge latest v2 into pierre-comments 2026-09-29 05:42:50 +00:00
Aiden Cline 2aaa265cdc fix(ai): read provider error messages from common body layouts (#52014) 2026-09-29 00:19:20 -05:00
Luke Parker ff72659a47 fix(desktop): give isolated dev service a fixed port (#52013) 2026-09-29 15:09:50 +10:00
Aiden Cline 14af33d53d fix(ai): show provider error bodies when no message is recognized (#51978) 2026-09-29 00:06:52 -05:00
Luke Parker a604b7f773 fix(ui): calm diff word highlights with @pierre/diffs 1.5.1 (#51236) 2026-09-29 14:58:24 +10:00
Luke Parker 41d4a9c45c fix(session-ui): render moved notice as a timeline divider (#52009) 2026-09-29 14:58:13 +10:00
Luke Parker 293d60a89f chore(desktop): bump electron to 44.4.5 (#52005) 2026-09-29 14:50:04 +10:00
Aiden Cline 34a8938dc7 fix(ai): bind Bedrock Claude thinking blocks by default (#51959) 2026-09-28 23:38:20 -05:00
Aiden Cline 36ef6cc216 fix(ai): finalize Bedrock redacted reasoning at block end (#51999) 2026-09-28 23:37:51 -05:00
opencode f02c30eb55 sync release versions for v2.0.19 2026-09-29 04:31:02 +00:00
Aiden Cline ca084b2430 fix(ai): give provider routes distinct IDs (#51976) 2026-09-28 19:55:06 -05:00
Aiden Cline 3740ec311b feat(core): align shell tool environment with agent conventions (#51975) 2026-09-28 19:53:53 -05:00
opencode-agent[bot]andrekram1-node 35bd8ac442 test(core): update xAI variant expectations for Responses (#51977)
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2026-09-28 19:48:35 -05:00
Aiden Cline 1fc05ca590 fix(core): share affinity in provider session headers (#51931) 2026-09-28 19:30:24 -05:00
Aiden Cline bda798b167 fix(core): drop session ID and order instructions for prompt-cache reuse (#51960) 2026-09-28 19:29:28 -05:00
Aiden Cline 3babae35c0 fix(core): fall back to Cloudflare environment IDs when not configured (#51963) 2026-09-28 19:28:03 -05:00
Aiden Cline 3ab5c1433c fix(core): request xAI reasoning summaries on Responses variants (#51964) 2026-09-28 19:11:48 -05:00
Aiden Cline 49437a25b1 fix(ai): classify invalid Google API keys as authentication errors (#51950) 2026-09-28 18:49:29 -05:00
Aiden Cline 49403a554f feat(core): cap requested output tokens at 256k (#51962) 2026-09-28 18:49:05 -05:00
opencode-agent[bot]andrekram1-node 87d6f93409 test(cli): isolate run exit codes between tests (#51955)
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2026-09-28 19:16:01 -04:00
Frank 97d4eaa2ba zen: jev privacy policy 2026-09-28 19:01:46 -04:00
Aarav Sareen df010c9ae8 refactor(ui): use textarea for line comments 2026-09-18 18:57:38 +05:30
Aarav Sareen cda0e981d2 refactor(session-ui): reduce comment interaction work 2026-09-18 18:08:13 +05:30
Aarav Sareen 5e8d4790a3 fix(session-ui): preserve existing pierre version 2026-09-18 17:11:51 +05:30
Aarav Sareen d390419abe fix(session-ui): align gutter comment action 2026-09-18 16:51:05 +05:30
Aarav Sareen 86d5b05d30 feat(session-ui): position comment action by selection 2026-09-18 16:51:05 +05:30
Aarav Sareen 6573f923b8 feat(app): improve line comment interactions 2026-09-18 16:51:05 +05:30
123 changed files with 1620 additions and 365 deletions
+42 -48
View File
@@ -32,7 +32,7 @@
},
"packages/ai": {
"name": "@opencode/ai",
"version": "2.0.18",
"version": "2.0.19",
"dependencies": {
"@aws-sdk/credential-providers": "3.1057.0",
"@opencode/schema": "workspace:*",
@@ -54,7 +54,7 @@
},
"packages/app": {
"name": "@opencode/app",
"version": "2.0.18",
"version": "2.0.19",
"dependencies": {
"@corvu/drawer": "catalog:",
"@dnd-kit/abstract": "0.5.0",
@@ -112,7 +112,7 @@
},
"packages/cli": {
"name": "@opencode/cli",
"version": "2.0.18",
"version": "2.0.19",
"bin": {
"opencode": "./bin/opencode.cjs",
"opencode2": "./bin/opencode2.cjs",
@@ -178,7 +178,7 @@
},
"packages/client": {
"name": "@opencode/client",
"version": "2.0.18",
"version": "2.0.19",
"dependencies": {
"@opencode/protocol": "workspace:*",
"@opencode/schema": "workspace:*",
@@ -204,7 +204,7 @@
},
"packages/codemode": {
"name": "@opencode/codemode",
"version": "2.0.18",
"version": "2.0.19",
"dependencies": {
"acorn": "8.15.0",
"effect": "catalog:",
@@ -217,7 +217,7 @@
},
"packages/console/app": {
"name": "@opencode/console-app",
"version": "2.0.18",
"version": "2.0.19",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@ibm/plex": "6.4.1",
@@ -253,7 +253,7 @@
},
"packages/console/core": {
"name": "@opencode/console-core",
"version": "2.0.18",
"version": "2.0.19",
"dependencies": {
"@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1",
@@ -280,7 +280,7 @@
},
"packages/console/function": {
"name": "@opencode/console-function",
"version": "2.0.18",
"version": "2.0.19",
"dependencies": {
"@openauthjs/openauth": "0.0.0-20250322224806",
"@opencode/console-core": "workspace:*",
@@ -297,7 +297,7 @@
},
"packages/console/mail": {
"name": "@opencode/console-mail",
"version": "2.0.18",
"version": "2.0.19",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
@@ -321,7 +321,7 @@
},
"packages/console/support": {
"name": "@opencode/console-support",
"version": "2.0.18",
"version": "2.0.19",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@opencode/console-core": "workspace:*",
@@ -341,7 +341,7 @@
},
"packages/core": {
"name": "@opencode/core",
"version": "2.0.18",
"version": "2.0.19",
"dependencies": {
"@ai-sdk/cohere": "3.0.27",
"@ai-sdk/gateway": "3.0.104",
@@ -409,7 +409,7 @@
},
"packages/desktop": {
"name": "@opencode/desktop",
"version": "2.0.18",
"version": "2.0.19",
"dependencies": {
"@zip.js/zip.js": "2.7.62",
"electron-context-menu": "5.0.0",
@@ -439,7 +439,7 @@
"drizzle-kit": "catalog:",
"drizzle-orm": "catalog:",
"effect": "catalog:",
"electron": "44.4.3",
"electron": "44.4.5",
"electron-builder": "26.15.7",
"electron-vite": "6.0.0-beta.1",
"puppeteer-core": "25.9.0",
@@ -458,7 +458,7 @@
},
"packages/enterprise": {
"name": "@opencode/enterprise",
"version": "2.0.18",
"version": "2.0.19",
"dependencies": {
"@hono/standard-validator": "catalog:",
"@opencode-ai/sdk": "1.18.21",
@@ -495,7 +495,7 @@
},
"packages/function": {
"name": "@opencode/function",
"version": "2.0.18",
"version": "2.0.19",
"dependencies": {
"@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:",
@@ -511,7 +511,7 @@
},
"packages/http-recorder": {
"name": "@opencode/http-recorder",
"version": "2.0.18",
"version": "2.0.19",
"dependencies": {
"@effect/platform-node-shared": "4.0.0-rc.112",
},
@@ -530,7 +530,7 @@
},
"packages/httpapi-codegen": {
"name": "@opencode/httpapi-codegen",
"version": "2.0.18",
"version": "2.0.19",
"dependencies": {
"effect": "catalog:",
"prettier": "3.6.2",
@@ -543,7 +543,7 @@
},
"packages/latex": {
"name": "@opencode/latex",
"version": "2.0.18",
"version": "2.0.19",
"dependencies": {
"@opencode/plugin": "workspace:*",
"@opentui/core": "catalog:",
@@ -557,7 +557,7 @@
},
"packages/merman": {
"name": "@opencode/merman",
"version": "2.0.18",
"version": "2.0.19",
"dependencies": {
"@opencode/plugin": "workspace:*",
"@opentui/core": "catalog:",
@@ -572,7 +572,7 @@
},
"packages/plugin": {
"name": "@opencode/plugin",
"version": "2.0.18",
"version": "2.0.19",
"dependencies": {
"@ai-sdk/provider": "3.0.8",
"@opencode/ai": "workspace:*",
@@ -611,7 +611,7 @@
},
"packages/plugin-browser": {
"name": "@opencode/plugin-browser",
"version": "2.0.18",
"version": "2.0.19",
"dependencies": {
"@opencode/plugin": "workspace:*",
"@opencode/schema": "workspace:*",
@@ -641,7 +641,7 @@
},
"packages/protocol": {
"name": "@opencode/protocol",
"version": "2.0.18",
"version": "2.0.19",
"dependencies": {
"@opencode/schema": "workspace:*",
"effect": "catalog:",
@@ -656,7 +656,7 @@
},
"packages/schema": {
"name": "@opencode/schema",
"version": "2.0.18",
"version": "2.0.19",
"dependencies": {
"@standard-schema/spec": "catalog:",
"effect": "catalog:",
@@ -680,7 +680,7 @@
},
"packages/sdk": {
"name": "@opencode/sdk",
"version": "2.0.18",
"version": "2.0.19",
"dependencies": {
"@opencode/client": "workspace:*",
"@opencode/core": "workspace:*",
@@ -701,7 +701,7 @@
},
"packages/server": {
"name": "@opencode/server",
"version": "2.0.18",
"version": "2.0.19",
"dependencies": {
"@effect/platform-node": "catalog:",
"@effect/platform-node-shared": "catalog:",
@@ -723,7 +723,7 @@
},
"packages/session-ui": {
"name": "@opencode/session-ui",
"version": "2.0.18",
"version": "2.0.19",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode/client": "workspace:*",
@@ -758,7 +758,7 @@
},
"packages/simulation": {
"name": "@opencode/simulation",
"version": "2.0.18",
"version": "2.0.19",
"dependencies": {
"@opencode/ai": "workspace:*",
"@opencode/core": "workspace:*",
@@ -778,7 +778,7 @@
},
"packages/stats/app": {
"name": "@opencode/stats-app",
"version": "2.0.18",
"version": "2.0.19",
"dependencies": {
"@ibm/plex": "6.4.1",
"@kobalte/core": "catalog:",
@@ -812,7 +812,7 @@
},
"packages/stats/core": {
"name": "@opencode/stats-core",
"version": "2.0.18",
"version": "2.0.19",
"dependencies": {
"@aws-sdk/client-athena": "3.933.0",
"@planetscale/database": "1.19.0",
@@ -831,7 +831,7 @@
},
"packages/stats/server": {
"name": "@opencode/stats-server",
"version": "2.0.18",
"version": "2.0.19",
"dependencies": {
"@aws-sdk/client-firehose": "3.933.0",
"@effect/platform-node": "catalog:",
@@ -877,7 +877,7 @@
},
"packages/theme": {
"name": "@opencode/theme",
"version": "2.0.18",
"version": "2.0.19",
"dependencies": {
"@opentui/core": "catalog:",
"effect": "catalog:",
@@ -891,7 +891,7 @@
},
"packages/tui": {
"name": "@opencode/tui",
"version": "2.0.18",
"version": "2.0.19",
"dependencies": {
"@opencode/client": "workspace:*",
"@opencode/core": "workspace:*",
@@ -925,7 +925,7 @@
},
"packages/ui": {
"name": "@opencode/ui",
"version": "2.0.18",
"version": "2.0.19",
"dependencies": {
"@kobalte/core": "catalog:",
"@pierre/diffs": "catalog:",
@@ -960,7 +960,7 @@
},
"packages/util": {
"name": "@opencode/util",
"version": "2.0.18",
"version": "2.0.19",
"dependencies": {
"@effect/opentelemetry": "catalog:",
"@effect/platform-node": "catalog:",
@@ -998,7 +998,7 @@
},
"packages/web": {
"name": "@opencode/web",
"version": "2.0.18",
"version": "2.0.19",
"dependencies": {
"@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1",
@@ -1039,7 +1039,7 @@
},
"services/update": {
"name": "@opencode/update",
"version": "2.0.18",
"version": "2.0.19",
"dependencies": {
"jose": "6.0.11",
"semver": "catalog:",
@@ -1119,7 +1119,7 @@
"@opentui/core": "0.5.12",
"@opentui/keymap": "0.5.12",
"@opentui/solid": "0.5.12",
"@pierre/diffs": "1.2.10",
"@pierre/diffs": "1.5.1",
"@playwright/test": "1.59.1",
"@sentry/solid": "10.71.0",
"@sentry/vite-plugin": "5.4.0",
@@ -2530,11 +2530,11 @@
"@peculiar/webcrypto": ["@peculiar/webcrypto@1.7.1", "", { "dependencies": { "@peculiar/asn1-schema": "^2.7.0", "@peculiar/json-schema": "^1.1.12", "@peculiar/utils": "^2.0.2", "tslib": "^2.8.1", "webcrypto-core": "^1.9.2" } }, "sha512-ODOov0sGMJMf3jPonOkgGqPknTsu+DdQ7kD++gz8aI+aFMOMHFbWAA2taqXXVTdP+OTOQR/znGvSpmkeI0WTYQ=="],
"@pierre/diffs": ["@pierre/diffs@1.2.10", "", { "dependencies": { "@pierre/theme": "1.0.3", "@pierre/theming": "0.0.1", "@shikijs/transformers": "^3.0.0 || ^4.0.0", "diff": "8.0.3", "hast-util-to-html": "9.0.5", "lru_map": "0.4.1", "shiki": "^3.0.0 || ^4.0.0" }, "peerDependencies": { "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0" } }, "sha512-rPeAmDWarxFVTQpaf4y6wTxjZxU44xKJKoJti2zU21P06DVd9nRHZX+xSIObLB307Qjpaesyb1x/j0z94t7vLw=="],
"@pierre/diffs": ["@pierre/diffs@1.5.1", "", { "dependencies": { "@pierre/theme": "2.0.0", "@pierre/theming": "1.0.1", "@shikijs/transformers": "^3.0.0 || ^4.0.0", "diff": "9.0.0", "hast-util-to-html": "9.0.5", "lru_map": "0.4.1", "shiki": "^3.0.0 || ^4.0.0" }, "peerDependencies": { "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0" }, "optionalPeers": ["react", "react-dom"] }, "sha512-+EXNfz4ZXI6FHr7P6ToWEORwyYiPVfAmzhA70xF+0y/oNvj7gbNTLJJ6jNzkBjuMbRlu564TAmgYZLc5OyHURA=="],
"@pierre/theme": ["@pierre/theme@1.0.3", "", {}, "sha512-sWHv11TMoqKxKDgTIk5VbhQjdPhs8DCcBxbjh3mRlS3YOM/OcrWoGX6MM8eBGn9cUu3M46Py0JnxsG2nJaFTuA=="],
"@pierre/theme": ["@pierre/theme@2.0.0", "", {}, "sha512-yNDd9GYLQl1mEUJR8AneJ5e4ohLIHQd/wZLWr4fagt78vS2RwwZNW530vVgHqXFAyFVcFlRmGUD5ramXH46OXw=="],
"@pierre/theming": ["@pierre/theming@0.0.1", "", { "peerDependencies": { "@pierre/theme": "^1.0.0", "@shikijs/themes": "^3.0.0 || ^4.0.0", "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0", "shiki": "^3.0.0 || ^4.0.0" }, "optionalPeers": ["@pierre/theme", "@shikijs/themes", "react", "react-dom", "shiki"] }, "sha512-1thlEtJbqdyLzc1ZS2KQa1q7FzDGHT4dTEdKHoyQjOMeWWOmbVG5/ndEfOKfAb5Fzkz8cNJrOjFLiZoDH/A03A=="],
"@pierre/theming": ["@pierre/theming@1.0.1", "", { "peerDependencies": { "@pierre/theme": "^1.1.0 || ^2.0.0", "@shikijs/themes": "^3.0.0 || ^4.0.0", "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0", "shiki": "^3.0.0 || ^4.0.0" }, "optionalPeers": ["@pierre/theme", "@shikijs/themes", "react", "react-dom", "shiki"] }, "sha512-WCI5Qd7iprDpISL9fBYOLe8RV53+b7mFNA3bPzl60/2CKCSrsKN8zEcep6Y3BAzvARlmca50zGjDodqPGiTUKA=="],
"@pierre/trees": ["@pierre/trees@1.0.0-beta.4", "", { "dependencies": { "preact": "11.0.0-beta.0", "preact-render-to-string": "6.6.5" }, "peerDependencies": { "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0" } }, "sha512-OfT1yk9ne8Te5+GB5zUY8yqE6B8BqjBHQJleH4lu8ltwNpoocZl4vXt1AzlEExpxI/pp+AFX5QG+lR3JjtTEag=="],
@@ -3870,7 +3870,7 @@
"ejs": ["ejs@3.1.10", "", { "dependencies": { "jake": "^10.8.5" }, "bin": { "ejs": "bin/cli.js" } }, "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA=="],
"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": ["electron@44.4.5", "", { "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-SjgoaeYsSWZfJzubgQU7juvuXMTvn6/e1gAHdGFA/yuMbpF+I+skYqIJ6DdXBXHeWbkbpNpmwZCTpiivtlWZSw=="],
"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=="],
@@ -6258,11 +6258,7 @@
"@parcel/watcher/detect-libc": ["detect-libc@1.0.3", "", { "bin": { "detect-libc": "./bin/detect-libc.js" } }, "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg=="],
"@pierre/diffs/diff": ["diff@8.0.3", "", {}, "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ=="],
"@pierre/diffs/react": ["react@19.2.8", "", {}, "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw=="],
"@pierre/diffs/react-dom": ["react-dom@19.2.8", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.8" } }, "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ=="],
"@pierre/diffs/diff": ["diff@9.0.0", "", {}, "sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw=="],
"@pierre/trees/react": ["react@19.2.8", "", {}, "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw=="],
@@ -7160,8 +7156,6 @@
"@oxc-resolver/binding-wasm32-wasi/@emnapi/core/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA=="],
"@pierre/diffs/react-dom/scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
"@pierre/trees/react-dom/scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
"@puppeteer/browsers/yargs/cliui": ["cliui@9.0.1", "", { "dependencies": { "string-width": "^7.2.0", "strip-ansi": "^7.1.0", "wrap-ansi": "^9.0.0" } }, "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w=="],
+2 -2
View File
@@ -2,7 +2,7 @@
"$schema": "https://json.schemastore.org/package.json",
"name": "opencode",
"description": "AI-powered development tool",
"version": "2.0.18",
"version": "2.0.19",
"private": true,
"type": "module",
"packageManager": "bun@1.4.2",
@@ -68,7 +68,7 @@
"@tsconfig/bun": "1.0.9",
"@cloudflare/workers-types": "4.20251008.0",
"@openauthjs/openauth": "0.0.0-20250322224806",
"@pierre/diffs": "1.2.10",
"@pierre/diffs": "1.5.1",
"opentui-spinner": "0.0.7",
"@solid-primitives/event-listener": "2.4.6",
"@solid-primitives/media": "2.3.6",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "2.0.18",
"version": "2.0.19",
"name": "@opencode/ai",
"type": "module",
"license": "MIT",
+1
View File
@@ -39,6 +39,7 @@ const resolve = (policy: CachePolicy | undefined): CachePolicyObject => {
// whole policy pass for these — emitting hints would be harmless but pointless.
const RESPECTS_INLINE_HINTS = new Set([
"anthropic-messages",
"anthropic-compatible-messages",
"google-vertex-messages",
"bedrock-converse",
"openrouter",
@@ -29,6 +29,7 @@ import { JsonObject, knownString, optionalArray, optionalNull, ProviderShared }
import { classifyProviderFailure } from "../provider-error.js"
import { effortUpdate, resolveEffortUpdates } from "../effort-updates.js"
import * as Cache from "./utils/cache.js"
import { claudeVersion, supportsThinkingBlockBinding, THINKING_BINDING_BETA } from "./utils/claude-model.js"
import { Lifecycle } from "./utils/lifecycle.js"
import { ToolStream } from "./utils/tool-stream.js"
@@ -988,22 +989,6 @@ const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* (
return messages
})
// Accept gateway namespaces and Vertex suffixes without treating a snapshot date as a minor version.
const claudeVersion = (id: string) => {
const match = /(?:^|[./])claude-(?<family>[a-z]+)-(?<major>\d+)(?:[.-](?<minor>\d{1,2}))?(?:$|[-:@])/.exec(
id.toLowerCase(),
)?.groups
if (!match) return undefined
return { family: match.family, major: Number(match.major), minor: Number(match.minor ?? 0) }
}
const supportsThinkingBlockBinding = (model: LLMRequest["model"]) => {
const override = model.compatibility?.supportsThinkingBlockBinding
if (override !== undefined) return override
const version = claudeVersion(model.id)
return version !== undefined && (version.major > 5 || (version.major === 5 && version.minor >= 1))
}
const supportsEffortUpdates = (model: LLMRequest["model"]) => {
const override = model.compatibility?.supportsEffortUpdates
if (override !== undefined) return override
@@ -1652,8 +1637,7 @@ function requiredBetaHeaders(body: Pick<AnthropicMessagesBody, "messages" | "con
betas.push("mid-conversation-output-config-2026-07-01")
const thinking = body.thinking
if (thinking && thinking.type !== "disabled" && thinking.block_binding)
betas.push("thinking-binding-controls-2026-08-01")
if (thinking && thinking.type !== "disabled" && thinking.block_binding) betas.push(THINKING_BINDING_BETA)
return betas
}
+48 -11
View File
@@ -23,6 +23,7 @@ import { JsonObject, optionalArray, ProviderShared } from "./shared.js"
import { BedrockAuth } from "./utils/bedrock-auth.js"
import { BedrockCache } from "./utils/bedrock-cache.js"
import { BedrockMedia } from "./utils/bedrock-media.js"
import { supportsThinkingBlockBinding, THINKING_BINDING_BETA } from "./utils/claude-model.js"
import { Lifecycle } from "./utils/lifecycle.js"
import { MistralToolID } from "./utils/mistral-tool-id.js"
import { ToolStream } from "./utils/tool-stream.js"
@@ -443,6 +444,23 @@ const decodeOptions = ProviderShared.validateWith(Schema.decodeUnknownEffect(Opt
// Claude on Bedrock requires the thinking budget below `maxTokens`, with a minimum of 1,024.
const MIN_THINKING_BUDGET = 1_024
const isThinkingDisabled = Schema.is(
Schema.Struct({
additionalModelRequestFields: Schema.Struct({ thinking: Schema.Struct({ type: Schema.Literal("disabled") }) }),
}),
)
// Claude 5.1+ binds each thinking signature to the prefix above it. Ask Bedrock to drop the affected blocks instead of
// failing when that prefix changes. `http.body` overlays this field by field, so callers can still override it.
const applyThinkingBindingDefault = (request: LLMRequest, thinking: Readonly<Record<string, unknown>> | undefined) => {
if (isThinkingDisabled(request.http?.body)) return thinking
if (!supportsThinkingBlockBinding(request.model)) return thinking
return {
...(thinking ?? { type: "adaptive" as const }),
block_binding: { prefix_mismatch_behavior: "drop_block" },
}
}
const fromRequest = Effect.fn("BedrockConverse.fromRequest")(function* (request: LLMRequest) {
const toolChoice = request.toolChoice ? yield* lowerToolChoice(request.toolChoice) : undefined
const flattened = ProviderShared.flattenToolRequest(request)
@@ -450,7 +468,8 @@ const fromRequest = Effect.fn("BedrockConverse.fromRequest")(function* (request:
const options = yield* decodeOptions(request.providerOptions ?? {})
const maxTokens =
isNova2(request.model) && isHighReasoningEffort(request.http?.body) ? undefined : generation?.maxTokens
const thinking =
const thinking = applyThinkingBindingDefault(
request,
options.thinking === undefined
? undefined
: {
@@ -460,7 +479,8 @@ const fromRequest = Effect.fn("BedrockConverse.fromRequest")(function* (request:
maxTokens,
MIN_THINKING_BUDGET,
),
}
},
)
// Bedrock-Claude shares Anthropic's 4-breakpoint cap. Spend the budget in
// tools → system → messages order to favour the highest-impact prefixes.
const breakpoints = BedrockCache.breakpoints(request.model.id)
@@ -509,6 +529,8 @@ const fromRequest = Effect.fn("BedrockConverse.fromRequest")(function* (request:
: {
...(generation?.topK === undefined ? {} : { top_k: generation.topK }),
...(thinking === undefined ? {} : { thinking }),
// Converse takes Anthropic betas in the body, and Bedrock rejects `block_binding` without this one.
...(thinking?.block_binding === undefined ? {} : { anthropic_beta: [THINKING_BINDING_BETA] }),
},
}
})
@@ -555,7 +577,7 @@ interface ParserState {
readonly hasToolCalls: boolean
readonly lifecycle: Lifecycle.State
readonly reasoningSignatures: Readonly<Record<number, string>>
readonly reasoningRedactedContent: Readonly<Record<number, ReadonlyArray<Uint8Array>>>
readonly reasoningRedactedContent: Readonly<Record<number, Uint8Array[]>>
}
const encodeRedactedContent = (chunks: ReadonlyArray<Uint8Array>) => Encoding.encodeBase64(concatBytes(chunks))
@@ -605,10 +627,9 @@ const step = (state: ParserState, event: BedrockEvent) =>
const index = event.contentBlockDelta.contentBlockIndex
const reasoning = event.contentBlockDelta.delta.reasoningContent
const events: LLMEvent[] = []
const redactedChunks = yield* (() => {
const redactedChunk = yield* (() => {
if (reasoning.redactedContent === undefined) return Effect.succeed(undefined)
return Effect.fromResult(Encoding.decodeBase64(reasoning.redactedContent)).pipe(
Effect.map((chunk) => [...(state.reasoningRedactedContent[index] ?? []), chunk]),
Effect.mapError((cause) =>
ProviderShared.eventError(
ADAPTER,
@@ -619,17 +640,21 @@ const step = (state: ParserState, event: BedrockEvent) =>
),
)
})()
const redactedData = redactedChunks === undefined ? reasoning.data : encodeRedactedContent(redactedChunks)
const redactedChunks = state.reasoningRedactedContent[index] ?? []
if (redactedChunk !== undefined) redactedChunks.push(redactedChunk)
const metadata = (() => {
if (reasoning.signature) return providerMetadata(state.providerMetadataKey, { signature: reasoning.signature })
if (redactedData !== undefined) return providerMetadata(state.providerMetadataKey, { redactedData })
if (redactedChunk === undefined && reasoning.data !== undefined)
return providerMetadata(state.providerMetadataKey, { redactedData: reasoning.data })
})()
const lifecycle = (() => {
if (reasoning.text === undefined && metadata === undefined) return state.lifecycle
return Lifecycle.reasoningDelta(state.lifecycle, events, `reasoning-${index}`, reasoning.text ?? "", metadata)
if (reasoning.text !== undefined || metadata !== undefined)
return Lifecycle.reasoningDelta(state.lifecycle, events, `reasoning-${index}`, reasoning.text ?? "", metadata)
if (redactedChunk !== undefined) return Lifecycle.reasoningStart(state.lifecycle, events, `reasoning-${index}`)
return state.lifecycle
})()
const reasoningRedactedContent = (() => {
if (redactedChunks !== undefined) return { ...state.reasoningRedactedContent, [index]: redactedChunks }
if (redactedChunk !== undefined) return { ...state.reasoningRedactedContent, [index]: redactedChunks }
if (reasoning.data === undefined) return state.reasoningRedactedContent
return Object.fromEntries(
Object.entries(state.reasoningRedactedContent).filter(([key]) => key !== String(index)),
@@ -765,7 +790,19 @@ const onHalt = (state: ParserState): ReadonlyArray<LLMEvent> => {
return state.finishReason.normalized
})()
const events: LLMEvent[] = []
Lifecycle.finish(state.lifecycle, events, {
const lifecycle = Object.entries(state.reasoningRedactedContent).reduce((current, [index, chunks]) => {
const signature = state.reasoningSignatures[Number(index)]
return Lifecycle.reasoningEnd(
current,
events,
`reasoning-${index}`,
providerMetadata(
state.providerMetadataKey,
signature ? { signature } : { redactedData: encodeRedactedContent(chunks) },
),
)
}, state.lifecycle)
Lifecycle.finish(lifecycle, events, {
reason: {
...state.finishReason,
normalized,
@@ -0,0 +1,19 @@
import type { LLMRequest } from "../../schema/index.js"
export const THINKING_BINDING_BETA = "thinking-binding-controls-2026-08-01"
// Accept gateway namespaces and Vertex suffixes without treating a snapshot date as a minor version.
export const claudeVersion = (id: string) => {
const match = /(?:^|[./])claude-(?<family>[a-z]+)-(?<major>\d+)(?:[.-](?<minor>\d{1,2}))?(?:$|[-:@])/.exec(
id.toLowerCase(),
)?.groups
if (!match) return undefined
return { family: match.family, major: Number(match.major), minor: Number(match.minor ?? 0) }
}
export const supportsThinkingBlockBinding = (model: LLMRequest["model"]) => {
const override = model.compatibility?.supportsThinkingBlockBinding
if (override !== undefined) return override
const version = claudeVersion(model.id)
return version !== undefined && (version.major > 5 || (version.major === 5 && version.minor >= 1))
}
+42 -2
View File
@@ -1,4 +1,4 @@
import { Option, Schema } from "effect"
import { Option, Schema, SchemaGetter } from "effect"
import {
AuthenticationError,
ContentPolicyError,
@@ -109,7 +109,8 @@ const QUOTA_CODES = new Set([
"freeusagelimiterror",
"creditlimitexceeded",
])
const AUTH_CODES = new Set(["authentication_error", "permission_error"])
// Google reports an invalid API key as HTTP 400 INVALID_ARGUMENT with this `details[].reason`.
const AUTH_CODES = new Set(["authentication_error", "permission_error", "api_key_invalid"])
const SERVER_CODES = new Set([
"api_error",
"internal_error",
@@ -154,6 +155,41 @@ const CONTENT_POLICY_TEXT =
const SERVER_ERROR_TEXT =
/\b(?:try again|(?:please |you can )?retry (?:the |this |your )?request|try (?:the |this |your )?request again|(?:currently |temporarily )?at capacity|overloaded|temporarily unavailable|service[-_\s]?unavailable|(?:server|internal)[-_\s]?error|server (?:is )?busy|provider returned (?:an )?error|resource[-_\s]?exhausted|upstream (?:connect|connection|request)|request buffer limit while retrying upstream)\b/i
const Message = Schema.String.check(Schema.isPattern(/\S/))
const messageAt = <Fields extends Schema.Struct.Fields>(
fields: Fields,
message: (body: Schema.Struct<Fields>["Type"]) => string,
) =>
Schema.Struct(fields).pipe(
Schema.decodeTo(Schema.String, {
decode: SchemaGetter.transform(message),
encode: SchemaGetter.forbidden(() => "Provider error messages are decode-only"),
}),
)
// Common error body layouts that carry a human-readable message, in priority order.
// Provider-specific layouts belong in their protocol.
const decodeMessage = Schema.decodeUnknownOption(
Schema.fromJsonString(
Schema.Union([
messageAt({ error: Schema.Struct({ message: Message }) }, (body) => body.error.message),
messageAt({ error: Message }, (body) => body.error),
messageAt({ message: Message }, (body) => body.message),
// AWS services
messageAt({ Message: Message }, (body) => body.Message),
// RFC 9457 problem details
messageAt({ detail: Message }, (body) => body.detail),
messageAt(
{ errors: Schema.NonEmptyArray(Schema.Struct({ message: Message })) },
(body) => body.errors[0].message,
),
]),
),
)
export const providerErrorMessage = (body: string) => Option.getOrUndefined(decodeMessage(body))
export interface ProviderFailure {
readonly message: string
readonly status?: number | undefined
@@ -259,6 +295,10 @@ function providerCodes(value: unknown) {
error?.type,
error?.status,
error?.error_type,
// Google `google.rpc.ErrorInfo` details carry the specific reason.
...(Array.isArray(error?.details)
? error.details.map((detail) => (isRecord(detail) ? detail.reason : undefined))
: []),
inner?.code,
metadata?.error_type,
responseError?.code,
@@ -28,7 +28,8 @@ export type Settings = ProviderPackage.Settings &
readonly provider?: string
}
export const routes = [AnthropicMessages.route]
const compatibleRoute = AnthropicMessages.route.with({ id: "anthropic-compatible-messages", provider: id })
export const routes = [compatibleRoute]
const auth = (input: ProviderAuthOption<"optional">) => {
if ("auth" in input && input.auth) return input.auth
@@ -43,7 +44,7 @@ export const configure = (input: Config) => {
message: "Anthropic-compatible providers require a baseURL",
})
const { provider: _, baseURL, apiKey: _apiKey, auth: _auth, ...rest } = input
const route = AnthropicMessages.route.with({
const route = (provider === "anthropic" ? AnthropicMessages.route : compatibleRoute).with({
...rest,
provider,
endpoint: { baseURL },
+3 -3
View File
@@ -35,13 +35,13 @@ const RESPONSES_WEBSOCKET_ROTATE_AFTER_MS = 24 * 60 * 1000
const responsesRoute = Route.make({
compact: { endpoint: XAIResponses.compact },
id: "openai-responses",
id: "xai-responses",
provider: id,
providerMetadataKey: "xai",
protocol: XAIResponses.protocol,
endpoint: Endpoint.path("/responses", { baseURL }),
transport: OpenResponsesChannel.transport({
id: "openai-responses",
id: "xai-responses",
name: "xAI Responses",
rotateAfterMs: RESPONSES_WEBSOCKET_ROTATE_AFTER_MS,
// xAI continues a chain only from stored responses: with `store: false` (the route default) `previous_response_id`
@@ -53,7 +53,7 @@ const responsesRoute = Route.make({
})
const chatRoute = Route.make({
id: "openai-compatible-chat",
id: "xai-chat",
provider: id,
providerMetadataKey: "xai",
protocol: OpenAIChat.protocol,
+10 -14
View File
@@ -8,7 +8,7 @@ import {
HttpClientResponse,
} from "effect/unstable/http"
import { HttpContext, HttpRateLimitDetails, AIError, TransportError } from "../schema/index.js"
import { classifyProviderFailure } from "../provider-error.js"
import { classifyProviderFailure, providerErrorMessage } from "../provider-error.js"
import { Service, type HttpMiddleware, type Interface } from "./executor-service.js"
export { Service } from "./executor-service.js"
@@ -84,21 +84,17 @@ export const responseHttp = (response: HttpClientResponse.HttpClientResponse) =>
headers: headerDetails(response.headers),
})
const decodeProviderBody = Schema.decodeUnknownOption(
Schema.fromJsonString(
Schema.Struct({
message: Schema.optionalKey(Schema.String),
error: Schema.optionalKey(Schema.Struct({ message: Schema.optionalKey(Schema.String) })),
}),
),
)
const MAX_BODY_CHARS = 2000
// Without a recognized message, show the raw body so the provider's explanation is never dropped.
const providerMessage = (status: number, body: string | void) => {
const decoded = body === undefined ? undefined : Option.getOrUndefined(decodeProviderBody(body))
return (
[decoded?.error?.message, decoded?.message].find((message) => message?.trim()) ??
`Provider request failed with HTTP ${status}`
)
const fallback = `Provider request failed with HTTP ${status}`
const text = body?.trim() ?? ""
const message = providerErrorMessage(text)
if (message) return message
// Gateway and proxy HTML error pages are markup, not an explanation.
if (!text || /^<(?:!doctype|html)/i.test(text)) return fallback
return `${fallback}: ${text.length > MAX_BODY_CHARS ? `${text.slice(0, MAX_BODY_CHARS)}…` : text}`
}
const statusError = (response: HttpClientResponse.HttpClientResponse) =>
+21 -1
View File
@@ -3,7 +3,7 @@ import { Effect } from "effect"
import { CacheHint, LLM, Message } from "../src/index.js"
import { Auth } from "../src/route.js"
import { compileRequest } from "../src/route/client.js"
import { AmazonBedrock, GoogleVertexMessages } from "../src/providers.js"
import { AmazonBedrock, AnthropicCompatible, GoogleVertexMessages } from "../src/providers.js"
import * as AnthropicMessages from "../src/protocols/anthropic-messages.js"
import * as Gemini from "../src/protocols/gemini.js"
import * as OpenAIChat from "../src/protocols/openai-chat.js"
@@ -107,6 +107,26 @@ describe("applyCachePolicy", () => {
}),
)
it.effect("'auto' emits Anthropic cache markers on Anthropic-compatible routes", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model: AnthropicCompatible.configure({ apiKey: "test", baseURL: "https://messages.example.test/v1" }).model(
"compatible",
),
system: "You are concise.",
prompt: "hi",
}),
)
expect(prepared.route).toBe("anthropic-compatible-messages")
expect(prepared.body).toMatchObject({
system: [{ type: "text", text: "You are concise.", cache_control: { type: "ephemeral" } }],
messages: [{ role: "user", content: [{ type: "text", text: "hi", cache_control: { type: "ephemeral" } }] }],
})
}),
)
it.effect("'auto' is a no-op on OpenAI (implicit caching protocol)", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
+49 -3
View File
@@ -273,10 +273,56 @@ describe("RequestExecutor", () => {
expectAIError(error)
expect(error.reason).toMatchObject({ _tag: "InvalidRequest" })
expect("classification" in error.reason ? error.reason.classification : undefined).toBeUndefined()
expect(error.message).toBe("Provider request failed with HTTP 400")
expect(error.message).toBe("Provider request failed with HTTP 400: invalid parameter")
}).pipe(Effect.provide(fixedResponse("invalid parameter", { status: 400 }))),
)
it.effect("shows unrecognized provider error bodies", () =>
Effect.gen(function* () {
const executor = yield* RequestExecutor.Service
const error = yield* executor.execute(request).pipe(Effect.flip)
expect(error.message).toBe(
'Provider request failed with HTTP 422: {"object":"error","message":{"detail":[{"msg":"Input should be less than or equal to 1.5"}]}}',
)
}).pipe(
Effect.provide(
fixedResponse('{"object":"error","message":{"detail":[{"msg":"Input should be less than or equal to 1.5"}]}}', {
status: 422,
}),
),
),
)
it.effect("shows messages from common provider error layouts", () =>
Effect.gen(function* () {
const executor = yield* RequestExecutor.Service
const error = yield* executor.execute(request).pipe(Effect.flip)
expect(error.message).toBe("Invalid API Key")
}).pipe(Effect.provide(fixedResponse('{"detail":"Invalid API Key"}', { status: 401 }))),
)
it.effect("truncates long unrecognized provider error bodies", () =>
Effect.gen(function* () {
const executor = yield* RequestExecutor.Service
const error = yield* executor.execute(request).pipe(Effect.flip)
expect(error.message).toBe(`Provider request failed with HTTP 400: ${"x".repeat(2000)}…`)
expect(error.reason.body).toHaveLength(5000)
}).pipe(Effect.provide(fixedResponse("x".repeat(5000), { status: 400 }))),
)
it.effect("does not show HTML error pages", () =>
Effect.gen(function* () {
const executor = yield* RequestExecutor.Service
const error = yield* executor.execute(request).pipe(Effect.flip)
expect(error.message).toBe("Provider request failed with HTTP 502")
expect(error.reason.body).toContain("Bad Gateway")
}).pipe(Effect.provide(fixedResponse("<!DOCTYPE html><html><body>Bad Gateway</body></html>", { status: 502 }))),
)
it.effect("preserves structured provider messages from large error bodies", () =>
Effect.gen(function* () {
const executor = yield* RequestExecutor.Service
@@ -299,7 +345,7 @@ describe("RequestExecutor", () => {
),
)
it.effect("falls back when structured provider messages are empty", () =>
it.effect("shows the body when structured provider messages are empty", () =>
Effect.gen(function* () {
const executor = yield* RequestExecutor.Service
const error = yield* executor.execute(request).pipe(Effect.flip)
@@ -308,7 +354,7 @@ describe("RequestExecutor", () => {
expect(error.reason).toMatchObject({
_tag: "InvalidRequest",
})
expect(error.message).toBe("Provider request failed with HTTP 400")
expect(error.message).toBe('Provider request failed with HTTP 400: {"error":{"message":" "}}')
}).pipe(Effect.provide(fixedResponse('{"error":{"message":" "}}', { status: 400 }))),
)
+2 -2
View File
@@ -154,8 +154,8 @@ describe("public exports", () => {
expect(XAI.model).toBeFunction()
expect(XAI.provider.responses).toBe(XAI.responses)
expect(XAI.provider.chat).toBe(XAI.chat)
expect(XAI.configure({ apiKey: "fixture" }).responses("grok-4.3").route.id).toBe("openai-responses")
expect(XAI.configure({ apiKey: "fixture" }).chat("grok-4.3").route.id).toBe("openai-compatible-chat")
expect(XAI.configure({ apiKey: "fixture" }).responses("grok-4.3").route.id).toBe("xai-responses")
expect(XAI.configure({ apiKey: "fixture" }).chat("grok-4.3").route.id).toBe("xai-chat")
expect(OpenAI.configure({ apiKey: "fixture" }).image("gpt-image-2").route.id).toBe("openai-images")
expect(OpenAI.provider.image).toBe(OpenAI.image)
expect(Google.configure({ apiKey: "fixture" }).image("imagen-4.0-generate-001").route.id).toBe("google-images")
@@ -0,0 +1,34 @@
{
"version": 1,
"metadata": {
"tags": [
"prefix:bedrock-converse-thinking-binding",
"provider:amazon-bedrock",
"protocol:bedrock-converse",
"reasoning"
],
"name": "bedrock-converse-thinking-binding/accepts-the-default-binding-with-no-thinking-configured",
"recordedAt": "2026-09-28T23:09:14.527Z"
},
"interactions": [
{
"transport": "http",
"request": {
"method": "POST",
"url": "https://bedrock-runtime.us-east-1.amazonaws.com/model/global.anthropic.claude-opus-5-5/converse-stream",
"headers": {
"content-type": "application/json"
},
"body": "{\"modelId\":\"global.anthropic.claude-opus-5-5\",\"messages\":[{\"role\":\"user\",\"content\":[{\"text\":\"Say hello.\"}]}],\"system\":[{\"text\":\"Reply with the single word 'Hello'.\"}],\"inferenceConfig\":{\"maxTokens\":1024},\"additionalModelRequestFields\":{\"thinking\":{\"type\":\"adaptive\",\"block_binding\":{\"prefix_mismatch_behavior\":\"drop_block\"}},\"anthropic_beta\":[\"thinking-binding-controls-2026-08-01\"]}}"
},
"response": {
"status": 200,
"headers": {
"content-type": "application/vnd.amazon.eventstream"
},
"body": "AAAAoQAAAFKtAFmXCzpldmVudC10eXBlBwAMbWVzc2FnZVN0YXJ0DTpjb250ZW50LXR5cGUHABBhcHBsaWNhdGlvbi9qc29uDTptZXNzYWdlLXR5cGUHAAVldmVudHsicCI6ImFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6QUJDREVGR0hJSiIsInJvbGUiOiJhc3Npc3RhbnQifUMJiXoAAAC8AAAAV0Ua/isLOmV2ZW50LXR5cGUHABFjb250ZW50QmxvY2tEZWx0YQ06Y29udGVudC10eXBlBwAQYXBwbGljYXRpb24vanNvbg06bWVzc2FnZS10eXBlBwAFZXZlbnR7ImNvbnRlbnRCbG9ja0luZGV4IjowLCJkZWx0YSI6eyJ0ZXh0IjoiSGVsbG8ifSwicCI6ImFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6QUJDRCJ91Ic6iwAAALQAAABWAm2FfAs6ZXZlbnQtdHlwZQcAEGNvbnRlbnRCbG9ja1N0b3ANOmNvbnRlbnQtdHlwZQcAEGFwcGxpY2F0aW9uL2pzb24NOm1lc3NhZ2UtdHlwZQcABWV2ZW50eyJjb250ZW50QmxvY2tJbmRleCI6MCwicCI6ImFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6QUJDREVGR0hJSktMTU5PUFFSU1RVViJ9qr7BagAAAIYAAABRR+j7OQs6ZXZlbnQtdHlwZQcAC21lc3NhZ2VTdG9wDTpjb250ZW50LXR5cGUHABBhcHBsaWNhdGlvbi9qc29uDTptZXNzYWdlLXR5cGUHAAVldmVudHsicCI6ImFiY2RlIiwic3RvcFJlYXNvbiI6ImVuZF90dXJuIn0pJ2GhAAABCAAAAE4PaiuaCzpldmVudC10eXBlBwAIbWV0YWRhdGENOmNvbnRlbnQtdHlwZQcAEGFwcGxpY2F0aW9uL2pzb24NOm1lc3NhZ2UtdHlwZQcABWV2ZW50eyJtZXRyaWNzIjp7ImxhdGVuY3lNcyI6MTI0OX0sInAiOiJhYmNkZWZnaGlqa2xtbm9wcXJzdHV2d3h5ekFCQ0RFRkdISUpLTE1OT1BRUlNUVVZXWFkiLCJ1c2FnZSI6eyJpbnB1dFRva2VucyI6MjgsIm91dHB1dFRva2VucyI6NSwic2VydmVyVG9vbFVzYWdlIjp7fSwidG90YWxUb2tlbnMiOjMzfX2s2SiQ",
"bodyEncoding": "base64"
}
}
]
}
+66 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test"
import { isContextOverflow } from "../src/index.js"
import { classifyProviderFailure } from "../src/provider-error.js"
import { classifyProviderFailure, providerErrorMessage } from "../src/provider-error.js"
describe("provider error classification", () => {
test("classifies provider token limit messages as context overflow", () => {
@@ -355,6 +355,32 @@ describe("provider error rawBody classification", () => {
}
})
test("classifies Google invalid API keys as authentication failures", () => {
const rawBody = JSON.stringify({
error: {
code: 400,
message: "API key not valid. Please pass a valid API key.",
status: "INVALID_ARGUMENT",
details: [
{
"@type": "type.googleapis.com/google.rpc.ErrorInfo",
reason: "API_KEY_INVALID",
domain: "googleapis.com",
},
{
"@type": "type.googleapis.com/google.rpc.LocalizedMessage",
locale: "en-US",
message: "API key not valid. Please pass a valid API key.",
},
],
},
})
expect(
classifyProviderFailure({ message: "API key not valid. Please pass a valid API key.", status: 400, rawBody })
._tag,
).toBe("Authentication")
})
test("classifies overflow signals buried in the raw payload when the summary is vague", () => {
const reason = classifyProviderFailure({
message: "Request failed",
@@ -370,3 +396,42 @@ describe("provider error rawBody classification", () => {
).toBe("QuotaExceeded")
})
})
describe("provider error messages", () => {
test("reads messages from common error body layouts", () => {
expect(
[
'{"error":{"message":"Invalid API Key","type":"invalid_request_error"}}',
'{"code":"invalid-argument","error":"Incorrect API key provided."}',
'{"message":"1 validation error detected"}',
'{"Message":"Invalid API Key format: Must start with pre-defined prefix"}',
'{"type":"about:blank","title":"Gone","status":410,"detail":"The model has reached its end of life"}',
'{"result":null,"success":false,"errors":[{"code":10000,"message":"Authentication error"}]}',
].map(providerErrorMessage),
).toEqual([
"Invalid API Key",
"Incorrect API key provided.",
"1 validation error detected",
"Invalid API Key format: Must start with pre-defined prefix",
"The model has reached its end of life",
"Authentication error",
])
})
test("prefers the nested error message over a top-level message", () => {
expect(providerErrorMessage('{"message":"Bad Request","error":{"message":"model not found"}}')).toBe(
"model not found",
)
})
test("ignores blank, non-string, and non-JSON messages", () => {
expect(
[
'{"error":{"message":" "}}',
'{"message":{"detail":[{"msg":"too high"}]}}',
'{"errors":[]}',
"invalid parameter",
].map(providerErrorMessage),
).toEqual([undefined, undefined, undefined, undefined])
})
})
+4 -1
View File
@@ -300,7 +300,9 @@ describe("provider package entrypoints", () => {
})
expect(String(selected.provider)).toBe("example")
expect(selected.route.id).toBe("anthropic-messages")
expect(selected.route.id).toBe("anthropic-compatible-messages")
expect(selected.route.protocol).toBe("anthropic-messages")
expect(selected.route.providerMetadataKey).toBe("example")
expect(selected.route.endpoint).toMatchObject({
baseURL: "https://messages.example.test/v1",
})
@@ -319,6 +321,7 @@ describe("provider package entrypoints", () => {
thinking: { type: "adaptive" },
})
expect(selected.route.id).toBe("anthropic-messages")
expect(selected.route.defaults.providerOptions).toEqual({ thinking: { type: "adaptive" } })
})
@@ -0,0 +1,103 @@
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { LLM, LLMRequest, Message } from "../../src/index.js"
import { LLMClient } from "../../src/route.js"
import { AmazonBedrock } from "../../src/providers.js"
import { recordedTests } from "../recorded-test.js"
const RECORDING_REGION = process.env.BEDROCK_RECORDING_REGION ?? "us-east-1"
// Claude Opus 5.5 enforces the conversation-prefix check on Bedrock, so a changed system prompt is a real
// mismatch. Override with BEDROCK_BINDING_MODEL_ID for another model that enforces it.
const modelID = process.env.BEDROCK_BINDING_MODEL_ID ?? "global.anthropic.claude-opus-5-5"
// Mirrors the "high" variant, so the recording covers the real overlay and default merge.
const model = (blockBinding?: { readonly prefix_mismatch_behavior: string }) =>
AmazonBedrock.model(modelID, {
apiKey: process.env.AWS_BEARER_TOKEN_BEDROCK ?? "fixture",
region: RECORDING_REGION,
body: {
additionalModelRequestFields: {
thinking: { type: "adaptive", display: "summarized", ...(blockBinding ? { block_binding: blockBinding } : {}) },
output_config: { effort: "high" },
},
},
})
const question = "How many positive integers below 5000 have exactly 12 positive divisors? Reply with the number only."
const first = LLM.request({
id: "recorded_bedrock_thinking_binding_first",
model: model(),
system: "You are a concise assistant.",
prompt: question,
cache: "none",
generation: { maxTokens: 12_000 },
})
const recorded = recordedTests({
prefix: "bedrock-converse-thinking-binding",
provider: "amazon-bedrock",
protocol: "bedrock-converse",
requires: ["AWS_BEARER_TOKEN_BEDROCK"],
})
describe("Bedrock Converse thinking binding recorded", () => {
recorded.effect.with("accepts the default binding with no thinking configured", { tags: ["reasoning"] }, () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(
LLM.request({
id: "recorded_bedrock_thinking_binding_default",
model: AmazonBedrock.model(modelID, {
apiKey: process.env.AWS_BEARER_TOKEN_BEDROCK ?? "fixture",
region: RECORDING_REGION,
}),
system: "Reply with the single word 'Hello'.",
prompt: "Say hello.",
cache: "none",
generation: { maxTokens: 1_024 },
}),
)
expect(response.finishReason?.normalized).toBe("stop")
expect(response.text).toMatch(/hello/i)
}),
)
recorded.effect.with(
"keeps a session working after the system prompt changes",
{ tags: ["reasoning"] },
() =>
Effect.gen(function* () {
const turn = yield* LLMClient.generate(first)
// The signed thinking block is what Bedrock binds to the first system prompt.
expect(turn.message.content.some((part) => part.type === "reasoning")).toBe(true)
// Replay the assistant turn under a different system prompt, as a prompt rebuild or compaction would.
const next = LLMRequest.update(first, {
id: "recorded_bedrock_thinking_binding_next",
system: [{ type: "text", text: "You are a concise assistant. Today is Monday." }],
messages: [
...first.messages,
Message.assistant(turn.message.content),
Message.user("Now double it. Reply with the number only."),
],
})
const bound = yield* LLMClient.generate(next)
expect(bound.finishReason?.normalized).toBe("stop")
expect(bound.text).toMatch(/\d/)
// The same replay is rejected when the caller asks for the default `error` behavior, proving the check is
// enforced and that the default `drop_block` is what let the request above through.
const rejected = yield* LLMClient.generate(
LLMRequest.update(next, {
id: "recorded_bedrock_thinking_binding_error",
model: model({ prefix_mismatch_behavior: "error" }),
}),
).pipe(Effect.flip)
expect(rejected.message).toContain("bound to a different conversation")
}),
60_000,
)
})
@@ -0,0 +1,100 @@
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { GenerationOptions, LanguageModel, LLM } from "../../src/index.js"
import { compileRequest } from "../../src/route/client.js"
import { AmazonBedrock } from "../../src/providers.js"
import { it } from "../lib/effect.js"
const binding = { prefix_mismatch_behavior: "drop_block" }
const beta = ["thinking-binding-controls-2026-08-01"]
const bedrock = (id: string, settings: Parameters<typeof AmazonBedrock.model>[1] = {}) =>
AmazonBedrock.model(id, { baseURL: "https://bedrock-runtime.test", apiKey: "test-bearer", ...settings })
const fields = (model: LanguageModel, generation?: GenerationOptions) =>
compileRequest(
LLM.request({ id: "req_1", model, system: "You are concise.", prompt: "Say hello.", cache: "none", generation }),
).pipe(Effect.map((prepared) => prepared.body.additionalModelRequestFields))
describe("Bedrock Converse thinking block binding", () => {
for (const id of [
"global.anthropic.claude-fable-5-1",
"us.anthropic.claude-fable-5-1-v1:0",
"anthropic.claude-fable-5.1",
"anthropic.claude-mythos-5-1",
"us.anthropic.claude-opus-5-5",
"global.anthropic.claude-sonnet-5-5",
"anthropic.claude-opus-6",
]) {
it.effect(`defaults adaptive thinking to drop_block with the beta for ${id}`, () =>
Effect.gen(function* () {
expect(yield* fields(bedrock(id))).toEqual({
thinking: { type: "adaptive", block_binding: binding },
anthropic_beta: beta,
})
}),
)
}
for (const id of [
"global.anthropic.claude-opus-5",
"us.anthropic.claude-sonnet-5",
"global.anthropic.claude-fable-5",
"anthropic.claude-fable-5-v1:0",
"global.anthropic.claude-opus-4-8",
"us.anthropic.claude-opus-4-5-20251101-v1:0",
"us.anthropic.claude-haiku-4-5-20251001-v1:0",
"anthropic.claude-3-5-sonnet-20241022-v2:0",
"us.amazon.nova-2-lite-v1:0",
]) {
it.effect(`leaves ${id} untouched`, () =>
Effect.gen(function* () {
expect(yield* fields(bedrock(id))).toBeUndefined()
}),
)
}
it.effect("binds a manual thinking budget and keeps top_k", () =>
Effect.gen(function* () {
expect(
yield* fields(
bedrock("global.anthropic.claude-fable-5-1", { thinking: { type: "enabled", budgetTokens: 4_000 } }),
GenerationOptions.make({ maxTokens: 64_000, topK: 40 }),
),
).toEqual({
top_k: 40,
thinking: { type: "enabled", budget_tokens: 4_000, block_binding: binding },
anthropic_beta: beta,
})
}),
)
it.effect("does not bind disabled thinking", () =>
Effect.gen(function* () {
expect(
yield* fields(
bedrock("global.anthropic.claude-fable-5-1", {
body: { additionalModelRequestFields: { thinking: { type: "disabled" } } },
}),
),
).toBeUndefined()
}),
)
it.effect("honors the compatibility override in both directions", () =>
Effect.gen(function* () {
const arn = "arn:aws:bedrock:us-east-1:123456789012:application-inference-profile/abc123"
expect(yield* fields(bedrock(arn))).toBeUndefined()
expect(
yield* fields(LanguageModel.update(bedrock(arn), { compatibility: { supportsThinkingBlockBinding: true } })),
).toEqual({ thinking: { type: "adaptive", block_binding: binding }, anthropic_beta: beta })
expect(
yield* fields(
LanguageModel.update(bedrock("global.anthropic.claude-fable-5-1"), {
compatibility: { supportsThinkingBlockBinding: false },
}),
),
).toBeUndefined()
}),
)
})
@@ -1301,11 +1301,11 @@ describe("Bedrock Converse route", () => {
),
),
)
expect(response.events.filter((event) => event.type === "reasoning-delta" && event.text === "").at(-1)).toEqual({
type: "reasoning-delta",
expect(response.events.filter((event) => event.type === "reasoning-delta")).toEqual([])
expect(response.events.find((event) => event.type === "reasoning-start")).toEqual({
type: "reasoning-start",
id: "reasoning-0",
text: "",
providerMetadata: { bedrock: { redactedData } },
providerMetadata: undefined,
})
expect(response.events.find((event) => event.type === "reasoning-end")).toEqual({
type: "reasoning-end",
@@ -1383,6 +1383,13 @@ describe("Bedrock Converse route", () => {
),
)
expect(response.events.filter((event) => event.type === "reasoning-delta")).toEqual([])
expect(response.events.find((event) => event.type === "reasoning-end")).toEqual({
type: "reasoning-end",
id: "reasoning-0",
providerMetadata: { bedrock: { redactedData: "AQID" } },
text: undefined,
})
expect(response.message.content).toEqual([
{ type: "reasoning", text: "", providerMetadata: { bedrock: { redactedData: "AQID" } } },
])
@@ -56,7 +56,9 @@ describe("xAI Responses route", () => {
expect(XAIResponses.protocol.body).not.toBe(OpenAIResponses.protocol.body)
const prepared = yield* compileRequest(LLM.request({ model, prompt: "Hello" }))
expect(prepared.route).toBe("xai-responses")
expect(prepared.protocol).toBe("xai-responses")
expect(prepared.model.route.providerMetadataKey).toBe("xai")
expect(prepared.body.store).toBe(false)
expect(prepared.body.include).toEqual(["reasoning.encrypted_content"])
}),
@@ -298,3 +300,14 @@ describe("xAI Responses route", () => {
}),
)
})
it.effect("names the xAI Chat route separately from its OpenAI Chat protocol", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({ model: XAI.configure({ apiKey: "test" }).chat("grok-4.6"), prompt: "Hello" }),
)
expect(prepared.route).toBe("xai-chat")
expect(prepared.protocol).toBe("openai-chat")
expect(prepared.model.route.providerMetadataKey).toBe("xai")
}),
)
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/app",
"version": "2.0.18",
"version": "2.0.19",
"description": "",
"type": "module",
"exports": {
@@ -415,6 +415,7 @@ export function SessionFileView(props: SessionFileViewProps) {
}}
enableLineSelection
enableGutterUtility
textSelectionAction={{ label: language.t("ui.lineComment.add") }}
selectedLines={activeSelection()}
commentedLines={commentedLines()}
onRendered={() => {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/cli",
"version": "2.0.18",
"version": "2.0.19",
"type": "module",
"license": "MIT",
"bin": {
@@ -309,6 +309,7 @@ async function capture(input: Parameters<typeof run>[0]) {
afterEach(() => {
mock.restore()
process.exitCode = 0
})
describe("runNonInteractivePrompt", () => {
@@ -433,6 +434,7 @@ describe("runNonInteractivePrompt", () => {
expect(sdk.form.list).toHaveBeenCalledWith({
location: { directory: "/work tree" },
})
expect(process.exitCode).toBe(1)
})
test("attach mode cancels only session-owned forms", async () => {
@@ -448,6 +450,7 @@ describe("runNonInteractivePrompt", () => {
{ sessionID: "global", formID: "frm_pending_global" },
expect.anything(),
)
expect(process.exitCode).toBe(1)
})
test("V1 JSON output flushes step_start before an unrelated step failure", async () => {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/client",
"version": "2.0.18",
"version": "2.0.19",
"type": "module",
"license": "MIT",
"repository": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/codemode",
"version": "2.0.18",
"version": "2.0.19",
"description": "Effect-native confined code execution over schema-described tools",
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/console-app",
"version": "2.0.18",
"version": "2.0.19",
"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.18",
"version": "2.0.19",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/console-function",
"version": "2.0.18",
"version": "2.0.19",
"$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.18",
"version": "2.0.19",
"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.18",
"version": "2.0.19",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "2.0.18",
"version": "2.0.19",
"name": "@opencode/core",
"type": "module",
"license": "MIT",
+11 -13
View File
@@ -2,13 +2,12 @@ export * as InstructionBuiltIns from "./builtins.js"
import { makeLocationNode } from "@opencode/util/effect/app-node"
import { Context, DateTime, Effect, Layer, Schema } from "effect"
import type { Session } from "@opencode/schema/session"
import { Global } from "@opencode/util/global"
import { Location } from "../location.js"
import { Instructions } from "./index.js"
export interface Interface {
readonly load: (sessionID: Session.ID) => Effect.Effect<Instructions.List>
readonly load: () => Effect.Effect<Instructions.List>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/InstructionBuiltIns") {}
@@ -19,16 +18,24 @@ const layer = Layer.effect(
const global = yield* Global.Service
const location = yield* Location.Service
return Service.of({
load: (sessionID) =>
load: () =>
Effect.succeed(
Instructions.combine([
Instructions.make({
key: Instructions.Key.make("core/date"),
codec: Schema.toCodecJson(Schema.String),
read: DateTime.nowAsDate.pipe(Effect.map((date) => date.toDateString())),
render: {
initial: (date) => `Today's date: ${date}`,
changed: (_previous, date) => `Today's date is now: ${date}`,
},
}),
Instructions.make({
key: Instructions.Key.make("core/environment"),
codec: Schema.toCodecJson(Schema.String),
read: Effect.sync(() =>
[
"<env>",
` Current conversation session ID: ${sessionID}`,
` Working directory: ${location.directory}`,
` Workspace root folder: ${location.project.directory}`,
` Is directory a git repo: ${location.vcs?.type === "git" ? "yes" : "no"}`,
@@ -44,15 +51,6 @@ const layer = Layer.effect(
["The environment you are running in is now:", environment].join("\n"),
},
}),
Instructions.make({
key: Instructions.Key.make("core/date"),
codec: Schema.toCodecJson(Schema.String),
read: DateTime.nowAsDate.pipe(Effect.map((date) => date.toDateString())),
render: {
initial: (date) => `Today's date: ${date}`,
changed: (_previous, date) => `Today's date is now: ${date}`,
},
}),
]),
),
})
@@ -1,55 +1,62 @@
import { Effect } from "effect"
import { isArrayNonEmpty } from "effect/Array"
import { define } from "@opencode/plugin/effect/plugin"
import { Form } from "@opencode/schema/form"
import { Provider } from "../../provider.js"
import { iife } from "../../util/iife.js"
import { configuredSettings } from "./configured.js"
const providerID = Provider.ID.make("cloudflare-ai-gateway")
const accountIdField = Form.StringField.make({
type: "string",
key: "accountId",
title: "Enter your Cloudflare Account ID",
placeholder: "e.g. 1234567890abcdef1234567890abcdef",
required: true,
})
const gatewayIdField = Form.StringField.make({
type: "string",
key: "gatewayId",
title: "Enter your Cloudflare AI Gateway ID",
placeholder: "e.g. my-gateway",
required: true,
})
export const CloudflareAIGatewayPlugin = define({
id: "opencode.provider.cloudflare.ai.gateway",
effect: Effect.fn(function* (ctx) {
const accountId = process.env.CLOUDFLARE_ACCOUNT_ID
const gatewayId = process.env.CLOUDFLARE_GATEWAY_ID
const configured = yield* configuredSettings(providerID)
const form = iife(() => {
if (typeof configured?.baseURL === "string") return
const accountId = process.env.CLOUDFLARE_ACCOUNT_ID || stringOption(configured ?? {}, "accountId")
const gatewayId =
process.env.CLOUDFLARE_GATEWAY_ID ||
stringOption(configured ?? {}, "gatewayId") ||
stringOption(configured ?? {}, "gateway")
if (accountId && gatewayId) return
const accountIdForm = Form.StringField.make({
type: "string",
key: "accountId",
title: "Enter your Cloudflare Account ID",
placeholder: "e.g. 1234567890abcdef1234567890abcdef",
required: true,
})
const gatewayIdForm = Form.StringField.make({
type: "string",
key: "gatewayId",
title: "Enter your Cloudflare AI Gateway ID",
placeholder: "e.g. my-gateway",
required: true,
})
if (accountId) return Form.Fields.make([gatewayIdForm])
if (gatewayId) return Form.Fields.make([accountIdForm])
return Form.Fields.make([accountIdForm, gatewayIdForm])
})
const fields =
typeof configured?.baseURL === "string"
? []
: [
...(accountId || typeof configured?.accountId === "string" ? [] : [accountIdField]),
...(gatewayId || typeof configured?.gatewayId === "string" ? [] : [gatewayIdField]),
]
yield* ctx.integration.transform((editor) => {
editor.method.update({
integrationID: providerID,
method: {
type: "key",
label: "Gateway API token",
form,
form: isArrayNonEmpty(fields) ? Form.Fields.make(fields) : undefined,
},
})
})
yield* ctx.provider.transform((evt) => {
const item = evt.get(providerID)
if (!item || (!accountId && !gatewayId)) return
evt.update(item.provider.id, (provider) => {
if (typeof provider.settings?.baseURL === "string") return
provider.settings = {
...(accountId ? { accountId } : {}),
...(gatewayId ? { gatewayId } : {}),
...provider.settings,
}
})
})
}),
})
function stringOption(options: Record<string, unknown>, key: string) {
return typeof options[key] === "string" ? options[key] : undefined
}
@@ -50,7 +50,7 @@ export const CloudflareWorkersAIPlugin = define({
})
function resolveAccountId(options: Record<string, unknown>) {
return process.env.CLOUDFLARE_ACCOUNT_ID ?? stringOption(options, "accountId")
return stringOption(options, "accountId") ?? process.env.CLOUDFLARE_ACCOUNT_ID
}
function workersEndpoint(accountId: string) {
@@ -11,6 +11,7 @@ import { Model } from "../../model.js"
import { Agent } from "../../agent.js"
import { define } from "@opencode/plugin/effect/plugin"
import { Provider } from "../../provider.js"
import { SessionAffinity } from "../../session/affinity.js"
import type { PluginInternal } from "../internal.js"
const clientID = "Ov23li8tweQw6odWQebz"
@@ -272,7 +273,7 @@ export const GithubCopilotPlugin = define({
.pipe(Effect.orElseSucceed(() => undefined))
const interaction = interactionType(evt.kind, session?.parentID !== undefined)
evt.headers["X-Interaction-Type"] = interaction
evt.headers["X-Interaction-Id"] = evt.sessionID
evt.headers["X-Interaction-Id"] = session ? SessionAffinity.get(session) : evt.sessionID
if (interaction !== "conversation-agent") evt.headers["x-initiator"] = "agent"
}),
{ providerID: Provider.ID.githubCopilot },
+7 -2
View File
@@ -9,6 +9,7 @@ import { Bus } from "../../bus.js"
import { Integration } from "../../integration.js"
import { OauthCallbackPage } from "../../oauth/page.js"
import { Provider } from "../../provider.js"
import { SessionAffinity } from "../../session/affinity.js"
import type { PluginInternal } from "../internal.js"
const clientID = "app_EMoamEEZ73f0CkXaXp7hrann"
@@ -299,12 +300,16 @@ export const OpenAIPlugin = define({
yield* ctx.session.hook(
"model.request",
(evt) =>
Effect.sync(() => {
Effect.gen(function* () {
if (!chatgpt) return
if (evt.baseURL && URL.canParse(evt.baseURL) && new URL(evt.baseURL).origin === "https://api.openai.com")
evt.baseURL = codexBaseURL
const session = yield* ctx.session
.get({ sessionID: evt.sessionID })
.pipe(Effect.orElseSucceed(() => undefined))
evt.headers.originator = "opencode"
evt.headers["session-id"] = evt.sessionID
// ChatGPT routes its prompt cache on this header, so children share the parent's.
evt.headers["session-id"] = session ? SessionAffinity.get(session) : evt.sessionID
}),
{ providerID: Provider.ID.openai },
)
+8
View File
@@ -0,0 +1,8 @@
export * as SessionAffinity from "./affinity.js"
import type { SessionSchema } from "./schema.js"
// TODO: Should the `model.request` hook expose affinity so plugins stop deriving it from `ctx.session.get`?
/** The Session ID that groups model requests for provider cache routing: children share the parent's, forks the fork source's. */
export const get = (session: Pick<SessionSchema.Info, "id" | "parentID" | "fork">) =>
session.parentID ?? session.fork?.sessionID ?? session.id
+8 -5
View File
@@ -132,7 +132,7 @@ const layer = Layer.effect(
const loaded = yield* Effect.all(
{
tools: registry.snapshot(permissions),
builtins: builtins.load(sessionID),
builtins: builtins.load(),
discovery: discovery.load(),
skills: skillInstructions.load(permissions),
references: referenceInstructions.load(),
@@ -144,13 +144,16 @@ const layer = Layer.effect(
return {
session,
agent: { ...agent, info: agent.info },
// Ordered from most to least shared across sessions so the baseline stays a reusable
// prompt-cache prefix: user-level catalog and guidance, then project instructions, then
// the date and environment, which vary by day and directory.
instructions: Instructions.combine([
loaded.builtins,
CodeModeInstructions.make(loaded.tools.codeModeCatalog),
loaded.discovery,
loaded.skills,
loaded.references,
loaded.mcp,
loaded.references,
loaded.skills,
loaded.discovery,
loaded.builtins,
loaded.entries,
]),
tools: loaded.tools,
+5 -2
View File
@@ -31,6 +31,7 @@ import { Permission } from "../permission.js"
import { PluginHooks } from "../plugin/hooks.js"
import { QuestionTool } from "../tool/plugin/question.js"
import { Tool } from "../tool.js"
import { SessionAffinity } from "./affinity.js"
import { SessionModelTransport } from "./model-transport.js"
import { SessionProviderContext } from "./provider-context.js"
import { SessionRunnerModel } from "./runner/model.js"
@@ -46,6 +47,8 @@ const IMAGE_REMOVED =
const GENERATION_KEYS = new Set(Object.keys(GenerationOptions.fields))
// Used when the catalog has no output limit for the model.
const OUTPUT_TOKEN_FALLBACK = 32_000
// No reply needs more, however much the model allows.
const OUTPUT_TOKEN_MAX = 256_000
// A summary never needs more, and a request asking for more cannot be shrunk to fit a window the catalog overstates.
const SUMMARY_OUTPUT_MAX = 32_000
// Prompt text is estimated at about 4 characters per token, which can run low on dense text such as code.
@@ -87,7 +90,7 @@ const outputLimit = (
kind: "primary" | "compaction",
inputTokens?: Input["inputTokens"],
) => {
const model = limit.output > 0 ? limit.output : OUTPUT_TOKEN_FALLBACK
const model = Math.min(limit.output > 0 ? limit.output : OUTPUT_TOKEN_FALLBACK, OUTPUT_TOKEN_MAX)
const requested = kind === "compaction" ? Math.min(model, SUMMARY_OUTPUT_MAX) : model
if (inputTokens === undefined || limit.context <= 0) return requested
const room = limit.context - inputTokens.measured - Math.ceil(inputTokens.estimated * (1 + ESTIMATE_ERROR))
@@ -268,7 +271,7 @@ export const layer = Layer.effect(
const entries = Object.entries(shaped.options)
const generation = Object.fromEntries(entries.filter(([k]) => GENERATION_KEYS.has(k))) as GenerationOptionsFields
const providerOptions = Object.fromEntries(entries.filter(([k]) => !GENERATION_KEYS.has(k)))
const affinity = session.parentID ?? session.fork?.sessionID ?? session.id
const affinity = SessionAffinity.get(session)
const base = LLM.request({
model: model.model,
http: {
+4
View File
@@ -207,6 +207,10 @@ export const Plugin = {
},
(invocation) =>
Effect.gen(function* () {
invocation.env.AGENT = "1"
invocation.env.OPENCODE = "1"
invocation.env.AI_AGENT ||= "opencode"
invocation.env.OPENCODE_SESSION_ID = context.sessionID
finalTimeout = yield* prepare(invocation, context)
}),
)
+6 -1
View File
@@ -88,6 +88,11 @@ const responsesEffort = (effort: string): Overlay => ({
settings: { reasoningEffort: effort, reasoningSummary: "auto", include: ENCRYPTED_REASONING },
})
const xaiResponses: Protocol = (_, support) => {
if (support.type !== "effort") return []
return efforts(support.values ?? EFFORTS, responsesEffort)
}
const cloudflareAIGateway: Protocol = (model, support) => {
const id = modelID(model)
if (id.startsWith("openai/")) return openaiResponses(model, support)
@@ -557,7 +562,7 @@ const PROTOCOLS: Readonly<Record<string, Protocol>> = {
"@opencode/ai/providers/mistral": openaiChat,
"@opencode/ai/providers/moonshot/chat": moonshotChat,
"@opencode/ai/providers/togetherai": openaiChat,
"@opencode/ai/providers/xai": openaiChat,
"@opencode/ai/providers/xai": xaiResponses,
"@opencode/ai/providers/zai/chat": zaiChat,
"@opencode/ai/providers/zai-coding-plan/chat": zaiChat,
@@ -8,7 +8,6 @@ import { FSUtil } from "@opencode/util/fs-util"
import { Global } from "@opencode/util/global"
import { AbsolutePath } from "@opencode/core/schema"
import { InstructionBuiltIns } from "@opencode/core/instructions/builtins"
import { SessionSchema } from "@opencode/core/session/schema"
import { location } from "../fixture/location"
import { testEffect } from "../lib/effect"
import { readInitial, readUpdate } from "../lib/instructions"
@@ -16,7 +15,6 @@ import { readInitial, readUpdate } from "../lib/instructions"
const directory = AbsolutePath.make(FSUtil.resolve("/repo/packages/core"))
const projectDirectory = AbsolutePath.make(FSUtil.resolve("/repo"))
const timestamp = Date.parse("2026-06-03T12:00:00.000Z")
const sessionID = SessionSchema.ID.make("ses_builtin_test")
const temporary = os.tmpdir()
const localDate = (time: number) => new Date(time).toDateString()
const locationLayer = Layer.succeed(
@@ -40,21 +38,20 @@ describe("InstructionBuiltIns", () => {
Effect.gen(function* () {
yield* TestClock.setTime(timestamp)
const context = yield* InstructionBuiltIns.Service
const initialized = yield* readInitial(yield* context.load(sessionID))
const initialized = yield* readInitial(yield* context.load())
expect(initialized.text).toBe(
[
`Today's date: ${localDate(timestamp)}`,
"",
"Here is some useful information about the environment you are running in:",
"<env>",
` Current conversation session ID: ${sessionID}`,
` Working directory: ${directory}`,
` Workspace root folder: ${projectDirectory}`,
" Is directory a git repo: yes",
` Platform: ${process.platform}`,
` Prefer ${temporary} over generic system temporary directories such as /tmp; it is pre-created and approved for external access.`,
"</env>",
"",
`Today's date: ${localDate(timestamp)}`,
].join("\n"),
)
}),
@@ -64,10 +61,10 @@ describe("InstructionBuiltIns", () => {
Effect.gen(function* () {
yield* TestClock.setTime(timestamp)
const context = yield* InstructionBuiltIns.Service
const initialized = yield* readInitial(yield* context.load(sessionID))
const initialized = yield* readInitial(yield* context.load())
yield* TestClock.setTime(timestamp + 24 * 60 * 60 * 1000)
const refreshed = yield* readUpdate(yield* context.load(sessionID), initialized)
const refreshed = yield* readUpdate(yield* context.load(), initialized)
expect(refreshed.text).toBe(`Today's date is now: ${localDate(timestamp + 24 * 60 * 60 * 1000)}`)
}),
@@ -77,10 +74,10 @@ describe("InstructionBuiltIns", () => {
Effect.gen(function* () {
yield* TestClock.setTime(timestamp)
const context = yield* InstructionBuiltIns.Service
const initialized = yield* readInitial(yield* context.load(sessionID))
const initialized = yield* readInitial(yield* context.load())
yield* TestClock.setTime(timestamp + 60 * 60 * 1000)
expect((yield* readUpdate(yield* context.load(sessionID), initialized)).changed).toBe(false)
expect((yield* readUpdate(yield* context.load(), initialized)).changed).toBe(false)
}),
)
})
+25 -1
View File
@@ -9,6 +9,7 @@ import { Credential } from "@opencode/core/credential"
import { Integration } from "@opencode/core/integration"
import { Compatibility, ID, Info, Model, VariantID } from "@opencode/core/model"
import { Provider } from "@opencode/core/provider"
import { Variant } from "@opencode/core/variant"
import { ModelResolver } from "@opencode/core/model-resolver"
import { AISDK } from "@opencode/core/aisdk"
import { Npm } from "@opencode/util/npm"
@@ -580,6 +581,29 @@ describe("ModelResolver", () => {
}),
)
it.effect("sends xAI Responses effort variants with reasoning summaries", () =>
Effect.gen(function* () {
const base = model("@opencode/ai/providers/xai", { providerID: Provider.ID.make("xai"), modelID: "grok-4.6" })
const catalog = Info.make({
...base,
variants: Variant.resolve(base, [{ type: "effort", values: ["low", "high"] }]),
})
const resolved = yield* ModelResolver.resolveModel(
catalog,
VariantID.make("high"),
Credential.Key.make({ type: "key", key: "secret" }),
)
const prepared = yield* compileRequest(LLM.request({ model: resolved, prompt: "Hello" }))
expect(resolved.route.id).toBe("xai-responses")
expect(prepared.body).toMatchObject({
reasoning: { effort: "high", summary: "auto" },
include: ["reasoning.encrypted_content"],
})
expect(prepared.body).not.toHaveProperty("reasoning_effort")
}),
)
it.effect("overlays selected OpenAI-compatible variant bodies", () =>
Effect.gen(function* () {
const catalog = model(Provider.aisdk("@ai-sdk/openai-compatible"), {
@@ -1282,7 +1306,7 @@ describe("ModelResolver", () => {
reasoningEffort: "high",
})
expect(String(mistral.provider)).toBe("test-provider")
expect(xai.route.id).toBe("openai-responses")
expect(xai.route.id).toBe("xai-responses")
expect(xai.route.defaults.providerOptions).toEqual({
reasoningEffort: "high",
store: false,
+1 -1
View File
@@ -1059,7 +1059,7 @@ describe("ModelsDevPlugin", () => {
expect(grok?.variants).toEqual(
["low", "medium", "high"].map((id) => ({
id: Model.VariantID.make(id),
settings: { reasoningEffort: id },
settings: { reasoningEffort: id, reasoningSummary: "auto", include: ["reasoning.encrypted_content"] },
})),
)
@@ -4,11 +4,19 @@ import { Integration } from "@opencode/core/integration"
import { Plugin } from "@opencode/core/plugin"
import { PluginHost } from "@opencode/core/plugin/host"
import { CloudflareAIGatewayPlugin } from "@opencode/core/plugin/provider/cloudflare-ai-gateway"
import { Provider } from "@opencode/core/provider"
import { withEnv } from "../fixture/env"
import { testEffect } from "../lib/effect"
import { PluginTestLayer } from "./fixture"
const it = testEffect(PluginTestLayer)
const providerID = Provider.ID.make("cloudflare-ai-gateway")
const seed = Effect.fn(function* (fn: (provider: Provider.MutableInfo) => void) {
const catalog = yield* Provider.Service
yield* catalog.transform((catalog) => catalog.update(providerID, fn))
return catalog
})
const addPlugin = Effect.fn(function* () {
const plugin = yield* Plugin.Service
@@ -46,4 +54,55 @@ describe("CloudflareAIGatewayPlugin", () => {
}),
),
)
it.effect("requests only the gateway ID when the environment provides the account ID", () =>
withEnv({ CLOUDFLARE_ACCOUNT_ID: "account", CLOUDFLARE_GATEWAY_ID: undefined }, () =>
Effect.gen(function* () {
yield* addPlugin()
const integrations = yield* Integration.Service
expect((yield* integrations.get(Integration.ID.make("cloudflare-ai-gateway")))?.methods).toContainEqual({
type: "key",
label: "Gateway API token",
form: [expect.objectContaining({ type: "string", key: "gatewayId", required: true })],
})
}),
),
)
it.effect("gives the provider its environment account and gateway IDs", () =>
withEnv({ CLOUDFLARE_ACCOUNT_ID: "account", CLOUDFLARE_GATEWAY_ID: "gateway" }, () =>
Effect.gen(function* () {
const catalog = yield* seed(() => {})
yield* addPlugin()
expect((yield* catalog.get(providerID))?.settings).toEqual({ accountId: "account", gatewayId: "gateway" })
}),
),
)
it.effect("prefers configured IDs over environment IDs", () =>
withEnv({ CLOUDFLARE_ACCOUNT_ID: "env-account", CLOUDFLARE_GATEWAY_ID: "env-gateway" }, () =>
Effect.gen(function* () {
const catalog = yield* seed((provider) => {
provider.settings = { accountId: "configured-account", gatewayId: "configured-gateway" }
})
yield* addPlugin()
expect((yield* catalog.get(providerID))?.settings).toEqual({
accountId: "configured-account",
gatewayId: "configured-gateway",
})
}),
),
)
it.effect("leaves a configured endpoint URL untouched", () =>
withEnv({ CLOUDFLARE_ACCOUNT_ID: "account", CLOUDFLARE_GATEWAY_ID: "gateway" }, () =>
Effect.gen(function* () {
const catalog = yield* seed((provider) => {
provider.settings = { baseURL: "https://proxy.example/v1" }
})
yield* addPlugin()
expect((yield* catalog.get(providerID))?.settings).toEqual({ baseURL: "https://proxy.example/v1" })
}),
),
)
})
@@ -119,7 +119,7 @@ describe("CloudflareWorkersAIPlugin", () => {
),
)
it.effect("uses env account ID over configured account ID", () =>
it.effect("uses configured account ID over env account ID", () =>
withEnv({ CLOUDFLARE_ACCOUNT_ID: "env-acct" }, () =>
Effect.gen(function* () {
const catalog = yield* seed((provider) => {
@@ -129,7 +129,7 @@ describe("CloudflareWorkersAIPlugin", () => {
yield* addPlugin()
expect(required(yield* catalog.get(providerID))).toMatchObject({
package: "aisdk:test-provider",
settings: { baseURL: "https://api.cloudflare.com/client/v4/accounts/env-acct/ai/v1" },
settings: { baseURL: "https://api.cloudflare.com/client/v4/accounts/configured-acct/ai/v1" },
})
}),
),
@@ -183,10 +183,11 @@ describe("GithubCopilotPlugin", () => {
it.effect("classifies child-session steps as subagent interactions", () =>
Effect.gen(function* () {
yield* addPlugin()
const event = yield* modelRequest((yield* sessions()).child, "primary")
const ids = yield* sessions()
const event = yield* modelRequest(ids.child, "primary")
expect(event.headers).toEqual({
"X-Interaction-Type": "conversation-subagent",
"X-Interaction-Id": event.sessionID,
"X-Interaction-Id": ids.parent,
"x-initiator": "agent",
})
}),
@@ -207,10 +208,11 @@ describe("GithubCopilotPlugin", () => {
it.effect("classifies compaction requests by kind rather than agent", () =>
Effect.gen(function* () {
yield* addPlugin()
const event = yield* modelRequest((yield* sessions()).child, "compaction", "build")
const ids = yield* sessions()
const event = yield* modelRequest(ids.child, "compaction", "build")
expect(event.headers).toEqual({
"X-Interaction-Type": "conversation-compaction",
"X-Interaction-Id": event.sessionID,
"X-Interaction-Id": ids.parent,
"x-initiator": "agent",
})
}),
@@ -1,6 +1,6 @@
import { Money } from "@opencode/schema/money"
import { Agent } from "@opencode/schema/agent"
import { Session } from "@opencode/schema/session"
import { Session } from "@opencode/core/session"
import { OpenAIResponses } from "@opencode/ai/protocols/openai-responses"
import { describe, expect } from "bun:test"
import { ConfigProvider, DateTime, Effect } from "effect"
@@ -41,10 +41,14 @@ function required<T>(value: T | undefined): T {
return value
}
const request = Effect.fn(function* (providerID: Provider.ID, baseURL: string) {
const request = Effect.fn(function* (
providerID: Provider.ID,
baseURL: string,
sessionID = Session.ID.make("ses_test"),
) {
const hooks = yield* PluginHooks.Service
const event = yield* hooks.trigger("session", "model.request", {
sessionID: Session.ID.make("ses_test"),
sessionID,
agent: Agent.ID.make("build"),
model: Model.Ref.make({ providerID, id: Model.ID.make("gpt-5.5") }),
kind: "primary",
@@ -156,6 +160,12 @@ describe("OpenAIPlugin", () => {
expect(custom.headers).not.toHaveProperty("originator")
expect(proxy.baseURL).toBe("https://proxy.example/v1?region=us")
expect(proxy.headers).toMatchObject({ originator: "opencode", "session-id": "ses_test" })
const sessions = yield* Session.Service
const location = yield* Location.Service
const parent = yield* sessions.create({ location: { directory: location.directory } })
const child = yield* sessions.create({ parentID: parent.id })
const childRequest = yield* request(Provider.ID.openai, "https://api.openai.com/v1", child.id)
expect(childRequest.headers).toMatchObject({ "session-id": parent.id })
const eligible = required(yield* models.get(Provider.ID.openai, Model.ID.make("gpt-5.5")))
expect(eligible.package).toBe("@opencode/ai/providers/openai")
expect(eligible.headers).toMatchObject({ originator: "opencode", "chatgpt-account-id": "acct_123" })
+1 -1
View File
@@ -236,7 +236,7 @@ const setup = Effect.gen(function* () {
db,
bus,
session,
instructions: yield* instructionBuiltIns.load(sessionID),
instructions: yield* instructionBuiltIns.load(),
instances: Instance.Service.of({
// Generation only exercises the Location's model context.
provide: () =>
+13 -4
View File
@@ -87,7 +87,7 @@ import { permissionLayer } from "./lib/permission"
import { agentHost, modelHost, host, noProviders } from "./plugin/host"
import { CodeModeInstructions } from "@opencode/core/codemode/instructions"
const emptyCodeMode = `\n\n${CodeModeInstructions.render({ total: 0, shown: 0, namespaces: [] })}`
const emptyCodeMode = `${CodeModeInstructions.render({ total: 0, shown: 0, namespaces: [] })}\n\n`
type ToolBarrier = {
readonly count: number
readonly started: Deferred.Deferred<void>
@@ -128,6 +128,7 @@ const undersizedContextModel = testModel("undersized-context", { context: 1, out
const recoveryModel = testModel("recovery", { context: 200_000, output: 1_000 })
const fittedOutputModel = testModel("fitted-output", { context: 100_000, output: 64_000 })
const smallWindowModel = testModel("small-window", { context: 64_000, output: 16_000 })
const largeOutputModel = testModel("large-output", { context: 1_000_000, output: 1_000_000 })
test("calculates step cost using the matching context tier", () => {
expect(
@@ -1874,8 +1875,8 @@ describe("SessionRunnerLLM", () => {
yield* s.runPrompt("Second")
expect(s.requests.map((request) => request.system.map((part) => part.text))).toEqual([
[defaultSystem, fakeIdentity, "Initial context\n\nBuild skills"],
[defaultSystem, fakeIdentity, "Initial context\n\nBuild skills"],
[defaultSystem, fakeIdentity, "Build skills\n\nInitial context"],
[defaultSystem, fakeIdentity, "Build skills\n\nInitial context"],
])
expect(systemTexts(s.requests[1])).toContainEqual(expect.stringContaining("Reviewer skills"))
})
@@ -1897,7 +1898,7 @@ describe("SessionRunnerLLM", () => {
yield* s.runPrompt("First")
expect(s.requests.map((request) => request.system.map((part) => part.text))).toEqual([
[defaultSystem, fakeIdentity, "Initial context\n\nBuild skills"],
[defaultSystem, fakeIdentity, "Build skills\n\nInitial context"],
])
})
@@ -3424,6 +3425,14 @@ describe("SessionRunnerLLM", () => {
expect(s.requests[1]?.generation?.maxTokens).toBeGreaterThan(100_000 - 50_000 - 200)
})
scenario("caps the output limit a large model advertises", function* (s) {
s.currentModel = largeOutputModel
yield* s.llm.push(TestLLM.text("Answer", "text-large-output"))
yield* s.runPrompt("Question")
expect(s.requests[0]?.generation?.maxTokens).toBe(256_000)
})
scenario("gives the summary its full output limit when the conversation overshot the threshold", function* (s) {
// The conversation overshot the threshold, so the prepared summary request exceeds the budget and `deliver`
// shrinks it before sending. The output limit must follow the budget, not the oversized prepared request.
+53
View File
@@ -956,6 +956,59 @@ describe("ShellTool", () => {
{ timeout: 15_000 },
)
productionIt.live(
"identifies agent shell commands without inheriting a stale session ID",
() =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) => {
reset()
return withSession(tmp.path, (registry) =>
Effect.gen(function* () {
const sessions = yield* Session.Service
yield* sessions.environment({
sessionID,
variables: { AGENT: "0", OPENCODE: "0", AI_AGENT: "", OPENCODE_SESSION_ID: "stale" },
})
const command = isWindows
? '[Console]::Out.Write("$env:AGENT|$env:OPENCODE|$env:AI_AGENT|$env:OPENCODE_SESSION_ID")'
: 'printf %s "$AGENT|$OPENCODE|$AI_AGENT|$OPENCODE_SESSION_ID"'
const settled = yield* executeTool(registry, call({ command }))
expect(settled.status).toBe("completed")
expect(settled.content?.[0]).toEqual({ type: "text", text: `1|1|opencode|${sessionID}` })
}),
)
},
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]().then(() => undefined)),
),
{ timeout: 15_000 },
)
productionIt.live(
"preserves an outer AI_AGENT marker in agent shell commands",
() =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) => {
reset()
return withSession(tmp.path, (registry) =>
Effect.gen(function* () {
const sessions = yield* Session.Service
yield* sessions.environment({ sessionID, variables: { AI_AGENT: "outer-agent" } })
const command = isWindows ? '[Console]::Out.Write($env:AI_AGENT)' : 'printf %s "$AI_AGENT"'
const settled = yield* executeTool(registry, call({ command }))
expect(settled.status).toBe("completed")
expect(settled.content?.[0]).toEqual({ type: "text", text: "outer-agent" })
}),
)
},
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]().then(() => undefined)),
),
{ timeout: 15_000 },
)
it.live("resolves a relative workdir from the active Location", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
+20
View File
@@ -126,6 +126,26 @@ test("spells Cloudflare AI Gateway variants for their upstream routes", () => {
])
})
test("spells xAI Responses variants with catalog effort levels", () => {
const supports: Variant.Support[] = [{ type: "effort", values: ["low", "medium", "high", "xhigh"] }]
expect(resolve(model("@opencode/ai/providers/xai", "grok-4.6"), supports)).toEqual(
["low", "medium", "high", "xhigh"].map((effort) => ({
id: effort,
settings: { reasoningEffort: effort, reasoningSummary: "auto", include: ["reasoning.encrypted_content"] },
})),
)
expect(resolve(model("@opencode/ai/providers/xai", "grok-4.3"), [{ type: "effort", values: ["none", "low"] }])).toEqual([
{
id: "none",
settings: { reasoningEffort: "none", reasoningSummary: "auto", include: ["reasoning.encrypted_content"] },
},
{ id: "low", settings: { reasoningEffort: "low", reasoningSummary: "auto", include: ["reasoning.encrypted_content"] } },
])
expect(resolve(model("@opencode/ai/providers/xai", "grok-4.6"), [{ type: "effort" }]).map((item) => item.id)).toEqual([
"low", "medium", "high",
])
})
test("spells Chat Completions variants for direct providers", () => {
expect(
resolve(model("@opencode/ai/providers/deepseek", "deepseek-v4-flash"), [
+2 -2
View File
@@ -1,7 +1,7 @@
{
"name": "@opencode/desktop",
"private": true,
"version": "2.0.18",
"version": "2.0.19",
"type": "module",
"license": "MIT",
"homepage": "https://opencode.ai",
@@ -53,7 +53,7 @@
"drizzle-kit": "catalog:",
"drizzle-orm": "catalog:",
"effect": "catalog:",
"electron": "44.4.3",
"electron": "44.4.5",
"electron-builder": "26.15.7",
"electron-vite": "6.0.0-beta.1",
"puppeteer-core": "25.9.0",
@@ -44,7 +44,14 @@ const connect = Effect.fn("BackgroundService.connect")(function* (mode: "initial
? path.join(app.getPath("userData"), "opencode", "service-local.json")
: undefined,
version,
command: [...cli.command, "serve", "--service", ...(isolated ? ["--hostname", "0.0.0.0", "--port", "0"] : [])],
// A fixed port makes a second contender fail to bind and back off; port 0 never collides, so two
// services could boot against the same database.
command: [
...cli.command,
"serve",
"--service",
...(isolated ? ["--hostname", "0.0.0.0", "--port", String(0x0c0c)] : []),
],
onStart: (reason, previousVersion) =>
runFork(Effect.logInfo("v2 CLI background service starting", { reason, previousVersion })),
})
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/enterprise",
"version": "2.0.18",
"version": "2.0.19",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/function",
"version": "2.0.18",
"version": "2.0.19",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "2.0.18",
"version": "2.0.19",
"name": "@opencode/http-recorder",
"description": "Record and replay Effect HTTP and WebSocket traffic with deterministic cassettes",
"type": "module",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/httpapi-codegen",
"version": "2.0.18",
"version": "2.0.19",
"private": true,
"type": "module",
"exports": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/latex",
"version": "2.0.18",
"version": "2.0.19",
"private": true,
"type": "module",
"exports": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/merman",
"version": "2.0.18",
"version": "2.0.19",
"private": true,
"type": "module",
"exports": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/plugin-browser",
"version": "2.0.18",
"version": "2.0.19",
"description": "OpenCode's desktop browser plugin",
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/plugin",
"version": "2.0.18",
"version": "2.0.19",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/protocol",
"version": "2.0.18",
"version": "2.0.19",
"type": "module",
"license": "MIT",
"repository": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/schema",
"version": "2.0.18",
"version": "2.0.19",
"type": "module",
"license": "MIT",
"repository": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "2.0.18",
"version": "2.0.19",
"name": "@opencode/sdk",
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/server",
"version": "2.0.18",
"version": "2.0.19",
"type": "module",
"license": "MIT",
"repository": {
@@ -13,10 +13,7 @@ for (const split of [false, true]) {
await expect(additions).toHaveText(["select-text"])
await expect(root.locator('[data-line-type="context"] [data-diff-span]')).toHaveCount(0)
await expect(root.locator('[data-line-type="change-deletion"] [data-diff-span]')).toHaveText([
'"http" in',
"? error.reason.",
"response?.",
": undefined",
'"http" in error.reason ? error.reason.http?.response?.headers["x-should-retry"] : undefined',
])
await expect(additions).not.toHaveCSS("background-color", "rgba(0, 0, 0, 0)")
})
@@ -27,8 +24,7 @@ for (const split of [false, true]) {
const root = await mount("components-session-review--large-file", { args: { split, source } })
const line = root.locator('[data-line][data-line-type="change-addition"]')
await expect(line).toHaveText("export const value = 'after'")
// Plain first paint is not proof that the worker kept inline diffs disabled.
await expect(line.locator('[style*="--syntax-"]')).not.toHaveCount(0)
await expect(line.locator('[style*="--syntax-"]')).toHaveCount(0)
await expect(root.locator("[data-diff-span]")).toHaveCount(0)
})
}
@@ -1,6 +1,33 @@
import { expect, story } from "../../storybook/playwright/story"
story("renders the line comment cancel action as a ghost button", async ({ mount }) => {
story("renders the line comment content editor and compact actions", async ({ mount }) => {
const root = await mount("ui-line-comment--editor-filled")
await expect(root.getByRole("button", { name: "Cancel" })).toHaveAttribute("data-variant", "ghost")
const editor = root.getByRole("textbox")
expect(await editor.evaluate((element) => element.tagName)).toBe("TEXTAREA")
await editor.fill("Updated comment\nwith context")
await expect(editor).toHaveValue("Updated comment\nwith context")
await editor.fill("x")
await editor.press("Backspace")
await expect(editor).toHaveValue("")
expect(await editor.evaluate((element) => element.matches(":placeholder-shown"))).toBe(true)
await expect(root.locator("textarea")).toHaveCount(1)
await expect(root.locator('[data-slot="line-comment-v2-label"]')).toHaveCount(0)
await expect(root.locator('[data-slot="line-comment-v2-footer-meta"]')).toHaveCount(0)
await expect(root.locator('[data-slot="line-comment-v2-shell"]')).toHaveCSS("padding", "0px")
await expect(editor).toHaveCSS("border-top-width", "0px")
await expect(editor).toHaveCSS("background-color", "rgba(0, 0, 0, 0)")
await expect(editor).toHaveCSS("padding", "12px")
await expect(root.getByRole("button", { name: "Cancel" })).toHaveAttribute("data-variant", "ghost-muted")
await expect(root.getByRole("button", { name: "Cancel" })).toHaveAttribute("data-size", "small")
await expect(root.getByRole("button", { name: "Comment" })).toHaveAttribute("data-variant", "submit")
await expect(root.getByRole("button", { name: "Comment" })).toHaveAttribute("data-size", "small")
})
story("preserves native undo in the line comment editor", async ({ mount }) => {
const root = await mount("ui-line-comment--editor")
const editor = root.getByRole("textbox")
await editor.pressSequentially("undo me")
await editor.press("Meta+z")
await expect(editor).toHaveValue("")
expect(await editor.evaluate((element) => element.matches(":placeholder-shown"))).toBe(true)
})
@@ -48,3 +48,154 @@ story("shows a comment button when a diff line is hovered", async ({ mount }) =>
await expect(review.getByRole("textbox")).toBeVisible()
await expect(review.locator('[data-slot="line-comment-editor-label"]')).toHaveText("Commenting on line 1")
})
for (const direction of ["ltr", "rtl"]) {
story(`offers a comment action for selected review text in ${direction}`, async ({ mount, page }) => {
const root = await mount("components-session-review--interactive-comments-panel", { globals: { direction } })
const action = page.getByRole("button", { name: "Add comment", exact: true })
await expect(async () => {
await root.locator('[data-line-type="change-addition"] [data-diff-span]').selectText()
await expect(action).toBeVisible()
}).toPass()
await expect(root.getByRole("textbox")).not.toBeVisible()
await expect(action).toHaveAttribute("data-variant", "submit")
await expect(action).toHaveCSS("z-index", "110")
const box = await action.boundingBox()
const code = root.locator("[data-code]").first()
const gutterRight = await code.evaluate((element) => element.firstElementChild?.getBoundingClientRect().right)
expect((box?.x ?? 0) - (gutterRight ?? 0)).toBeCloseTo(8, 0)
expect(box?.x).toBeGreaterThanOrEqual(0)
expect((box?.x ?? 0) + (box?.width ?? 0)).toBeLessThanOrEqual(
await page.evaluate(() => document.documentElement.clientWidth),
)
await action.click()
await expect.poll(() => page.evaluate(() => window.getSelection()?.toString())).toBe("")
await expect(root.getByRole("textbox")).toBeVisible()
await expect(root.locator('[data-line="2"][data-line-type="change-addition"]')).toHaveAttribute(
"data-selected-line",
/.*/,
)
})
}
for (const direction of ["up", "down"] as const) {
story(
`positions the comment action with ${direction === "up" ? "an upward" : "a downward"} selection`,
async ({ mount, page }) => {
const root = await mount("components-session-review--interactive-comments-panel")
const action = page.getByRole("button", { name: "Add comment", exact: true })
await expect(async () => {
await root.getByText("export const first = 1", { exact: true }).evaluate((element, value) => {
const root = element.getRootNode()
if (!(root instanceof ShadowRoot)) throw new Error("Expected a shadow root")
const text = (line: number) => {
const row = root.querySelector(`[data-line="${line}"]`)
if (!row) throw new Error(`Missing line ${line}`)
const node = document.createTreeWalker(row, NodeFilter.SHOW_TEXT).nextNode()
if (!node) throw new Error(`Missing text for line ${line}`)
return node
}
const first = text(1)
const last = text(3)
const selection = window.getSelection()
if (!selection) throw new Error("Missing selection")
if (value === "up") {
selection.setBaseAndExtent(last, last.textContent?.length ?? 0, first, 0)
} else {
selection.setBaseAndExtent(first, 0, last, last.textContent?.length ?? 0)
}
document.dispatchEvent(new Event("selectionchange"))
}, direction)
await expect(action).toHaveAttribute("data-placement", direction === "up" ? "top" : "bottom")
await expect(action).toHaveClass(/transition-transform/)
await expect(action).toHaveClass(/ease-out/)
await expect(action).not.toHaveClass(/fade-in/)
await expect
.poll(() =>
action.evaluate((button) => {
const host = document.querySelector('[data-component="file"]')?.querySelector("diffs-container")
const root = host?.shadowRoot
if (!root) return NaN
const selection =
(root as unknown as { getSelection?: () => Selection | null }).getSelection?.() ??
window.getSelection()
const source = (
selection as unknown as {
getComposedRanges?: (options: { shadowRoots: ShadowRoot[] }) => StaticRange[]
}
)?.getComposedRanges?.({ shadowRoots: [root] })?.[0]
if (!source) return NaN
const range = new Range()
range.setStart(source.startContainer, source.startOffset)
range.setEnd(source.endContainer, source.endOffset)
const selected = range.getBoundingClientRect()
const action = button.getBoundingClientRect()
return button.getAttribute("data-placement") === "top"
? selected.top - action.bottom
: action.top - selected.bottom
}),
)
.toBeCloseTo(8, 0)
}).toPass()
},
)
}
story("leaves a review code click as regular text interaction", async ({ mount }) => {
const root = await mount("components-session-review--interactive-comments-panel")
await root.locator('[data-line-type="change-addition"] [data-diff-span]').click()
await expect(root.getByRole("textbox")).not.toBeVisible()
})
story("keeps the text selection action visible past the end of a file", async ({ mount, page }) => {
const root = await mount("components-session-review--interactive-file-comments")
const action = page.getByRole("button", { name: "Add comment", exact: true })
await expect(async () => {
await root.getByText("export const last = 2", { exact: true }).selectText()
await expect(action).toBeVisible()
}).toPass()
const file = root.locator('[data-component="file"]')
await expect(action).toHaveCSS("position", "fixed")
expect(await action.evaluate((element) => element.closest('[data-component="file"]'))).toBeNull()
const actionBox = await action.boundingBox()
const fileBox = await file.boundingBox()
expect((actionBox?.y ?? 0) + (actionBox?.height ?? 0)).toBeGreaterThan(
(fileBox?.y ?? 0) + (fileBox?.height ?? 0),
)
})
story("keeps direct line-number range comments in the review panel", async ({ mount }) => {
const root = await mount("components-session-review--interactive-comments-panel")
await root.locator('[data-column-number="1"]').dragTo(root.locator('[data-column-number="3"]'))
await expect(root.getByRole("textbox")).toBeVisible()
await expect(root.locator("[data-selected-line]")).not.toHaveCount(0)
})
story("keeps the direct gutter comment action in the review panel", async ({ mount }) => {
const root = await mount("components-session-review--interactive-comments-panel")
const comment = root.getByRole("button", { name: "Comment", exact: true, includeHidden: true })
await expect(async () => {
await root.getByText("export const first = 1", { exact: true }).hover()
await expect(comment).toBeVisible()
}).toPass()
expect(await comment.evaluate((element) => (element as HTMLElement).style.background)).toBe(
"var(--v2-background-bg-inverse)",
)
expect(await comment.evaluate((element) => (element as HTMLElement).style.left)).toBe("-4px")
await expect(comment).toHaveCSS("z-index", "110")
await expect
.poll(async () => {
const box = await comment.boundingBox()
const gutterRight = await comment.evaluate(
(element) => element.parentElement?.assignedSlot?.parentElement?.parentElement?.getBoundingClientRect().right,
)
return (box?.x ?? 0) + (box?.width ?? 0) - (gutterRight ?? 0)
})
.toBe(-4)
await comment.dispatchEvent("click")
await expect(root.getByRole("textbox")).toBeVisible()
await expect(root.locator('[data-line="1"]')).toHaveAttribute("data-selected-line", /.*/)
})
@@ -1,7 +1,7 @@
import { expect, story } from "../../storybook/playwright/story"
// Moved from packages/app/e2e/regression/session-timeline-notices.spec.ts
story("renders the moved location notice in its compact timeline style", async ({ mount, page }) => {
story("renders the moved location notice as a timeline divider", async ({ mount, page }) => {
const directory = `/Users/usrnk1/Developer/opencode/${"nested-directory/".repeat(24)}session`
await page.setViewportSize({ width: 480, height: 720 })
const timeline = await mount("current-session-timeline-rows--conversation", { args: { scenario: "location" } })
@@ -14,12 +14,10 @@ story("renders the moved location notice in its compact timeline style", async (
await expect(value).toHaveText(directory)
await expect(notice).not.toContainText("·")
await expect(notice.locator("svg")).toHaveCount(0)
await expect(notice).toHaveCSS("height", "28px")
await expect(notice).toHaveCSS("gap", "8px")
await expect(notice).toHaveCSS("padding-top", "4px")
await expect(notice).toHaveCSS("padding-bottom", "4px")
await expect(notice).toHaveCSS("padding-top", "8px")
await expect(notice).toHaveCSS("padding-bottom", "8px")
await expect(label).toHaveCSS("font-size", "13px")
await expect(label).toHaveCSS("font-weight", "530")
await expect(label).toHaveCSS("font-weight", "440")
await expect(label).toHaveCSS("line-height", "16px")
await expect(label).toHaveCSS("color", "rgb(128, 128, 128)")
await expect(value).toHaveCSS("font-size", "13px")
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/session-ui",
"version": "2.0.18",
"version": "2.0.19",
"private": true,
"type": "module",
"license": "MIT",
@@ -13,7 +13,7 @@ test("renders large diffs as plain text without changing ordinary highlighting",
await page.goto("/")
await page.waitForFunction(() => !!window.highlighting)
const ordinary = await page.evaluate(() => window.highlighting.mount(1))
expect(ordinary.options).toMatchObject({ lineDiffType: "word-alt", maxLineDiffLength: 1000, tokenizeMaxLineLength: 1000 })
expect(ordinary.options).toMatchObject({ lineDiffType: "word-line", maxLineDiffLength: 1000, tokenizeMaxLineLength: 1000 })
expect(ordinary.syntaxSpans).toBeGreaterThan(0)
await expect(page.locator('[data-line="11"][data-line-type="change-addition"]')).toContainText("status: 201")
})
@@ -80,7 +80,7 @@ async function mount(revision: number) {
const offset = messages.length
const start = performance.now()
active = normalize({ ...input, additions: large ? 120 : 12, deletions: large ? 120 : 12 })
const pool = getWorkerPool(large ? "none" : "word-alt")!
const pool = getWorkerPool(large ? "none" : "word-line")!
let firstReady = 0
let rendered = 0
let finish!: (value: Measurement) => void
@@ -128,7 +128,7 @@ async function mount(revision: number) {
export const highlighting = {
mount,
configure(options: Partial<WorkerRenderingOptions>) { return getWorkerPool(large ? "none" : "word-alt")!.setRenderOptions(options) },
configure(options: Partial<WorkerRenderingOptions>) { return getWorkerPool(large ? "none" : "word-line")!.setRenderOptions(options) },
unmount() { dispose?.(); dispose = undefined; host.scrollTop = 0 },
contents() { return active && { before: text(active, "deletions"), after: text(active, "additions") } },
input: { before, after: inputs.map((input) => input.after) },
@@ -16,7 +16,7 @@ import {
import { acquireVirtualizer, virtualMetrics } from "../pierre/virtualizer"
import { File, type DiffFileProps, type FileProps } from "./file"
type DiffPreload<T> = PreloadMultiFileDiffResult<T> | PreloadFileDiffResult<T>
type DiffPreload<T> = PreloadMultiFileDiffResult<T, undefined> | PreloadFileDiffResult<T, undefined>
type SSRDiffFileProps<T> = DiffFileProps<T> & {
preloadedDiff: DiffPreload<T>
@@ -47,6 +47,7 @@ function DiffSSRViewer<T>(props: SSRDiffFileProps<T>) {
"onLineNumberSelectionEnd",
"onRendered",
"preloadedDiff",
"textSelectionAction",
])
const getRoot = () => fileDiffRef?.shadowRoot ?? undefined
@@ -119,7 +120,7 @@ function DiffSSRViewer<T>(props: SSRDiffFileProps<T>) {
// @ts-expect-error private field required for hydration
fileDiffInstance.fileContainer = fileDiffRef
fileDiffInstance.hydrate(
local.fileDiff
local.fileDiff || !local.before || !local.after
? {
fileDiff: local.fileDiff,
lineAnnotations: annotations,
@@ -128,12 +129,11 @@ function DiffSSRViewer<T>(props: SSRDiffFileProps<T>) {
prerenderedHTML: local.preloadedDiff.prerenderedHTML,
}
: {
oldFile: local.before
? { ...local.before, contents: typeof local.before.contents === "string" ? local.before.contents : "" }
: local.before,
newFile: local.after
? { ...local.after, contents: typeof local.after.contents === "string" ? local.after.contents : "" }
: local.after,
oldFile: {
...local.before,
contents: typeof local.before.contents === "string" ? local.before.contents : "",
},
newFile: { ...local.after, contents: typeof local.after.contents === "string" ? local.after.contents : "" },
lineAnnotations: annotations,
fileContainer: fileDiffRef,
containerWrapper: container,
+184 -10
View File
@@ -21,10 +21,13 @@ import { type PreloadFileDiffResult, type PreloadMultiFileDiffResult } from "@pi
import { createMediaQuery } from "@solid-primitives/media"
import { makeEventListener } from "@solid-primitives/event-listener"
import { ComponentProps, createEffect, createMemo, createSignal, onCleanup, onMount, Show, splitProps } from "solid-js"
import { Portal } from "solid-js/web"
import { Button } from "@opencode/ui/button"
import { createDefaultOptions, styleVariables } from "../pierre"
import { markCommentedDiffLines, markCommentedFileLines } from "../pierre/commented-lines"
import { fixDiffSelection, findDiffSide, type DiffSelectionSide } from "../pierre/diff-selection"
import { createFileFind } from "../pierre/file-find"
import { LINE_COMMENT_ACTION_GAP } from "../pierre/comment-hover"
import {
applyViewerScheme,
clearReadyWatcher,
@@ -48,6 +51,8 @@ import { FileMedia, type FileMediaOptions } from "./file-media"
import { FileSearchBar } from "./file-search"
const VIRTUALIZE_BYTES = 500_000
const TEXT_SELECTION_ACTION_HEIGHT = 24
const TEXT_SELECTION_ACTION_GAP = 8
const codeMetrics = {
...DEFAULT_VIRTUAL_FILE_METRICS,
@@ -65,6 +70,9 @@ type SharedProps<T> = {
classList?: ComponentProps<"div">["classList"]
media?: FileMediaOptions
search?: FileSearchControl
textSelectionAction?: {
label: string
}
}
export type FileSearchHandle = {
@@ -75,17 +83,17 @@ export type FileSearchControl = {
register: (handle: FileSearchHandle | null) => void
}
export type TextFileProps<T = {}> = FileOptions<T> &
export type TextFileProps<T = {}> = FileOptions<T, undefined> &
SharedProps<T> & {
mode: "text"
file: FileContents
annotations?: LineAnnotation<T>[]
preloadedDiff?: PreloadMultiFileDiffResult<T>
preloadedDiff?: PreloadMultiFileDiffResult<T, undefined>
}
type DiffPreload<T> = PreloadMultiFileDiffResult<T> | PreloadFileDiffResult<T>
type DiffPreload<T> = PreloadMultiFileDiffResult<T, undefined> | PreloadFileDiffResult<T, undefined>
type DiffBaseProps<T> = FileDiffOptions<T> &
type DiffBaseProps<T> = FileDiffOptions<T, undefined> &
SharedProps<T> & {
mode: "diff"
annotations?: DiffLineAnnotation<T>[]
@@ -123,6 +131,7 @@ const sharedKeys = [
"onLineNumberSelectionEnd",
"onRendered",
"preloadedDiff",
"textSelectionAction",
] as const
const textKeys = ["file", ...sharedKeys] as const
@@ -140,6 +149,7 @@ type MouseHit = {
type ViewerConfig = {
enableLineSelection: () => boolean
textSelectionAction: () => { label: string } | undefined
selectedLines: () => SelectedLineRange | null | undefined
commentedLines: () => SelectedLineRange[]
onLineSelectionEnd: (range: SelectedLineRange | null) => void
@@ -148,6 +158,14 @@ type ViewerConfig = {
lineFromMouseEvent: (event: MouseEvent) => MouseHit
setSelectedLines: (range: SelectedLineRange | null, preserve?: { root: ShadowRoot; text: Range }) => void
updateSelection: (preserveTextSelection: boolean) => void
readTextSelection: () =>
| {
range: SelectedLineRange
text: Range
direction: "up" | "down" | "same"
gutterRight?: number
}
| undefined
buildDragSelection: () => SelectedLineRange | undefined
buildClickSelection: () => SelectedLineRange | undefined
onDragStart: (hit: MouseHit) => void
@@ -162,6 +180,7 @@ function useFileViewer(config: ViewerConfig) {
let overlay!: HTMLDivElement
let selectionFrame: number | undefined
let dragFrame: number | undefined
let textSelectionFrame: number | undefined
let dragStart: number | undefined
let dragEnd: number | undefined
let dragMoved = false
@@ -171,6 +190,14 @@ function useFileViewer(config: ViewerConfig) {
const ready = createReadyWatcher()
const bridge = createLineNumberSelectionBridge()
const [rendered, setRendered] = createSignal(0)
const [textSelection, setTextSelection] = createSignal<{
range: SelectedLineRange
rect: DOMRect
label: string
below: boolean
gutterEdge: number
}>()
const hasTextSelection = createMemo(() => textSelection() !== undefined)
const getRoot = () => getViewerRoot(container)
const getHost = () => getViewerHost(container)
@@ -204,6 +231,54 @@ function useFileViewer(config: ViewerConfig) {
})
}
const updateTextSelection = () => {
textSelectionFrame = undefined
const action = config.textSelectionAction()
if (!action) {
setTextSelection(undefined)
return
}
const selected = config.readTextSelection()
if (!selected) {
setTextSelection(undefined)
return
}
const rect = selected.text.getBoundingClientRect()
if (rect.width === 0 && rect.height === 0) {
setTextSelection(undefined)
return
}
const roomBelow = rect.bottom + TEXT_SELECTION_ACTION_HEIGHT + TEXT_SELECTION_ACTION_GAP <= window.innerHeight
const roomAbove = rect.top - TEXT_SELECTION_ACTION_HEIGHT - TEXT_SELECTION_ACTION_GAP >= 0
const preferBelow = selected.direction !== "up"
const below = preferBelow ? roomBelow || !roomAbove : !roomAbove && roomBelow
const gutterEdge = selected.gutterRight ?? wrapper.getBoundingClientRect().left
setTextSelection({ range: selected.range, rect, label: action.label, below, gutterEdge })
}
const scheduleTextSelectionUpdate = () => {
if (textSelectionFrame !== undefined) return
textSelectionFrame = requestAnimationFrame(updateTextSelection)
}
const clearTextSelection = () => {
setTextSelection(undefined)
const root = getRoot()
const selection =
(root as unknown as { getSelection?: () => Selection | null } | undefined)?.getSelection?.() ??
window.getSelection()
selection?.removeAllRanges()
}
const activateTextSelection = () => {
const selected = textSelection()
if (!selected) return
clearTextSelection()
config.setSelectedLines(selected.range)
config.onLineSelectionEnd(selected.range)
}
// -- mouse handlers --
const handleMouseDown = (event: MouseEvent) => {
@@ -218,6 +293,11 @@ function useFileViewer(config: ViewerConfig) {
if (hit.line === undefined) return
bridge.begin(false, hit.line)
if (config.textSelectionAction()) {
setTextSelection(undefined)
if (lastSelection) config.setSelectedLines(null)
return
}
dragStart = hit.line
dragEnd = hit.line
dragMoved = false
@@ -250,6 +330,10 @@ function useFileViewer(config: ViewerConfig) {
const handleMouseUp = () => {
if (!config.enableLineSelection()) return
if (bridge.finish() === "numbers") return
if (config.textSelectionAction()) {
scheduleTextSelectionUpdate()
return
}
if (dragStart === undefined) return
if (!dragMoved) {
@@ -284,6 +368,10 @@ function useFileViewer(config: ViewerConfig) {
const handleSelectionChange = () => {
if (!config.enableLineSelection()) return
if (config.textSelectionAction()) {
scheduleTextSelectionUpdate()
return
}
if (dragStart === undefined) return
const selection = window.getSelection()
if (!selection || selection.isCollapsed) return
@@ -326,7 +414,9 @@ function useFileViewer(config: ViewerConfig) {
})
createEffect(() => {
config.setSelectedLines(config.selectedLines() ?? null)
const selected = config.selectedLines() ?? null
if (selected && config.textSelectionAction()) clearTextSelection()
config.setSelectedLines(selected)
})
createEffect(() => {
@@ -338,14 +428,26 @@ function useFileViewer(config: ViewerConfig) {
makeEventListener(document, "selectionchange", handleSelectionChange)
})
createEffect(() => {
if (!config.enableLineSelection() || !config.textSelectionAction() || !hasTextSelection()) return
makeEventListener(document, "scroll", scheduleTextSelectionUpdate, true)
makeEventListener(window, "resize", scheduleTextSelectionUpdate)
makeEventListener(document, "keydown", (event) => {
if (event.key !== "Escape") return
clearTextSelection()
})
})
onCleanup(() => {
clearReadyWatcher(ready)
if (selectionFrame !== undefined) cancelAnimationFrame(selectionFrame)
if (dragFrame !== undefined) cancelAnimationFrame(dragFrame)
if (textSelectionFrame !== undefined) cancelAnimationFrame(textSelectionFrame)
selectionFrame = undefined
dragFrame = undefined
textSelectionFrame = undefined
dragStart = undefined
dragEnd = undefined
dragMoved = false
@@ -393,15 +495,21 @@ function useFileViewer(config: ViewerConfig) {
getHost,
find,
scheduleSelectionUpdate,
textSelection,
activateTextSelection,
}
}
type Viewer = ReturnType<typeof useFileViewer>
type ModeAdapter = Omit<ViewerConfig, "enableLineSelection" | "selectedLines" | "commentedLines" | "onLineSelectionEnd">
type ModeAdapter = Omit<
ViewerConfig,
"enableLineSelection" | "textSelectionAction" | "selectedLines" | "commentedLines" | "onLineSelectionEnd"
>
type ModeConfig = {
enableLineSelection: () => boolean
textSelectionAction: () => { label: string } | undefined
selectedLines: () => SelectedLineRange | null | undefined
commentedLines: () => SelectedLineRange[] | undefined
onLineSelectionEnd: (range: SelectedLineRange | null) => void
@@ -424,6 +532,7 @@ type VirtualStrategy = {
function useModeViewer(config: ModeConfig, adapter: ModeAdapter) {
return useFileViewer({
enableLineSelection: config.enableLineSelection,
textSelectionAction: config.textSelectionAction,
selectedLines: config.selectedLines,
commentedLines: () => config.commentedLines() ?? [],
onLineSelectionEnd: config.onLineSelectionEnd,
@@ -728,6 +837,38 @@ function ViewerShell(props: {
</Show>
<div ref={(el) => (props.viewer.container = el)} />
<div ref={(el) => (props.viewer.overlay = el)} class="pointer-events-none absolute inset-0 z-0" />
<Show when={props.viewer.textSelection()}>
{(selection) => (
<Portal>
<Button
data-slot="file-text-selection-action"
data-placement={selection().below ? "bottom" : "top"}
size="small"
variant="submit"
class="z-[110] whitespace-nowrap motion-safe:transition-transform duration-100 ease-out motion-reduce:transition-none"
style={{
position: "fixed",
left: `${selection().gutterEdge + LINE_COMMENT_ACTION_GAP}px`,
top: `${selection().below ? selection().rect.bottom : selection().rect.top}px`,
transform: selection().below
? `translateY(${TEXT_SELECTION_ACTION_GAP}px)`
: `translateY(calc(-100% - ${TEXT_SELECTION_ACTION_GAP}px))`,
}}
onPointerDown={(event: PointerEvent) => {
event.preventDefault()
event.stopPropagation()
}}
onMouseDown={(event: MouseEvent) => {
event.preventDefault()
event.stopPropagation()
}}
onClick={props.viewer.activateTextSelection}
>
{selection().label}
</Button>
</Portal>
)}
</Show>
</div>
)
}
@@ -845,6 +986,23 @@ function TextViewer<T>(props: TextFileProps<T>) {
if (!preserveTextSelection || !selected.text) return
restoreShadowTextSelection(root, selected.text)
},
readTextSelection: () => {
const root = viewer.getRoot()
if (!root) return
const selected = readShadowLineSelection({
root,
lineForNode: findFileLineNumber,
sideForNode: findCodeSelectionSide,
preserveTextSelection: true,
})
if (!selected?.text) return
return {
range: selected.range,
text: selected.text,
direction: selected.direction,
gutterRight: selected.gutterRight,
}
},
buildDragSelection: () => {
if (viewer.dragStart === undefined || viewer.dragEnd === undefined) return
return { start: Math.min(viewer.dragStart, viewer.dragEnd), end: Math.max(viewer.dragStart, viewer.dragEnd) }
@@ -862,6 +1020,7 @@ function TextViewer<T>(props: TextFileProps<T>) {
viewer = useModeViewer(
{
enableLineSelection: () => props.enableLineSelection === true,
textSelectionAction: () => local.textSelectionAction,
selectedLines: () => local.selectedLines,
commentedLines: () => local.commentedLines,
onLineSelectionEnd: (range) => local.onLineSelectionEnd?.(range),
@@ -957,7 +1116,7 @@ function DiffViewer<T>(props: DiffFileProps<T>) {
let instance: FileDiff<T> | undefined
let instanceVirtualizer: Virtualizer | undefined
let instanceWorkerPool: ReturnType<typeof getWorkerPool>
let instanceVirtualHunkSeparators: FileDiffOptions<T>["hunkSeparators"] | undefined
let instanceVirtualHunkSeparators: FileDiffOptions<T, undefined>["hunkSeparators"] | undefined
let instanceFileDiff: FileDiffMetadata | undefined
let instanceBefore: FileContents | undefined
let instanceAfter: FileContents | undefined
@@ -1008,6 +1167,20 @@ function DiffViewer<T>(props: DiffFileProps<T>) {
setSelectedLines(selected.range)
},
readTextSelection: () => {
const root = viewer.getRoot()
if (!root) return
const selected = readShadowLineSelection({
root,
lineForNode: findDiffLineNumber,
sideForNode: diffSelectionSide,
preserveTextSelection: true,
})
if (!selected?.text) return
const range = fixDiffSelection(root, selected.range)
if (!range) return
return { range, text: selected.text, direction: selected.direction, gutterRight: selected.gutterRight }
},
buildDragSelection: () => {
if (viewer.dragStart === undefined || viewer.dragEnd === undefined) return
const selected: SelectedLineRange = { start: viewer.dragStart, end: viewer.dragEnd }
@@ -1038,6 +1211,7 @@ function DiffViewer<T>(props: DiffFileProps<T>) {
viewer = useModeViewer(
{
enableLineSelection: () => props.enableLineSelection === true,
textSelectionAction: () => local.textSelectionAction,
selectedLines: () => local.selectedLines,
commentedLines: () => local.commentedLines,
onLineSelectionEnd: (range) => local.onLineSelectionEnd?.(range),
@@ -1066,7 +1240,7 @@ function DiffViewer<T>(props: DiffFileProps<T>) {
lineDiffType: "none",
maxLineDiffLength: 0,
tokenizeMaxLineLength: 1,
} satisfies Pick<FileDiffOptions<T>, "lineDiffType" | "maxLineDiffLength" | "tokenizeMaxLineLength">
} satisfies Pick<FileDiffOptions<T, undefined>, "lineDiffType" | "maxLineDiffLength" | "tokenizeMaxLineLength">
const lineCallbacks = createLineCallbacks({
viewer,
@@ -1076,7 +1250,7 @@ function DiffViewer<T>(props: DiffFileProps<T>) {
onLineNumberSelectionEnd: (range) => local.onLineNumberSelectionEnd?.(range),
})
const options = createMemo<FileDiffOptions<T>>(() => {
const options = createMemo<FileDiffOptions<T, undefined>>(() => {
const base = {
...createDefaultOptions(props.diffStyle),
...others,
@@ -1112,7 +1286,7 @@ function DiffViewer<T>(props: DiffFileProps<T>) {
createEffect(() => {
const opts = options()
// Worker render options override per-viewer options, including the large-file fallback.
const workerPool = getWorkerPool(large() ? "none" : "word-alt")
const workerPool = getWorkerPool(large() ? "none" : "word-line")
const virtualizer = virtuals.get()
const beforeContents = typeof local.before?.contents === "string" ? local.before.contents : ""
const afterContents = typeof local.after?.contents === "string" ? local.after.contents : ""
@@ -4,6 +4,7 @@ import { CurrentSessionProviders } from "../storybook/current-session-story"
import { editThenTestDocument, reviewDiffs } from "../storybook/current-session-fixtures"
import { File } from "./file"
import { SessionReview, type SessionReviewComment } from "./session-review"
import { SessionReviewFilePreviewV2 } from "../v2/components/session-review-file-preview-v2"
function ReviewStory(props: { split?: boolean }) {
return (
@@ -80,6 +81,53 @@ function InteractiveCommentsStory() {
export const InteractiveComments = { render: () => <InteractiveCommentsStory /> }
function InteractiveCommentsV2Story() {
const [state, setState] = createStore({ comments: [] as SessionReviewComment[] })
const file = "src/review.ts"
const diff = {
file,
additions: 1,
deletions: 1,
status: "modified" as const,
patch:
"diff --git a/src/review.ts b/src/review.ts\n--- a/src/review.ts\n+++ b/src/review.ts\n@@ -1,3 +1,3 @@\n export const first = 1\n-export const value = 'before'\n+export const value = 'after'\n export const last = 3\n",
}
return (
<CurrentSessionProviders document={editThenTestDocument}>
<div class="mx-auto h-screen min-h-[620px] w-full max-w-[900px] overflow-auto bg-background-base">
<SessionReviewFilePreviewV2
file={file}
diff={diff}
diffStyle="unified"
comments={state.comments}
onLineComment={(comment) =>
setState("comments", (comments) => [...comments, { id: `comment-${comments.length + 1}`, ...comment }])
}
/>
</div>
</CurrentSessionProviders>
)
}
export const InteractiveCommentsPanel = { render: () => <InteractiveCommentsV2Story /> }
export const InteractiveFileComments = {
render: () => (
<CurrentSessionProviders document={editThenTestDocument}>
<div class="h-screen overflow-auto bg-background-base">
<div class="relative overflow-hidden pb-40">
<File
mode="text"
file={{ name: "review.ts", contents: "export const first = 1\nexport const last = 2" }}
enableLineSelection
textSelectionAction={{ label: "Add comment" }}
/>
</div>
</div>
</CurrentSessionProviders>
),
}
const gitDiffs = [
{
// OpenCode 93e1f383dd79683af4fc5ad139cea0516603c838, unchanged git-show output.
@@ -64,12 +64,12 @@ export type SessionReviewCommentActions = {
export type SessionReviewFocus = { file: string; id: string }
type RawReviewDiff = (PresentationFileDiff | FileDiffInfo) & {
preloaded?: PreloadMultiFileDiffResult<unknown>
preloaded?: PreloadMultiFileDiffResult<unknown, undefined>
}
type ReviewDiff = ((PresentationFileDiff & { file: string }) | FileDiffInfo) & {
preloaded?: PreloadMultiFileDiffResult<unknown>
preloaded?: PreloadMultiFileDiffResult<unknown, undefined>
}
type Item = ViewDiff & { preloaded?: PreloadMultiFileDiffResult<unknown> }
type Item = ViewDiff & { preloaded?: PreloadMultiFileDiffResult<unknown, undefined> }
function diff(value: unknown): value is ReviewDiff {
if (!value || typeof value !== "object" || Array.isArray(value)) return false
@@ -1,7 +1,25 @@
import { ProviderIcon } from "@opencode/ui/provider-icon"
import { Tooltip } from "@opencode/ui/tooltip"
import { Show } from "solid-js"
export function TimelineSeparator(props: { label: string; providerID?: string; variant?: string }) {
export function TimelineSeparator(props: {
label: string
value?: string
tooltip?: string
providerID?: string
variant?: string
}) {
const label = () => (
<bdi
data-slot="session-timeline-notice-label"
dir="auto"
class="block truncate"
classList={{ "shrink-0": !!props.value }}
title={props.tooltip ? undefined : props.label}
>
{props.label}
</bdi>
)
return (
<div class="flex h-8 w-full items-center gap-3 text-v2-text-text-faint">
<span class="h-px min-w-0 flex-1 bg-v2-border-border-strong" />
@@ -10,9 +28,26 @@ export function TimelineSeparator(props: { label: string; providerID?: string; v
{(providerID) => <ProviderIcon id={providerID()} class="text-v2-icon-icon-faint" aria-hidden="true" />}
</Show>
<span class="flex min-w-0 items-center gap-1.5">
<bdi dir="auto" class="truncate" title={props.label}>
{props.label}
</bdi>
<Show when={props.tooltip} fallback={label()}>
{(tooltip) => (
<Tooltip
appearance="compact"
placement="top"
value={tooltip()}
class={props.value ? "min-w-0 shrink-0" : "min-w-0"}
triggerTabIndex={0}
>
{label()}
</Tooltip>
)}
</Show>
<Show when={props.value}>
{(value) => (
<bdi data-slot="session-timeline-notice-value" dir="ltr" class="min-w-0 truncate" title={value()}>
{value()}
</bdi>
)}
</Show>
<Show when={props.variant && props.variant !== "default" ? props.variant : undefined}>
{(variant) => (
<>
+1 -1
View File
@@ -32,7 +32,7 @@ type Data = {
[sessionID: string]: FileDiffInfo[]
}
session_diff_preload?: {
[sessionID: string]: PreloadMultiFileDiffResult<unknown>[]
[sessionID: string]: PreloadMultiFileDiffResult<unknown, undefined>[]
}
}
@@ -3,6 +3,9 @@ export type HoverCommentLine = {
side?: "additions" | "deletions"
}
export const LINE_COMMENT_ACTION_GAP = 8
const LINE_COMMENT_ACTION_SIZE = 20
export function createHoverCommentUtility(props: {
label: string
getHoveredLine: () => HoverCommentLine | undefined
@@ -14,21 +17,22 @@ export function createHoverCommentUtility(props: {
button.type = "button"
button.ariaLabel = props.label
button.textContent = "+"
button.style.width = "20px"
button.style.height = "20px"
button.style.width = `${LINE_COMMENT_ACTION_SIZE}px`
button.style.height = `${LINE_COMMENT_ACTION_SIZE}px`
button.style.display = "flex"
button.style.alignItems = "center"
button.style.justifyContent = "center"
button.style.border = "none"
button.style.borderRadius = "var(--radius-md)"
button.style.background = "var(--icon-interactive-base)"
button.style.color = "var(--white)"
button.style.background = "var(--v2-background-bg-inverse)"
button.style.color = "var(--v2-icon-icon-inverse)"
button.style.boxShadow = "var(--shadow-xs)"
button.style.fontSize = "14px"
button.style.lineHeight = "1"
button.style.cursor = "pointer"
button.style.position = "relative"
button.style.left = "30px"
button.style.zIndex = "110"
button.style.left = "-4px"
button.style.top = "calc((var(--diffs-line-height, 24px) - 20px) / 2)"
let line: HoverCommentLine | undefined
@@ -59,6 +63,29 @@ export function createHoverCommentUtility(props: {
props.onSelect(next)
}
const startLineSelection = (event: PointerEvent) => {
const number = button.parentElement?.assignedSlot?.parentElement?.parentElement
if (!(number instanceof HTMLElement)) return
number.dispatchEvent(
new PointerEvent("pointerdown", {
bubbles: true,
cancelable: true,
composed: true,
pointerId: event.pointerId,
pointerType: event.pointerType,
isPrimary: event.isPrimary,
button: event.button,
buttons: event.buttons,
clientX: event.clientX,
clientY: event.clientY,
ctrlKey: event.ctrlKey,
metaKey: event.metaKey,
shiftKey: event.shiftKey,
altKey: event.altKey,
}),
)
}
document.addEventListener("pointermove", onHoverInvalidated, { passive: true })
document.addEventListener("scroll", onHoverInvalidated, { passive: true, capture: true })
button.addEventListener("mouseenter", sync)
@@ -67,6 +94,7 @@ export function createHoverCommentUtility(props: {
event.preventDefault()
event.stopPropagation()
sync()
startLineSelection(event)
})
button.addEventListener("mousedown", (event) => {
event.preventDefault()
@@ -73,6 +73,19 @@ export function readShadowLineSelection(opts: {
const startSide = opts.sideForNode?.(startNode)
const endSide = opts.sideForNode?.(endNode)
const side = startSide ?? endSide
const anchorTop = findElement(selection.anchorNode)
?.closest("[data-line], [data-alt-line]")
?.getBoundingClientRect().top
const focusElement = findElement(selection.focusNode)
const focusTop = focusElement?.closest("[data-line], [data-alt-line]")?.getBoundingClientRect().top
const code = focusElement?.closest("[data-code]")
const gutterRight = code?.firstElementChild?.getBoundingClientRect().right
const direction =
anchorTop === undefined || focusTop === undefined || anchorTop === focusTop
? ("same" as const)
: focusTop < anchorTop
? ("up" as const)
: ("down" as const)
const range: SelectedLineRange = { start, end }
if (side) range.side = side
@@ -81,5 +94,7 @@ export function readShadowLineSelection(opts: {
return {
range,
text: opts.preserveTextSelection && domRange ? toRange(domRange).cloneRange() : undefined,
direction,
gutterRight,
}
}
+21 -10
View File
@@ -2,7 +2,7 @@ import { DiffLineAnnotation, FileContents, FileDiffOptions, type SelectedLineRan
import { ComponentProps } from "solid-js"
import { lineCommentStyles } from "../components/line-comment-styles"
export type DiffProps<T = {}> = FileDiffOptions<T> & {
export type DiffProps<T = {}> = FileDiffOptions<T, undefined> & {
before: FileContents
after: FileContents
annotations?: DiffLineAnnotation<T>[]
@@ -17,6 +17,11 @@ export type DiffProps<T = {}> = FileDiffOptions<T> & {
const unsafeCSS = `
:host {
--diffs-bg: var(--opencode-diffs-bg, var(--color-background-stronger));
/* Pierre mixes 15% in dark mode, which makes collapsed rows read as highlighted. */
--diffs-bg-separator-override: light-dark(
color-mix(in lab, var(--diffs-bg) 98%, var(--diffs-mixer)),
color-mix(in lab, var(--diffs-bg) 95%, var(--diffs-mixer))
);
}
[data-diff],
@@ -130,6 +135,12 @@ const unsafeCSS = `
color: var(--diffs-selection-number-fg);
}
[data-gutter-utility-slot] {
left: unset;
right: 0;
justify-content: flex-end;
}
[data-diff] [data-column-number][data-line-type='context'][data-selected-line],
[data-diff] [data-column-number][data-line-type='context-expanded'][data-selected-line],
[data-diff] [data-column-number][data-line-type='change-addition'][data-selected-line],
@@ -138,15 +149,15 @@ const unsafeCSS = `
}
@media (max-width: 767px) {
/* File annotations share the code column; reclaim the measured number gutter. */
[data-file] [data-line-annotation] {
margin-inline-start: calc(-1 * var(--diffs-column-number-width, 0px));
z-index: 4;
/* File annotations share the code column; reclaim the number gutter. Pierre only measures column widths for
scrolling files with annotations, so derive the gutter from the code grid width instead. */
[data-file][data-overflow='wrap'] [data-code] {
container-type: inline-size;
}
[data-file] [data-annotation-content] {
width: var(--diffs-column-width, auto);
inset-inline-start: 0;
[data-file][data-overflow='wrap'] [data-line-annotation] {
margin-inline-start: calc(100% - 100cqi);
z-index: 4;
}
}
@@ -198,7 +209,7 @@ ${lineCommentStyles}
`
export function createDefaultOptions<T>(style: FileDiffOptions<T>["diffStyle"]) {
export function createDefaultOptions<T>(style: FileDiffOptions<T, undefined>["diffStyle"]) {
return {
theme: "OpenCode",
themeType: "system",
@@ -210,7 +221,7 @@ export function createDefaultOptions<T>(style: FileDiffOptions<T>["diffStyle"])
disableBackground: false,
expansionLineCount: 20,
hunkSeparators: "line-info-basic",
lineDiffType: "word-alt",
lineDiffType: "word-line",
maxLineDiffLength: 1000,
maxLineLengthForHighlighting: 1000,
disableFileHeader: true,
+3 -3
View File
@@ -8,7 +8,7 @@ export function workerFactory(): Worker {
return new Worker(ShikiWorkerUrl, { type: "module" })
}
function createPool(lineDiffType: "none" | "word-alt") {
function createPool(lineDiffType: "none" | "word-line") {
const pool = new WorkerPoolManager(
{
workerFactory,
@@ -36,7 +36,7 @@ function createPool(lineDiffType: "none" | "word-alt") {
let plain: WorkerPoolManager | undefined
let diff: WorkerPoolManager | undefined
export function getWorkerPool(lineDiffType: "none" | "word-alt" = "word-alt"): WorkerPoolManager | undefined {
export function getWorkerPool(lineDiffType: "none" | "word-line" = "word-line"): WorkerPoolManager | undefined {
if (typeof window === "undefined") return
if (lineDiffType === "none") {
@@ -44,7 +44,7 @@ export function getWorkerPool(lineDiffType: "none" | "word-alt" = "word-alt"): W
return plain
}
if (!diff) diff = createPool("word-alt")
if (!diff) diff = createPool("word-line")
return diff
}
@@ -271,7 +271,12 @@ export namespace Timeline {
detail,
new Set(
messages
.filter((message) => message.type === "compaction" || message.type === "model-switched")
.filter(
(message) =>
message.type === "compaction" ||
message.type === "model-switched" ||
message.type === "location-switched",
)
.map((message) => message.id),
),
)
@@ -5,6 +5,7 @@ import type {
SessionMessageInfo,
} from "@opencode/client/promise"
import { storyDocument, storyTool } from "../storybook/current-session-scenarios"
import { timelinePresets } from "./detail"
import { createTimelineProjection, Timeline, TimelineRow } from "./projection"
describe("current session timeline rows", () => {
@@ -333,6 +334,39 @@ describe("current session timeline rows", () => {
])
})
test("keeps divider notices outside grouped context rows", () => {
const source = [
{ id: "msg_user", type: "user", text: "move", time: { created: 1 } },
{
id: "msg_assistant",
type: "assistant",
agent: "build",
model: { id: "model", providerID: "provider" },
content: [storyTool("tool_read", "read", "completed", {})],
time: { created: 2, completed: 3 },
},
{ id: "msg_moved", type: "location-switched", location: { directory: "/tmp/next" }, time: { created: 4 } },
{ id: "msg_skill", type: "skill", skill: "review", name: "Review", text: "instructions", time: { created: 5 } },
] satisfies SessionMessageInfo[]
const rows = Timeline.constructSessionMessageRows(
source,
false,
{ type: "idle" },
undefined,
false,
false,
undefined,
timelinePresets.find((preset) => preset.id === "compact")!.value,
).rows
expect(rows.map(TimelineRow.key)).toEqual([
"user-message:msg_user",
"assistant-part:context:context:msg_assistant:tool_read",
"notice:msg_moved",
"assistant-part:context:message:msg_skill",
])
})
test("keeps assistant errors and retries before later notices", () => {
const result = Timeline.constructSessionMessageRows(
[
@@ -5,7 +5,6 @@ import type {
SessionStatus,
} from "@opencode/client/promise"
import { useI18n } from "@opencode/ui/context/i18n"
import { Tooltip } from "@opencode/ui/tooltip"
import { For, Show, createMemo, type Accessor, type JSX } from "solid-js"
import { Dynamic } from "solid-js/web"
import type { SessionUserActions, SessionUserAttachmentReference, SessionUserComment } from "../actions"
@@ -522,25 +521,12 @@ export function createSessionTimelineRowRenderer(input: {
}
>
{(message) => (
<div
data-slot="session-timeline-notice"
data-type="location-switched"
class={`flex h-7 w-full min-w-0 items-center gap-2 py-1 text-[13px] leading-text-compact tracking-[-0.04px] text-v2-text-text-faint ${inset()}`}
>
<Tooltip
appearance="compact"
placement="top"
value={i18n.t("ui.sessionTimeline.notice.movedTooltip")}
class="shrink-0"
triggerTabIndex={0}
>
<bdi data-slot="session-timeline-notice-label" dir="auto" class="font-[530]">
{i18n.t("ui.sessionTimeline.notice.movedTo")}
</bdi>
</Tooltip>{" "}
<bdi data-slot="session-timeline-notice-value" dir="ltr" class="min-w-0 truncate font-[440]">
{message().location.directory}
</bdi>
<div data-slot="session-timeline-notice" data-type="location-switched" class={`w-full py-2 ${inset()}`}>
<TimelineSeparator
label={i18n.t("ui.sessionTimeline.notice.movedTo")}
value={message().location.directory}
tooltip={i18n.t("ui.sessionTimeline.notice.movedTooltip")}
/>
</div>
)}
</Show>
@@ -455,7 +455,7 @@ const MovedLocation = {
render: () => (
<CurrentSessionTimelineStory
title="Moved Session location"
description="A changed working directory stays compact, truncates, and exposes its tooltip."
description="A changed working directory renders as a timeline divider, truncates, and exposes its tooltip."
document={{
...thinkingDocument,
status: { type: "idle" },
@@ -228,6 +228,7 @@ export function SessionReviewFilePreviewV2(props: SessionReviewFilePreviewV2Prop
hunkSeparators={view().fileDiff.isPartial ? "simple" : "line-info-basic"}
enableLineSelection={lineCommentsEnabled()}
enableGutterUtility={lineCommentsEnabled()}
textSelectionAction={lineCommentsEnabled() ? { label: i18n.t("ui.lineComment.add") } : undefined}
onLineSelected={(range: SelectedLineRange | null) => {
if (!lineCommentsEnabled()) return
commentsUi.onLineSelected(range)
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/simulation",
"version": "2.0.18",
"version": "2.0.19",
"type": "module",
"license": "MIT",
"repository": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/stats-app",
"version": "2.0.18",
"version": "2.0.19",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/stats-core",
"version": "2.0.18",
"version": "2.0.19",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/stats-server",
"version": "2.0.18",
"version": "2.0.19",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/theme",
"version": "2.0.18",
"version": "2.0.19",
"type": "module",
"license": "MIT",
"repository": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/tui",
"version": "2.0.18",
"version": "2.0.19",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/ui",
"version": "2.0.18",
"version": "2.0.19",
"type": "module",
"license": "MIT",
"repository": {
@@ -117,85 +117,61 @@
display: flex;
flex-direction: column;
align-items: flex-start;
padding: 12px;
gap: 12px;
padding: 0;
gap: 0;
}
[data-component="line-comment-v2"][data-variant="editor"] [data-slot="line-comment-v2-field"] {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 8px;
gap: 0;
width: 100%;
min-width: 0;
}
[data-component="line-comment-v2"][data-variant="editor"] [data-slot="line-comment-v2-label"] {
display: flex;
flex-direction: row;
align-items: center;
width: 100%;
font-size: 13px;
font-style: normal;
font-weight: 530;
line-height: var(--line-height-compact);
letter-spacing: -0.04px;
color: var(--v2-text-text-base);
user-select: none;
}
[data-component="line-comment-v2"][data-variant="editor"] [data-slot="line-comment-v2-textarea"] {
display: block;
width: 100%;
min-width: 0;
min-height: 80px;
padding: 8px;
height: 80px;
padding: 12px;
margin: 0;
resize: vertical;
border: 1px solid var(--v2-border-border-base);
border-radius: 6px;
background:
linear-gradient(180deg, var(--v2-alpha-light-2) 0%, var(--v2-alpha-light-0) 100%), var(--v2-background-bg-base);
resize: none;
border: 0;
background: transparent;
font-size: 13px;
font-style: normal;
font-weight: 440;
line-height: 1.35;
line-height: var(--line-height-compact);
letter-spacing: -0.04px;
color: var(--v2-text-text-base);
font-variation-settings: "slnt" 0;
scrollbar-width: none;
outline: none;
}
[data-component="line-comment-v2"][data-variant="editor"] [data-slot="line-comment-v2-textarea"]::-webkit-scrollbar {
display: none;
}
[data-component="line-comment-v2"][data-variant="editor"] [data-slot="line-comment-v2-textarea"]::placeholder {
color: var(--v2-text-text-faint);
user-select: none;
}
[data-component="line-comment-v2"][data-variant="editor"] [data-slot="line-comment-v2-textarea"]:focus {
border-color: var(--v2-border-border-focus);
}
[data-component="line-comment-v2"][data-variant="editor"] [data-slot="line-comment-v2-footer"] {
display: flex;
flex-direction: row;
align-items: center;
justify-content: flex-end;
gap: 4px;
padding-block: 0 12px;
padding-inline: 12px;
width: 100%;
min-width: 0;
}
[data-component="line-comment-v2"][data-variant="editor"] [data-slot="line-comment-v2-footer-meta"] {
flex: 1 1 auto;
min-width: 0;
font-size: 11px;
font-style: normal;
font-weight: 530;
line-height: 1;
letter-spacing: 0.05px;
color: var(--v2-text-text-faint);
font-variation-settings: "slnt" 0;
}
[data-component="line-comment-v2"][data-variant="editor"] [data-slot="line-comment-v2-footer-actions"] {
display: flex;
flex-direction: row;
@@ -61,7 +61,7 @@ export type LineCommentEditorMention = {
}
export interface LineCommentEditorProps extends Omit<ComponentProps<"div">, "children" | "onInput" | "onSubmit"> {
/** Visible field label above the textarea (default: “Comment”). */
/** Accessible editor label (default: “Comment”). */
heading?: JSX.Element | string
value: string
onInput: (value: string) => void
@@ -108,7 +108,6 @@ export function LineCommentEditor(props: LineCommentEditorProps) {
"classList",
])
const heading = () => local.heading ?? i18n.t("ui.lineComment.submit")
const canSubmit = () => local.value.trim().length > 0
const closeMention = () => {
@@ -206,19 +205,19 @@ export function LineCommentEditor(props: LineCommentEditorProps) {
>
<div data-slot="line-comment-v2-shell">
<div data-slot="line-comment-v2-field">
<div data-slot="line-comment-v2-label">{heading()}</div>
<textarea
ref={(el) => {
textareaRef = el
}}
data-slot="line-comment-v2-textarea"
aria-label={typeof local.heading === "string" ? local.heading : i18n.t("ui.lineComment.submit")}
dir="auto"
rows={local.rows ?? 3}
placeholder={local.placeholder ?? i18n.t("ui.lineComment.contextPlaceholder")}
value={local.value}
style={{ "unicode-bidi": "plaintext", "text-align": "start" }}
onInput={(e) => {
local.onInput(e.currentTarget.value)
onInput={(event) => {
local.onInput(event.currentTarget.value)
syncMention()
}}
onClick={() => syncMention()}
@@ -292,12 +291,11 @@ export function LineCommentEditor(props: LineCommentEditorProps) {
</Show>
</div>
<div data-slot="line-comment-v2-footer">
<div data-slot="line-comment-v2-footer-meta">{local.selection}</div>
<div data-slot="line-comment-v2-footer-actions">
<Button type="button" size="normal" variant="ghost" onClick={() => local.onCancel()}>
<Button type="button" size="small" variant="ghost-muted" onClick={() => local.onCancel()}>
{local.cancelLabel ?? i18n.t("ui.lineComment.cancel")}
</Button>
<Button type="button" size="normal" variant="contrast" disabled={!canSubmit()} onClick={submit}>
<Button type="button" size="small" variant="submit" disabled={!canSubmit()} onClick={submit}>
{local.submitLabel ?? i18n.t("ui.lineComment.submit")}
</Button>
</div>

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