Compare commits

..
Author SHA1 Message Date
Brendan Allan c17d697cc1 fix(app): make QR pairing work across origins 2026-10-05 10:30:12 +08:00
304 changed files with 3661 additions and 12000 deletions

No files matched your search

+63 -36
View File
@@ -33,7 +33,7 @@
},
"packages/ai": {
"name": "@opencode/ai",
"version": "2.0.23",
"version": "2.0.22",
"dependencies": {
"@aws-sdk/credential-providers": "3.1057.0",
"@opencode/schema": "workspace:*",
@@ -55,7 +55,7 @@
},
"packages/app": {
"name": "@opencode/app",
"version": "2.0.23",
"version": "2.0.22",
"dependencies": {
"@corvu/drawer": "catalog:",
"@dnd-kit/abstract": "0.5.0",
@@ -112,7 +112,7 @@
},
"packages/cli": {
"name": "@opencode/cli",
"version": "2.0.23",
"version": "2.0.22",
"bin": {
"opencode": "./bin/opencode.cjs",
"opencode2": "./bin/opencode2.cjs",
@@ -179,7 +179,7 @@
},
"packages/client": {
"name": "@opencode/client",
"version": "2.0.23",
"version": "2.0.22",
"dependencies": {
"@opencode/protocol": "workspace:*",
"@opencode/schema": "workspace:*",
@@ -205,7 +205,7 @@
},
"packages/codemode": {
"name": "@opencode/codemode",
"version": "2.0.23",
"version": "2.0.22",
"dependencies": {
"acorn": "8.15.0",
"effect": "catalog:",
@@ -218,7 +218,7 @@
},
"packages/console/app": {
"name": "@opencode/console-app",
"version": "2.0.23",
"version": "2.0.22",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@ibm/plex": "6.4.1",
@@ -254,7 +254,7 @@
},
"packages/console/core": {
"name": "@opencode/console-core",
"version": "2.0.23",
"version": "2.0.22",
"dependencies": {
"@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1",
@@ -281,7 +281,7 @@
},
"packages/console/function": {
"name": "@opencode/console-function",
"version": "2.0.23",
"version": "2.0.22",
"dependencies": {
"@openauthjs/openauth": "0.0.0-20250322224806",
"@opencode/console-core": "workspace:*",
@@ -298,7 +298,7 @@
},
"packages/console/mail": {
"name": "@opencode/console-mail",
"version": "2.0.23",
"version": "2.0.22",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
@@ -322,7 +322,7 @@
},
"packages/console/support": {
"name": "@opencode/console-support",
"version": "2.0.23",
"version": "2.0.22",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@opencode/console-core": "workspace:*",
@@ -342,7 +342,7 @@
},
"packages/core": {
"name": "@opencode/core",
"version": "2.0.23",
"version": "2.0.22",
"dependencies": {
"@ai-sdk/cohere": "3.0.27",
"@ai-sdk/gateway": "3.0.104",
@@ -383,6 +383,7 @@
"mime-types": "3.0.2",
"tree-sitter-bash": "0.25.0",
"tree-sitter-powershell": "0.25.10",
"venice-ai-sdk-provider": "2.1.1",
"web-tree-sitter": "0.25.10",
"which": "6.0.1",
"zod": "catalog:",
@@ -410,7 +411,7 @@
},
"packages/desktop": {
"name": "@opencode/desktop",
"version": "2.0.23",
"version": "2.0.22",
"dependencies": {
"@zip.js/zip.js": "2.7.62",
"electron-context-menu": "5.0.0",
@@ -455,7 +456,7 @@
},
"packages/enterprise": {
"name": "@opencode/enterprise",
"version": "2.0.23",
"version": "2.0.22",
"dependencies": {
"@hono/standard-validator": "catalog:",
"@opencode-ai/sdk": "1.18.21",
@@ -492,7 +493,7 @@
},
"packages/function": {
"name": "@opencode/function",
"version": "2.0.23",
"version": "2.0.22",
"dependencies": {
"@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:",
@@ -508,7 +509,7 @@
},
"packages/gui-extensions": {
"name": "@opencode/gui-extensions",
"version": "2.0.23",
"version": "2.0.22",
"dependencies": {
"@dnd-kit/abstract": "0.5.0",
"@dnd-kit/dom": "0.5.0",
@@ -553,7 +554,7 @@
},
"packages/http-recorder": {
"name": "@opencode/http-recorder",
"version": "2.0.23",
"version": "2.0.22",
"dependencies": {
"@effect/platform-node-shared": "4.0.0-rc.112",
},
@@ -572,7 +573,7 @@
},
"packages/httpapi-codegen": {
"name": "@opencode/httpapi-codegen",
"version": "2.0.23",
"version": "2.0.22",
"dependencies": {
"effect": "catalog:",
"prettier": "3.6.2",
@@ -585,7 +586,7 @@
},
"packages/latex": {
"name": "@opencode/latex",
"version": "2.0.23",
"version": "2.0.22",
"dependencies": {
"@opencode/plugin": "workspace:*",
"@opentui/core": "catalog:",
@@ -599,7 +600,7 @@
},
"packages/merman": {
"name": "@opencode/merman",
"version": "2.0.23",
"version": "2.0.22",
"dependencies": {
"@opencode/plugin": "workspace:*",
"@opentui/core": "catalog:",
@@ -614,7 +615,7 @@
},
"packages/plugin": {
"name": "@opencode/plugin",
"version": "2.0.23",
"version": "2.0.22",
"dependencies": {
"@ai-sdk/provider": "3.0.8",
"@opencode/ai": "workspace:*",
@@ -653,7 +654,7 @@
},
"packages/plugin-browser": {
"name": "@opencode/plugin-browser",
"version": "2.0.23",
"version": "2.0.22",
"dependencies": {
"@opencode/plugin": "workspace:*",
"@opencode/schema": "workspace:*",
@@ -683,7 +684,7 @@
},
"packages/protocol": {
"name": "@opencode/protocol",
"version": "2.0.23",
"version": "2.0.22",
"dependencies": {
"@opencode/schema": "workspace:*",
"effect": "catalog:",
@@ -698,7 +699,7 @@
},
"packages/schema": {
"name": "@opencode/schema",
"version": "2.0.23",
"version": "2.0.22",
"dependencies": {
"@standard-schema/spec": "catalog:",
"effect": "catalog:",
@@ -722,7 +723,7 @@
},
"packages/sdk": {
"name": "@opencode/sdk",
"version": "2.0.23",
"version": "2.0.22",
"dependencies": {
"@opencode/client": "workspace:*",
"@opencode/core": "workspace:*",
@@ -743,7 +744,7 @@
},
"packages/server": {
"name": "@opencode/server",
"version": "2.0.23",
"version": "2.0.22",
"dependencies": {
"@effect/platform-node": "catalog:",
"@effect/platform-node-shared": "catalog:",
@@ -765,7 +766,7 @@
},
"packages/session-ui": {
"name": "@opencode/session-ui",
"version": "2.0.23",
"version": "2.0.22",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode/client": "workspace:*",
@@ -800,7 +801,7 @@
},
"packages/simulation": {
"name": "@opencode/simulation",
"version": "2.0.23",
"version": "2.0.22",
"dependencies": {
"@opencode/ai": "workspace:*",
"@opencode/core": "workspace:*",
@@ -820,7 +821,7 @@
},
"packages/stats/app": {
"name": "@opencode/stats-app",
"version": "2.0.23",
"version": "2.0.22",
"dependencies": {
"@ibm/plex": "6.4.1",
"@kobalte/core": "catalog:",
@@ -854,7 +855,7 @@
},
"packages/stats/core": {
"name": "@opencode/stats-core",
"version": "2.0.23",
"version": "2.0.22",
"dependencies": {
"@aws-sdk/client-athena": "3.933.0",
"@planetscale/database": "1.19.0",
@@ -873,7 +874,7 @@
},
"packages/stats/server": {
"name": "@opencode/stats-server",
"version": "2.0.23",
"version": "2.0.22",
"dependencies": {
"@aws-sdk/client-firehose": "3.933.0",
"@effect/platform-node": "catalog:",
@@ -919,7 +920,7 @@
},
"packages/theme": {
"name": "@opencode/theme",
"version": "2.0.23",
"version": "2.0.22",
"dependencies": {
"@opentui/core": "catalog:",
"effect": "catalog:",
@@ -933,7 +934,7 @@
},
"packages/tui": {
"name": "@opencode/tui",
"version": "2.0.23",
"version": "2.0.22",
"dependencies": {
"@opencode/client": "workspace:*",
"@opencode/core": "workspace:*",
@@ -967,7 +968,7 @@
},
"packages/ui": {
"name": "@opencode/ui",
"version": "2.0.23",
"version": "2.0.22",
"dependencies": {
"@kobalte/core": "catalog:",
"@pierre/diffs": "catalog:",
@@ -1002,7 +1003,7 @@
},
"packages/util": {
"name": "@opencode/util",
"version": "2.0.23",
"version": "2.0.22",
"dependencies": {
"@effect/opentelemetry": "catalog:",
"@effect/platform-node": "catalog:",
@@ -1040,7 +1041,7 @@
},
"packages/web": {
"name": "@opencode/web",
"version": "2.0.23",
"version": "2.0.22",
"dependencies": {
"@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1",
@@ -1081,7 +1082,7 @@
},
"services/update": {
"name": "@opencode/update",
"version": "2.0.23",
"version": "2.0.22",
"dependencies": {
"jose": "6.0.11",
"semver": "catalog:",
@@ -3326,6 +3327,8 @@
"@webgpu/types": ["@webgpu/types@0.1.54", "", {}, "sha512-81oaalC8LFrXjhsczomEQ0u3jG+TqE6V9QHLA8GNZq/Rnot0KDugu3LhSYSlie8tSdooAN1Hov05asrUUp9qgg=="],
"@workflow/serde": ["@workflow/serde@4.1.0", "", {}, "sha512-pav4F2BoirECWR7Nf1TKt+2eETcBj7jj4cBefQ8VXQCA6NPkaKeLfj/zMgi+3zYV5ZIBT4GuUiphsj0/b9hPQQ=="],
"@xmldom/xmldom": ["@xmldom/xmldom@0.8.14", "", {}, "sha512-T4EDRUBVZYRldYApjEJiU0e1stYWaRAX7CuSnKzrpwdZKo53zGV8/pqfzV6FfwNl9YThD2OumQYvqtvjvgG7aQ=="],
"@yuuang/ffi-rs-android-arm64": ["@yuuang/ffi-rs-android-arm64@1.3.7", "", { "os": "android", "cpu": "arm64" }, "sha512-t6Wx3Xll6c07Nuk0k3xnZsxKFxlshm92i0U/BiTHc6kQbvu+fMJF+gKsj4yEj886jH51CM3EqZT9Xdhq9CdUVw=="],
@@ -3366,6 +3369,8 @@
"agentkeepalive": ["agentkeepalive@4.6.0", "", { "dependencies": { "humanize-ms": "^1.2.1" } }, "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ=="],
"ai": ["ai@7.0.66", "", { "dependencies": { "@ai-sdk/gateway": "4.0.52", "@ai-sdk/provider": "4.0.7", "@ai-sdk/provider-utils": "5.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-wBUyoCYF3GVr+62nelBgR8YbpTSsMZrzFyOOjiwijylNSM2TFCW35C+Pml2vc59/WLMpyhS/LWZ55M+B9DAcSg=="],
"ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="],
"ajv-draft-04": ["ajv-draft-04@1.0.0", "", { "peerDependencies": { "ajv": "^8.5.0" }, "optionalPeers": ["ajv"] }, "sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw=="],
@@ -5718,6 +5723,8 @@
"validate-npm-package-name": ["validate-npm-package-name@7.0.2", "", {}, "sha512-hVDIBwsRruT73PbK7uP5ebUt+ezEtCmzZz3F59BSr2F6OVFnJ/6h8liuvdLrQ88Xmnk6/+xGGuq+pG9WwTuy3A=="],
"venice-ai-sdk-provider": ["venice-ai-sdk-provider@2.1.1", "", { "dependencies": { "@ai-sdk/openai-compatible": "^2.0.51", "@ai-sdk/provider": "^3.0.10", "@ai-sdk/provider-utils": "^4.0.30" }, "peerDependencies": { "ai": "^6.0.90" } }, "sha512-w3OHkuzzKZ3r2TOxER6myBYzZJNoDqol+DUHu3NnfBN/GETnUVxecZJab0CHQQ8GZc0jjzpFymepjcLDPS4SQg=="],
"vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="],
"vfile-location": ["vfile-location@5.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile": "^6.0.0" } }, "sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg=="],
@@ -6386,6 +6393,12 @@
"@vscode/emmet-helper/jsonc-parser": ["jsonc-parser@2.3.1", "", {}, "sha512-H8jvkz1O50L3dMZCsLqiuB2tA7muqbSg1AtGEkN0leAqGjsUzDJir3Zwr02BhqdcITPg3ei3mZ+HjMocAknhhg=="],
"ai/@ai-sdk/gateway": ["@ai-sdk/gateway@4.0.52", "", { "dependencies": { "@ai-sdk/provider": "4.0.7", "@ai-sdk/provider-utils": "5.0.27", "@vercel/oidc": "3.2.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-SXUM8jzzuTUJRq+EOgPd5to6DSx0EKslVn+IVZHbUEX6k/3vCPNrvjckbK26HnNxHU/STxm+zTSJteqrO+7Z0w=="],
"ai/@ai-sdk/provider": ["@ai-sdk/provider@4.0.7", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-6or44XprPzKbr8zkmzosowSE0pxkvJcoojBL+mCZvPUt3kvXp3XSNqeVun9golb1acEfSo6yaEBRT18h2VU+1Q=="],
"ai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@5.0.27", "", { "dependencies": { "@ai-sdk/provider": "4.0.7", "@standard-schema/spec": "^1.1.0", "@workflow/serde": "4.1.0", "eventsource-parser": "^3.0.8", "undici": "^7.28.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-EzAn4pdgG5g0xXtH6lE2zyNmfjDQIDjATkfqzuidEI35g++hh4+07vnjzkT/RmGmIClPZiRj/Q2GMPV2V7mkHw=="],
"ansi-align/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
"anymatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="],
@@ -6690,6 +6703,12 @@
"unzipper/fs-extra": ["fs-extra@11.3.1", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-eXvGGwZ5CL17ZSwHWd3bbgk7UUpF6IFHtP57NYYakPvHOs8GDgDe5KJI36jIJzDkJ6eJjuzRA8eBQb6SkKue0g=="],
"venice-ai-sdk-provider/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.69", "", { "dependencies": { "@ai-sdk/provider": "3.0.15", "@ai-sdk/provider-utils": "4.0.46" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-C99M0T0SpRkcCClmJxbQkpSqGmxLfh3NhTsNF3aNaUQZZ7oXN5sPWi9LGs49X5Q/r9FWxBYeZARXs15xxtGIig=="],
"venice-ai-sdk-provider/@ai-sdk/provider": ["@ai-sdk/provider@3.0.14", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-5X1k57JBJ4H7H1QjX7CnJYAB1I19r/trVZTMcSms7/kLNZ8RaU4Nt2agcwZzv82Hfx6Q7/TOLU7agAKeFfc8cA=="],
"venice-ai-sdk-provider/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.40", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-OL5IrpUm9Y8Dwy+w/vvFwPotS6m52O9W0op2oXgXdCROMJIBalBI0oro6OIBYkPxvm5Xg02GSkoQN25RlR0bnw=="],
"vite-plugin-dynamic-import/acorn": ["acorn@8.18.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ=="],
"vite-plugin-icons-spritesheet/chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="],
@@ -7238,6 +7257,8 @@
"@vitest/expect/@vitest/utils/@vitest/pretty-format": ["@vitest/pretty-format@3.2.4", "", { "dependencies": { "tinyrainbow": "^2.0.0" } }, "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA=="],
"ai/@ai-sdk/provider-utils/undici": ["undici@7.29.0", "", {}, "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw=="],
"ansi-align/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
"ansi-align/string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
@@ -7536,6 +7557,10 @@
"tw-to-css/tailwindcss/postcss": ["postcss@8.5.26", "", { "dependencies": { "nanoid": "^3.3.17", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ=="],
"venice-ai-sdk-provider/@ai-sdk/openai-compatible/@ai-sdk/provider": ["@ai-sdk/provider@3.0.15", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-XeZW1CcDF2GMbH4wejW6xBRI2QCOgnkVYUnxoeDadB1mf85riL2bMUeDoh+6gJ/r4mjNfzUPW8OjLjvwTP0u1Q=="],
"venice-ai-sdk-provider/@ai-sdk/openai-compatible/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.46", "", { "dependencies": { "@ai-sdk/provider": "3.0.15", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8", "undici": "^6.28.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-tEtld97plCFiYevsJuOkGkeuhQndeMWFBVrJS4AjnbD5AqrNSXRCe0p+BZ3Cju/sxDeeZ9ym3q9YUV8fASA7aQ=="],
"vitest/@vitest/expect/chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="],
"vscode-languageserver/vscode-languageserver-protocol/vscode-jsonrpc": ["vscode-jsonrpc@8.2.0", "", {}, "sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA=="],
@@ -8414,6 +8439,8 @@
"tw-to-css/tailwindcss/chokidar/readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="],
"venice-ai-sdk-provider/@ai-sdk/openai-compatible/@ai-sdk/provider-utils/undici": ["undici@6.28.0", "", {}, "sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA=="],
"yargs/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
"@astrojs/cloudflare/@cloudflare/vite-plugin/miniflare/sharp/@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.35.2", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.3.1" }, "os": "darwin", "cpu": "arm64" }, "sha512-eEieHsMksAW4IiO5NzauESRl2D2qz3J/kwUxUrSfV06A93eEaRfMpHXyUb1mAqrR7i8U9A0GRqE9pjn6u1Jjpg=="],
+3 -3
View File
@@ -1,7 +1,7 @@
{
"nodeModules": {
"x86_64-linux": "sha256-4AqU8dPEwo0ukoyEX1SuDVzsXQ7G/fIPnxoL400zH4M=",
"aarch64-linux": "sha256-p75sJYtR8a4oVKb1+Dgp8Kl+1onSmUw5NRv95bbV1+E=",
"aarch64-darwin": "sha256-Psyvbw/lGulysQlhK/MqcHVIwx2aCDTr+Pfr+gRWsG4="
"x86_64-linux": "sha256-2RlbJRTEKSliuUbAE2lAktX63JFR/RKAuLkcCou8wb4=",
"aarch64-linux": "sha256-P7DAE018lTJNGmttg87U9oaTAuw/wHlnMStdnXYKCr0=",
"aarch64-darwin": "sha256-N+NfV1ObOTnW+ez7As+CS+6ci/iRGCQ6cslvyQFCp6E="
}
}
+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.23",
"version": "2.0.22",
"private": true,
"type": "module",
"packageManager": "bun@1.4.2",
@@ -12,7 +12,7 @@
"dev:vite": "bun run --cwd packages/cli --conditions=browser dev/vite.ts",
"dev:vite:live": "sh -c 'OPENCODE_TUI_CHANNEL=dev OPENCODE_PASSWORD=\"$(opencode service get password)\" exec bun run dev:vite \"$@\" --server \"$(opencode service status)\"' --",
"dev:desktop": "bun --cwd packages/desktop dev",
"dev:web": "bun --cwd packages/app dev",
"dev:web": "VITE_OPENCODE_SERVER_MODE=none bun --cwd packages/app dev",
"dev:console": "ulimit -n 10240 2>/dev/null; bun run --cwd packages/console/app dev",
"dev:stats": "bun sst shell --stage=production -- bun run --cwd packages/stats/app dev",
"dev:www": "bun run --cwd services/www dev",
-35
View File
@@ -106,41 +106,6 @@ for await (const event of ai.llm.stream(ai.llm.request(input))) {
await ai.dispose()
```
## Venice AI
`Venice` provides native Chat Completions with streaming tools and reasoning. `model` and `chat`
select the same API; credentials default to `VENICE_API_KEY`.
```ts
import { LLM } from "@opencode/ai"
import { Venice } from "@opencode/ai/providers"
import { Effect } from "effect"
const program = Effect.gen(function* () {
const response = yield* LLM.generate({
model: Venice.configure({ apiKey: process.env.VENICE_API_KEY }).chat("qwen3-6-27b"),
prompt: "Explain this design.",
providerOptions: {
reasoningEffort: "high",
veniceParameters: { includeVeniceSystemPrompt: false },
},
})
console.log(response.text)
})
```
Effort lowers to `reasoning.effort`; `reasoning.enabled` and `reasoning.summary` are also available.
Supported effort levels and toggles depend on the selected model. Omitted controls preserve its defaults.
Venice's added system prompt is disabled by default, matching the previous OpenCode Venice SDK behavior.
Replay complete `response.message` values to retain signed/encrypted reasoning and Gemini thought
signatures, including per-tool signatures. Venice's encrypted scalar trailers are excluded from visible
reasoning but retained in provider metadata for replay. Cache affinity uses `promptCacheKey`, and cache-write
usage reads Venice's `cache_creation_input_tokens` field.
The native package entrypoint is `@opencode/ai/providers/venice`. Image generation, embeddings, Responses, and
client-side E2EE are not implemented by this provider.
## Experimental evaluation
Evaluation models compare shared state with typed choice, score, and boolean questions. The API is
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "2.0.23",
"version": "2.0.22",
"name": "@opencode/ai",
"type": "module",
"license": "MIT",
+88 -97
View File
@@ -422,74 +422,57 @@ const AnthropicUsage = Schema.StructWithRest(
)
type AnthropicUsage = Schema.Schema.Type<typeof AnthropicUsage>
const AnthropicStreamBlock = Schema.StructWithRest(
Schema.Struct({
type: Schema.String,
id: Schema.optional(Schema.String),
name: Schema.optional(Schema.String),
text: Schema.optional(Schema.String),
thinking: Schema.optional(Schema.String),
signature: Schema.optional(Schema.String),
// redacted_thinking blocks arrive whole in content_block_start with the
// encrypted payload in `data`; there is no streaming delta sequence.
data: Schema.optional(Schema.String),
input: Schema.optional(Schema.Unknown),
// *_tool_result blocks arrive whole as content_block_start (no streaming
// delta) with the structured payload in `content` and the originating
// server_tool_use id in `tool_use_id`.
tool_use_id: Schema.optional(Schema.String),
content: Schema.optional(Schema.Unknown),
}),
[JsonObject],
)
const AnthropicStreamBlock = Schema.Struct({
type: Schema.String,
id: Schema.optional(Schema.String),
name: Schema.optional(Schema.String),
text: Schema.optional(Schema.String),
thinking: Schema.optional(Schema.String),
signature: Schema.optional(Schema.String),
// redacted_thinking blocks arrive whole in content_block_start with the
// encrypted payload in `data`; there is no streaming delta sequence.
data: Schema.optional(Schema.String),
input: Schema.optional(Schema.Unknown),
// *_tool_result blocks arrive whole as content_block_start (no streaming
// delta) with the structured payload in `content` and the originating
// server_tool_use id in `tool_use_id`.
tool_use_id: Schema.optional(Schema.String),
content: Schema.optional(Schema.Unknown),
})
type AnthropicStreamBlock = Schema.Schema.Type<typeof AnthropicStreamBlock>
const decodeAnthropicStreamBlock = Schema.decodeUnknownOption(AnthropicStreamBlock)
const AnthropicStreamDelta = Schema.StructWithRest(
Schema.Struct({
content: optionalNull(Schema.String),
type: Schema.optional(Schema.String),
text: Schema.optional(Schema.String),
thinking: Schema.optional(Schema.String),
partial_json: Schema.optional(Schema.String),
signature: Schema.optional(Schema.String),
stop_reason: optionalNull(Schema.String),
stop_sequence: optionalNull(Schema.String),
stop_details: optionalNull(
Schema.StructWithRest(
Schema.Struct({ category: optionalNull(Schema.String), explanation: optionalNull(Schema.String) }),
[JsonObject],
),
),
}),
[JsonObject],
)
const AnthropicStreamDelta = Schema.Struct({
content: optionalNull(Schema.String),
type: Schema.optional(Schema.String),
text: Schema.optional(Schema.String),
thinking: Schema.optional(Schema.String),
partial_json: Schema.optional(Schema.String),
signature: Schema.optional(Schema.String),
stop_reason: optionalNull(Schema.String),
stop_sequence: optionalNull(Schema.String),
stop_details: optionalNull(
Schema.Struct({ category: optionalNull(Schema.String), explanation: optionalNull(Schema.String) }),
),
})
type AnthropicStreamDelta = Schema.Schema.Type<typeof AnthropicStreamDelta>
const decodeAnthropicStreamDelta = Schema.decodeUnknownOption(AnthropicStreamDelta)
const AnthropicEvent = Schema.StructWithRest(
Schema.Struct({
type: Schema.String,
index: Schema.optional(Schema.Number),
message: Schema.optional(
Schema.StructWithRest(Schema.Struct({ usage: Schema.optional(AnthropicUsage) }), [JsonObject]),
),
content_block: Schema.optional(Schema.Unknown),
delta: Schema.optional(Schema.Unknown),
usage: Schema.optional(AnthropicUsage),
// `type` and `message` are both required per Anthropic's spec, but
// OpenAI-compatible proxies and gateway translations occasionally drop one
// or the other; mark them optional so a partial payload still parses and
// the parser can fall back to whichever field is populated.
error: Schema.optional(
Schema.StructWithRest(
Schema.Struct({ type: Schema.optional(Schema.String), message: Schema.optional(Schema.String) }),
[JsonObject],
),
),
}),
[JsonObject],
)
const AnthropicEvent = Schema.Struct({
type: Schema.String,
index: Schema.optional(Schema.Number),
message: Schema.optional(Schema.Struct({ usage: Schema.optional(AnthropicUsage) })),
content_block: Schema.optional(Schema.Unknown),
delta: Schema.optional(Schema.Unknown),
usage: Schema.optional(AnthropicUsage),
// `type` and `message` are both required per Anthropic's spec, but
// OpenAI-compatible proxies and gateway translations occasionally drop one
// or the other; mark them optional so a partial payload still parses and
// the parser can fall back to whichever field is populated.
error: Schema.optional(
Schema.Struct({ type: Schema.optional(Schema.String), message: Schema.optional(Schema.String) }),
),
})
type AnthropicEvent = Schema.Schema.Type<typeof AnthropicEvent>
interface ParserState {
@@ -601,7 +584,10 @@ const serverToolResultType = (name: string): AnthropicServerToolResultType | und
return undefined
}
const lowerServerToolResult = Effect.fnUntraced(function* (part: ToolResultPart, providerMetadataKey: string) {
const lowerServerToolResult = Effect.fnUntraced(function* (
part: ToolResultPart,
providerMetadataKey: string,
) {
const wireType = serverToolResultType(part.name)
if (!wireType)
return yield* invalid(`Anthropic Messages does not know how to round-trip server tool result for ${part.name}`)
@@ -671,7 +657,10 @@ const citationsFromMetadata = (metadata: MediaPart["metadata"]): AnthropicDocume
const isHttpUrl = (value: string) => /^https?:\/\//i.test(value.trim())
const lowerMedia = Effect.fnUntraced(function* (part: MediaPart, breakpoints?: Cache.Breakpoints) {
const lowerMedia = Effect.fnUntraced(function* (
part: MediaPart,
breakpoints?: Cache.Breakpoints,
) {
const mime = part.media.mediaType.toLowerCase()
const cacheControlValue = breakpoints ? cacheControl(breakpoints, part.cache) : undefined
const fileId = fileIdFromMetadata(part.metadata)
@@ -815,6 +804,9 @@ const requireThinkingSignature = (request: LLMRequest) => {
return true
}
// Mid-conversation system messages became available with Opus 4.8 and version
// 5 of the other supported Claude families. Treat later family versions as
// compatible without assuming that every Anthropic Messages model is Claude.
// Opus 4.8 and every Claude 5 model accept mid-conversation system messages; later versions inherit support.
const supportsNativeSystemUpdates = (request: LLMRequest) => {
const version = claudeVersion(String(request.model.id))
@@ -823,9 +815,25 @@ const supportsNativeSystemUpdates = (request: LLMRequest) => {
return version.major >= 5
}
// Native system messages must follow a user turn (tool results count) or a paused server-tool turn.
const acceptsNativeSystemAfter = (message: AnthropicMessage | undefined) =>
message?.role === "user" || (message?.role === "assistant" && message.content.at(-1)?.type === "server_tool_use")
const endsInServerToolUse = (message: LLMRequest["messages"][number]) => {
const last = message.content.at(-1)
return message.role === "assistant" && last?.type === "tool-call" && last.providerExecuted === true
}
const canUseNativeSystemUpdate = (request: LLMRequest, index: number) => {
const previous = request.messages[index - 1]
const next = request.messages[index + 1]
// Vertex currently rejects/404s for a system message after local tool results,
// so fold it into the user tool-result turn across continuations and history.
if (request.model.route.id === "google-vertex-messages" && previous?.role === "tool") return false
return (
previous !== undefined &&
previous.role !== "system" &&
(previous.role === "user" || previous.role === "tool" || endsInServerToolUse(previous)) &&
next?.role !== "system" &&
(next === undefined || next.role === "assistant")
)
}
const splitsLocalToolResults = (messages: LLMRequest["messages"], index: number) => {
const pending = new Set<string>()
@@ -854,34 +862,12 @@ const lowerNativeSystemUpdate = Effect.fnUntraced(function* (
}
})
const lowerWrappedSystemUpdate = Effect.fnUntraced(function* (
message: LLMRequest["messages"][number],
const lowerMessages = Effect.fnUntraced(function* (
request: LLMRequest,
breakpoints: Cache.Breakpoints,
) {
const part = yield* ProviderShared.wrappedSystemUpdate("Anthropic Messages", message)
return { type: "text" as const, text: part.text, cache_control: cacheControl(breakpoints, part.cache) }
})
const appendToUserTurn = (messages: AnthropicMessage[], block: AnthropicUserBlock) => {
const last = messages.at(-1)
if (last?.role === "user") messages[messages.length - 1] = { role: "user", content: [...last.content, block] }
else messages.push({ role: "user", content: [block] })
}
const lowerMessages = Effect.fnUntraced(function* (request: LLMRequest, breakpoints: Cache.Breakpoints) {
const messages: AnthropicMessage[] = []
const providerMetadataKey = request.model.route.providerMetadataKey ?? String(request.model.provider)
// Text updates stay where they are unless a user turn follows them; then they move after the latest
// user turn, the nearest spot where Anthropic accepts a native system message.
const holdUpdates = supportsNativeSystemUpdates(request)
const held: Array<LLMRequest["messages"][number]> = []
const releaseHeld = Effect.fnUntraced(function* () {
const native = acceptsNativeSystemAfter(messages.findLast((message) => message.role !== "system"))
for (const update of held.splice(0)) {
if (native) messages.push(yield* lowerNativeSystemUpdate(update, breakpoints))
else appendToUserTurn(messages, yield* lowerWrappedSystemUpdate(update, breakpoints))
}
})
for (const [index, message] of request.messages.entries()) {
if (message.role === "system") {
@@ -893,8 +879,16 @@ const lowerMessages = Effect.fnUntraced(function* (request: LLMRequest, breakpoi
}
if (splitsLocalToolResults(request.messages, index))
return yield* invalid("Anthropic Messages system updates cannot split a local tool call from its tool result")
if (holdUpdates) held.push(message)
else appendToUserTurn(messages, yield* lowerWrappedSystemUpdate(message, breakpoints))
if (supportsNativeSystemUpdates(request) && canUseNativeSystemUpdate(request, index)) {
messages.push(yield* lowerNativeSystemUpdate(message, breakpoints))
continue
}
const part = yield* ProviderShared.wrappedSystemUpdate("Anthropic Messages", message)
const block = { type: "text" as const, text: part.text, cache_control: cacheControl(breakpoints, part.cache) }
const previous = messages.at(-1)
if (previous?.role === "user")
messages[messages.length - 1] = { role: "user", content: [...previous.content, block] }
else messages.push({ role: "user", content: [block] })
continue
}
@@ -970,9 +964,7 @@ const lowerMessages = Effect.fnUntraced(function* (request: LLMRequest, breakpoi
`Anthropic Messages assistant messages only support text, reasoning, and tool-call content for now`,
)
}
if (content.length === 0) continue
yield* releaseHeld()
messages.push({ role: "assistant", content })
if (content.length > 0) messages.push({ role: "assistant", content })
continue
}
@@ -993,7 +985,6 @@ const lowerMessages = Effect.fnUntraced(function* (request: LLMRequest, breakpoi
messages[messages.length - 1] = { role: "user", content: [...previous.content, ...content] }
else messages.push({ role: "user", content })
}
yield* releaseHeld()
return messages
})
+12 -20
View File
@@ -301,26 +301,18 @@ export const OpenResponsesBody = Schema.Struct({
})
export type OpenResponsesBody = Schema.Schema.Type<typeof OpenResponsesBody>
export const OpenResponsesUsage = Schema.StructWithRest(
Schema.Struct({
input_tokens: Schema.optional(Schema.Number),
input_tokens_details: optionalNull(
Schema.StructWithRest(
Schema.Struct({
cached_tokens: Schema.optional(Schema.Number),
cache_write_tokens: Schema.optional(Schema.Number),
}),
[JsonObject],
),
),
output_tokens: Schema.optional(Schema.Number),
output_tokens_details: optionalNull(
Schema.StructWithRest(Schema.Struct({ reasoning_tokens: Schema.optional(Schema.Number) }), [JsonObject]),
),
total_tokens: Schema.optional(Schema.Number),
}),
[JsonObject],
)
export const OpenResponsesUsage = Schema.Struct({
input_tokens: Schema.optional(Schema.Number),
input_tokens_details: optionalNull(
Schema.Struct({
cached_tokens: Schema.optional(Schema.Number),
cache_write_tokens: Schema.optional(Schema.Number),
}),
),
output_tokens: Schema.optional(Schema.Number),
output_tokens_details: optionalNull(Schema.Struct({ reasoning_tokens: Schema.optional(Schema.Number) })),
total_tokens: Schema.optional(Schema.Number),
})
type OpenResponsesUsage = Schema.Schema.Type<typeof OpenResponsesUsage>
// The spec requires `id` on every output item, but some gateways drop it from
+9 -13
View File
@@ -65,7 +65,7 @@ const ExtraContent = Schema.Struct({
})
const decodeExtraContent = (value: unknown) => Option.getOrUndefined(Schema.decodeUnknownOption(ExtraContent)(value))
export const OpenAIChatAssistantToolCall = Schema.Struct({
const OpenAIChatAssistantToolCall = Schema.Struct({
id: Schema.String,
type: Schema.tag("function"),
function: Schema.Struct({
@@ -134,7 +134,7 @@ const OpenAIChatUserContent = Schema.Union([
])
type OpenAIChatUserContent = Schema.Schema.Type<typeof OpenAIChatUserContent>
export const OpenAIChatMessage = Schema.Union([
const OpenAIChatMessage = Schema.Union([
Schema.Struct({
role: Schema.Literal("system"),
content: Schema.Union([Schema.String, Schema.Array(OpenAIChatUserContent)]),
@@ -200,7 +200,7 @@ export type OpenAIChatBody = Schema.Schema.Type<typeof OpenAIChatBody>
// The event schema is one decoded SSE `data:` payload. `Framing.sse` splits the
// byte stream into strings, then `Protocol.jsonEvent` decodes each string into
// this provider-native event shape.
export const OpenAIChatUsage = Schema.StructWithRest(
const OpenAIChatUsage = Schema.StructWithRest(
Schema.Struct({
prompt_tokens: optionalNull(Schema.Number),
completion_tokens: optionalNull(Schema.Number),
@@ -238,7 +238,7 @@ const OpenAIChatToolCallDeltaFunction = Schema.Struct({
arguments: optionalNull(Schema.String),
})
export const OpenAIChatToolCallDelta = Schema.Struct({
const OpenAIChatToolCallDelta = Schema.Struct({
index: optionalNull(Schema.Number),
id: optionalNull(Schema.String),
function: optionalNull(OpenAIChatToolCallDeltaFunction),
@@ -246,7 +246,7 @@ export const OpenAIChatToolCallDelta = Schema.Struct({
})
type OpenAIChatToolCallDelta = Schema.Schema.Type<typeof OpenAIChatToolCallDelta>
export const OpenAIChatDelta = Schema.StructWithRest(
const OpenAIChatDelta = Schema.StructWithRest(
Schema.Struct({
content: optionalNull(Schema.String),
refusal: optionalNull(Schema.String),
@@ -259,7 +259,7 @@ export const OpenAIChatDelta = Schema.StructWithRest(
[Schema.Record(Schema.String, Schema.Unknown)],
)
export const OpenAIChatChoice = Schema.StructWithRest(
const OpenAIChatChoice = Schema.StructWithRest(
Schema.Struct({
delta: optionalNull(OpenAIChatDelta),
finish_reason: optionalNull(Schema.String),
@@ -326,8 +326,6 @@ export interface ParserState {
interface LoweringOptions {
readonly cacheControl?: (cache: CacheHint | undefined) => OpenAIChatCacheControl | undefined
readonly toolCallID?: (id: string) => string
/** Project provider-specific fields from the exact source, even when other messages are dropped during lowering. */
readonly assistant?: (source: LLMRequest["messages"][number], message: OpenAIChatMessage) => OpenAIChatMessage
}
const lowerTool = (tool: ToolDefinition, options: LoweringOptions, supportsStrictMode: boolean): OpenAIChatTool => ({
@@ -541,10 +539,8 @@ const lowerMessage = Effect.fnUntraced(function* (
options: LoweringOptions & { readonly providerMetadataKey: string },
) {
if (message.role === "user") return [yield* lowerUserMessage(message, options)]
if (message.role === "assistant") {
const lowered = yield* lowerAssistantMessage(message, reasoningField, requireReasoning, options)
return [options.assistant?.(message, lowered) ?? lowered]
}
if (message.role === "assistant")
return [yield* lowerAssistantMessage(message, reasoningField, requireReasoning, options)]
return (yield* lowerToolMessages(message, options)).messages
})
@@ -1218,7 +1214,7 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
] as const
})
export const finishEvents = Effect.fnUntraced(function* (state: ParserState) {
const finishEvents = Effect.fnUntraced(function* (state: ParserState) {
if (state.finishReason === undefined && state.requireFinishReason)
return yield* new AIError({
reason: new InvalidProviderOutputError({
-374
View File
@@ -1,374 +0,0 @@
import { Effect, Schema } from "effect"
import { Protocol } from "../route/protocol.js"
import { AIError, LLMEvent, type LanguageModelCompatibility, type LLMRequest } from "../schema/index.js"
import { classifyProviderFailure } from "../provider-error.js"
import { OpenAIChat } from "./openai-chat.js"
import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared.js"
import { cacheControl } from "./utils/cache.js"
// ---------------------------------------------------------------------------
// Public options and request body
// ---------------------------------------------------------------------------
export type ReasoningEffort = "none" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max" | (string & {})
const Parameters = Schema.Struct({
enableWebSearch: Schema.optional(Schema.String),
enableWebScraping: Schema.optional(Schema.Boolean),
enableWebCitations: Schema.optional(Schema.Boolean),
enableXSearch: Schema.optional(Schema.Boolean),
stripThinkingResponse: Schema.optional(Schema.Boolean),
disableThinking: Schema.optional(Schema.Boolean),
includeVeniceSystemPrompt: Schema.optional(Schema.Boolean),
characterSlug: Schema.optional(Schema.String),
includeSearchResultsInStream: Schema.optional(Schema.Boolean),
returnSearchResultsAsDocuments: Schema.optional(Schema.Boolean),
})
const Reasoning = Schema.Struct({
effort: Schema.optional(Schema.String),
enabled: Schema.optional(Schema.Boolean),
summary: Schema.optional(Schema.String),
})
export type OptionsInput = {
readonly reasoningEffort?: ReasoningEffort
readonly reasoning?: {
readonly effort?: ReasoningEffort
readonly enabled?: boolean
readonly summary?: "auto" | "concise" | "detailed" | (string & {})
}
readonly veniceParameters?: Omit<typeof Parameters.Type, "enableWebSearch"> & {
readonly enableWebSearch?: "off" | "on" | "auto" | (string & {})
}
readonly promptCacheKey?: string
readonly promptCacheRetention?: "default" | "extended" | "24h" | (string & {})
readonly parallelToolCalls?: boolean
readonly maxCompletionTokens?: number
readonly maxTokens?: number
readonly minP?: number
readonly repetitionPenalty?: number
readonly stopTokenIds?: readonly number[]
readonly logprobs?: boolean
readonly topLogprobs?: number
readonly maxTemp?: number
readonly minTemp?: number
readonly responseFormat?: Readonly<Record<string, unknown>>
readonly user?: string
}
const Options = Schema.Struct({
reasoningEffort: Schema.optional(Schema.String),
reasoning: Schema.optional(Reasoning),
veniceParameters: Schema.optional(Parameters),
promptCacheKey: Schema.optional(Schema.String),
promptCacheRetention: Schema.optional(Schema.String),
parallelToolCalls: Schema.optional(Schema.Boolean),
maxCompletionTokens: Schema.optional(Schema.Number),
maxTokens: Schema.optional(Schema.Number),
minP: Schema.optional(Schema.Number),
repetitionPenalty: Schema.optional(Schema.Number),
stopTokenIds: Schema.optional(Schema.Array(Schema.Number)),
logprobs: Schema.optional(Schema.Boolean),
topLogprobs: Schema.optional(Schema.Number),
maxTemp: Schema.optional(Schema.Number),
minTemp: Schema.optional(Schema.Number),
responseFormat: Schema.optional(JsonObject),
user: Schema.optional(Schema.String),
})
const ToolCall = Schema.Struct({
...OpenAIChat.OpenAIChatAssistantToolCall.fields,
thought_signature: Schema.optional(Schema.String),
})
const Assistant = Schema.StructWithRest(
Schema.Struct({
...OpenAIChat.OpenAIChatMessage.cases.assistant.schema.fields,
tool_calls: optionalArray(ToolCall),
thought_signature: Schema.optional(Schema.String),
}),
[Schema.Record(Schema.String, Schema.Unknown)],
)
const Messages = Schema.Array(
Schema.Union([
OpenAIChat.OpenAIChatMessage.cases.system,
OpenAIChat.OpenAIChatMessage.cases.user,
Assistant,
OpenAIChat.OpenAIChatMessage.cases.tool,
]),
)
const Body = Schema.Struct({
...OpenAIChat.bodyFields,
messages: Messages,
reasoning: Schema.optional(Reasoning),
venice_parameters: Schema.Record(Schema.String, Schema.Union([Schema.String, Schema.Boolean])),
prompt_cache_retention: Options.fields.promptCacheRetention,
parallel_tool_calls: Options.fields.parallelToolCalls,
min_p: Options.fields.minP,
top_k: Schema.optional(Schema.Number),
repetition_penalty: Options.fields.repetitionPenalty,
stop_token_ids: Options.fields.stopTokenIds,
logprobs: Options.fields.logprobs,
top_logprobs: Options.fields.topLogprobs,
max_temp: Options.fields.maxTemp,
min_temp: Options.fields.minTemp,
response_format: Options.fields.responseFormat,
user: Options.fields.user,
})
// ---------------------------------------------------------------------------
// Streaming schemas and state
// ---------------------------------------------------------------------------
const ToolDelta = Schema.Struct({
...OpenAIChat.OpenAIChatToolCallDelta.fields,
thought_signature: optionalNull(Schema.String),
})
const Delta = Schema.StructWithRest(
Schema.Struct({
...OpenAIChat.OpenAIChatDelta.schema.fields,
tool_calls: optionalNull(Schema.Array(ToolDelta)),
thought_signature: optionalNull(Schema.String),
}),
[Schema.Record(Schema.String, Schema.Unknown)],
)
const Choice = Schema.StructWithRest(
Schema.Struct({ ...OpenAIChat.OpenAIChatChoice.schema.fields, delta: optionalNull(Delta) }),
[Schema.Record(Schema.String, Schema.Unknown)],
)
const Usage = Schema.StructWithRest(
Schema.Struct({
...OpenAIChat.OpenAIChatUsage.schema.fields,
prompt_tokens_details: optionalNull(
Schema.StructWithRest(
Schema.Struct({
cached_tokens: optionalNull(Schema.Number),
cache_write_tokens: optionalNull(Schema.Number),
cache_creation_input_tokens: optionalNull(Schema.Number),
}),
[Schema.Record(Schema.String, Schema.Unknown)],
),
),
}),
[Schema.Record(Schema.String, Schema.Unknown)],
)
const Event = Schema.StructWithRest(
Schema.Struct({
...OpenAIChat.OpenAIChatEvent.schema.fields,
choices: optionalNull(Schema.Array(Choice)),
usage: optionalNull(Usage),
error: optionalNull(Schema.Union([Schema.String, OpenAIChat.OpenAIChatEvent.schema.fields.error.schema])),
issues: optionalArray(
Schema.Struct({
message: Schema.String,
path: Schema.optional(Schema.Array(Schema.Union([Schema.String, Schema.Number]))),
}),
),
}),
[Schema.Record(Schema.String, Schema.Unknown)],
)
interface State {
readonly shared: OpenAIChat.ParserState
readonly pending: string
readonly reasoning: string
readonly encrypted: boolean
readonly signature?: string
readonly tools: Readonly<Record<string, string>>
}
const MARKER = "__ENCRYPTED_REASONING__"
// ---------------------------------------------------------------------------
// Request lowering
// ---------------------------------------------------------------------------
const fromRequest = Effect.fn("VeniceChat.fromRequest")(function* (request: LLMRequest) {
const options = yield* ProviderShared.validateWith(Schema.decodeUnknownEffect(Options))(request.providerOptions ?? {})
const body = yield* OpenAIChat.fromRequest(request, {
cacheControl: cacheControl(),
assistant: (source, message) => {
if (message.role !== "assistant") return message
const parts = source.content.filter(
(part) => part.type === "text" || part.type === "reasoning" || part.type === "tool-call",
)
const calls = source.content.filter((part) => part.type === "tool-call")
const signature = parts
.map((part) => part.providerMetadata?.venice?.messageThoughtSignature)
.find((value) => typeof value === "string")
const raw = parts
.map((part) => part.providerMetadata?.venice?.encryptedReasoningContent)
.find((value) => typeof value === "string")
return {
...message,
...(signature === undefined ? {} : { thought_signature: signature }),
...(raw === undefined ? {} : { reasoning_content: raw }),
tool_calls: message.tool_calls?.map((call, index) => {
const signature = calls[index]?.providerMetadata?.venice?.thoughtSignature
return { ...call, ...(typeof signature === "string" ? { thought_signature: signature } : {}) }
}),
}
},
})
return {
...body,
max_completion_tokens: options.maxCompletionTokens ?? options.maxTokens ?? request.generation?.maxTokens,
reasoning_effort: undefined,
reasoning:
options.reasoningEffort === undefined
? options.reasoning
: { ...options.reasoning, effort: options.reasoningEffort },
// Match the previous Venice SDK default, not the gateway's added prompt.
venice_parameters: Object.fromEntries(
Object.entries({
...options.veniceParameters,
includeVeniceSystemPrompt: options.veniceParameters?.includeVeniceSystemPrompt ?? false,
})
.filter(([, value]) => value !== undefined)
.map(([key, value]) => [key.replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`), value]),
),
prompt_cache_retention: options.promptCacheRetention,
parallel_tool_calls: options.parallelToolCalls,
min_p: options.minP,
top_k: request.generation?.topK,
repetition_penalty: options.repetitionPenalty,
stop_token_ids: options.stopTokenIds,
logprobs: options.logprobs,
top_logprobs: options.topLogprobs,
max_temp: options.maxTemp,
min_temp: options.minTemp,
response_format: options.responseFormat,
user: options.user,
}
})
// ---------------------------------------------------------------------------
// Venice normalization around the shared Chat state machine
// ---------------------------------------------------------------------------
const step = Effect.fn("VeniceChat.step")(function* (state: State, event: typeof Event.Type) {
if (typeof event.error === "string")
return yield* new AIError({
reason: classifyProviderFailure({
message: [
event.error,
...(event.issues ?? []).map(
(issue) => `${issue.path?.length ? `${issue.path.join(".")}: ` : ""}${issue.message}`,
),
].join("; "),
rawBody: ProviderShared.encodeJson(event),
}),
})
const delta = event.choices?.[0]?.delta
const scalar = delta?.reasoning_content ?? ""
const text = state.pending + scalar
const marker = text.indexOf(MARKER)
const pending =
marker >= 0 || state.encrypted
? 0
: (Array.from({ length: Math.min(text.length, MARKER.length - 1) }, (_, index) => index + 1)
.filter((length) => text.endsWith(MARKER.slice(0, length)))
.at(-1) ?? 0)
const visible = state.encrypted ? "" : marker >= 0 ? text.slice(0, marker) : text.slice(0, text.length - pending)
const result = yield* OpenAIChat.protocol.stream.step(state.shared, {
...event,
error: event.error,
usage: event.usage
? {
...event.usage,
prompt_tokens_details: event.usage.prompt_tokens_details
? {
...event.usage.prompt_tokens_details,
cache_write_tokens:
event.usage.prompt_tokens_details.cache_write_tokens ??
event.usage.prompt_tokens_details.cache_creation_input_tokens,
}
: event.usage.prompt_tokens_details,
}
: event.usage,
choices: event.choices?.map((choice, index) =>
index === 0 && choice.delta
? {
...choice,
delta: {
...choice.delta,
reasoning_content: visible || undefined,
// Opaque-only scalar reasoning still needs a canonical part for replay.
reasoning_details: choice.delta.reasoning_details ?? (marker >= 0 ? [] : undefined),
},
}
: choice,
),
})
const tools = { ...state.tools }
for (const call of delta?.tool_calls ?? []) {
if (!call.thought_signature) continue
const index = call.index ?? result[0].latestToolIndex
const id =
call.id ?? (index === undefined ? undefined : (result[0].tools[index]?.id ?? result[0].pendingTools[index]?.id))
if (id) tools[id] = call.thought_signature
}
return [
{
shared: result[0],
pending: pending ? text.slice(-pending) : "",
reasoning: state.reasoning + scalar,
encrypted: state.encrypted || marker >= 0,
signature: delta?.thought_signature ?? state.signature,
tools,
},
result[1],
] as const
})
const onHalt = Effect.fn("VeniceChat.onHalt")(function* (state: State) {
const events = yield* OpenAIChat.finishEvents(state.shared)
return events.flatMap((event): LLMEvent[] => {
if (
event.type !== "reasoning-end" &&
event.type !== "text-end" &&
event.type !== "tool-call" &&
event.type !== "tool-input-end"
)
return [event]
const signature = event.type === "tool-call" || event.type === "tool-input-end" ? state.tools[event.id] : undefined
const providerMetadata = {
...event.providerMetadata,
venice: {
...event.providerMetadata?.venice,
...(state.signature ? { messageThoughtSignature: state.signature } : {}),
...(signature ? { thoughtSignature: signature } : {}),
...(event.type === "reasoning-end" && state.encrypted ? { encryptedReasoningContent: state.reasoning } : {}),
},
}
if (event.type === "reasoning-end" && state.pending)
return [LLMEvent.reasoningDelta({ id: event.id, text: state.pending }), { ...event, providerMetadata }]
return [{ ...event, providerMetadata }]
})
})
export const compatibility = {
maxTokensField: "max_completion_tokens",
supportsStore: false,
supportsPromptCacheKey: true,
} satisfies LanguageModelCompatibility
export const protocol = Protocol.make({
id: "venice-chat",
body: { schema: Body, from: fromRequest },
stream: {
event: Schema.Union([Schema.Literal("[DONE]"), Protocol.jsonEvent(Event)]),
initial: (request): State => ({
shared: OpenAIChat.protocol.stream.initial(request),
pending: "",
reasoning: "",
encrypted: false,
tools: {},
}),
step: (state: State, event) => (event === "[DONE]" ? Effect.succeed([state, []] as const) : step(state, event)),
terminal: (event) => event === "[DONE]",
onHalt,
},
})
export * as VeniceChat from "./venice-chat.js"
@@ -54,7 +54,6 @@ const route = Route.make({
),
},
stream: AnthropicMessages.protocol.stream,
supportsEffortUpdates: AnthropicMessages.protocol.supportsEffortUpdates,
}),
endpoint: Endpoint.path(({ request }) => `/${request.model.id}:streamRawPredict`),
auth: Auth.none,
-1
View File
@@ -40,7 +40,6 @@ export * as Stability from "./stability.js"
export * as TogetherAI from "./togetherai.js"
export * as TypeSafeAI from "./typesafe-ai.js"
export * as VercelAIGateway from "./vercel-ai-gateway.js"
export * as Venice from "./venice.js"
export * as XAI from "./xai.js"
export * as ZAI from "./zai.js"
export * as ZAICodingPlan from "./zai-coding-plan.js"
-62
View File
@@ -1,62 +0,0 @@
import type { ProviderPackage } from "../provider-package.js"
import { VeniceChat } from "../protocols/venice-chat.js"
import { OpenAIChat } from "../protocols/openai-chat.js"
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options.js"
import { Route, type RouteDefaultsInput } from "../route/client.js"
import { Endpoint } from "../route/endpoint.js"
import { ProviderID, type ModelID } from "../schema/index.js"
export const id = ProviderID.make("venice")
export type ChatOptionsInput = VeniceChat.OptionsInput
export type Config = Omit<RouteDefaultsInput, "providerOptions"> &
ProviderAuthOption<"optional"> & {
readonly baseURL?: string
readonly queryParams?: Readonly<Record<string, string>>
readonly providerOptions?: ChatOptionsInput
}
export type Settings = ProviderPackage.Settings &
ChatOptionsInput & {
readonly apiKey?: string
readonly baseURL?: string
readonly queryParams?: Readonly<Record<string, string>>
}
const route = Route.make({
id: "venice-chat",
provider: id,
providerMetadataKey: "venice",
protocol: VeniceChat.protocol,
endpoint: Endpoint.path("/chat/completions", { baseURL: "https://api.venice.ai/api/v1" }),
framing: OpenAIChat.framing,
})
export const routes = [route]
export const configure = (input: Config = {}) => {
const { apiKey: _apiKey, auth: _auth, baseURL, queryParams, ...rest } = input
const chat = (modelID: string | ModelID) =>
route
.with({
...rest,
endpoint: { baseURL: baseURL ?? route.endpoint.baseURL, query: queryParams },
auth: AuthOptions.bearer(input, "VENICE_API_KEY"),
})
.model<ChatOptionsInput>({ id: modelID, compatibility: VeniceChat.compatibility })
return { id, model: chat, chat, configure }
}
export const provider = configure()
export const chat = provider.chat
export const model: ProviderPackage.Definition<Settings, ChatOptionsInput>["model"] = (
modelID,
{ apiKey, baseURL, queryParams, body, headers, ...providerOptions },
) =>
configure({
apiKey,
baseURL,
queryParams,
headers,
http: body === undefined ? undefined : { body: { ...body } },
providerOptions,
}).model(modelID)
export * as Venice from "./venice.js"
+7 -42
View File
@@ -171,33 +171,6 @@ describe("Anthropic Messages effort updates", () => {
}),
)
it.effect("releases a held system update next to an effort marker as one valid section", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model: opus5,
messages: [
Message.user("Fix it."),
Message.assistant("Done."),
lowFromHigh,
Message.system("Update."),
Message.user("Next."),
],
providerOptions: { effort: "low" },
cache: "none",
}),
)
expect(prepared.body.messages).toEqual([
{ role: "user", content: [{ type: "text", text: "Fix it." }] },
{ role: "assistant", content: [{ type: "text", text: "Done." }] },
{ role: "system", content: [], output_config: { effort: "low" } },
{ role: "user", content: [{ type: "text", text: "Next." }] },
{ role: "system", content: [{ type: "text", text: "Update.", cache_control: undefined }] },
])
}),
)
it.effect("falls back to a plain top-level effort when history drifted from the current effort", () =>
Effect.gen(function* () {
const drifted = yield* compileRequest(
@@ -293,28 +266,20 @@ describe("Anthropic Messages effort updates", () => {
}),
)
it.effect("lowers markers on the Vertex Anthropic route for models that support them", () =>
it.effect("strips markers on the Vertex Anthropic route, whose protocol wrapper does not forward support", () =>
Effect.gen(function* () {
const vertex = GoogleVertexMessages.configure({ accessToken: "test", location: "global", project: "test" })
const opus5 = yield* compileRequest(
const prepared = yield* compileRequest(
LLM.request({
model: vertex.model("claude-opus-5"),
messages: conversation,
providerOptions: { effort: "low" },
}),
)
const opus48 = yield* compileRequest(
LLM.request({
model: vertex.model("claude-opus-4-8"),
model: GoogleVertexMessages.configure({ accessToken: "test", location: "global", project: "test" }).model(
"claude-opus-5",
),
messages: conversation,
providerOptions: { effort: "low" },
}),
)
expect(systemMessages(opus5.body)).toEqual([{ role: "system", content: [], output_config: { effort: "low" } }])
expect(opus5.body.output_config).toEqual({ effort: "high" })
expect(systemMessages(opus48.body)).toHaveLength(0)
expect(opus48.body.output_config).toEqual({ effort: "low" })
expect(systemMessages(prepared.body)).toHaveLength(0)
expect(prepared.body.output_config).toEqual({ effort: "low" })
}),
)
})
@@ -1,75 +0,0 @@
{
"version": 1,
"metadata": {
"model": "claude-opus-4-8",
"tags": [
"prefix:venice-chat",
"provider:venice",
"protocol:venice-chat",
"reasoning",
"tool",
"tool-loop",
"continuation",
"usage",
"signed"
],
"name": "venice-chat/claude-opus-4-8-preserves-reasoning-through-a-tool-loop-and-follow-up",
"recordedAt": "2026-10-05T03:00:37.127Z"
},
"interactions": [
{
"transport": "http",
"request": {
"method": "POST",
"url": "https://api.venice.ai/api/v1/chat/completions",
"headers": {
"content-type": "application/json"
},
"body": "{\"model\":\"claude-opus-4-8\",\"messages\":[{\"role\":\"system\",\"content\":\"Use the provided tool for private data. After receiving the result, give only the requested number; do not call the tool again.\"},{\"role\":\"user\",\"content\":\"Look up conversion rate for code ZEBRA. Then multiply that rate by 17 and add 9. You must use lookup_rate before answering.\"}],\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"lookup_rate\",\"description\":\"Retrieve the private conversion rate for a code. Only this tool knows the rate.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"code\":{\"type\":\"string\",\"enum\":[\"ZEBRA\",\"MOOSE\"]}},\"required\":[\"code\"],\"additionalProperties\":false},\"strict\":false}}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"max_completion_tokens\":4096,\"reasoning\":{\"effort\":\"high\"},\"venice_parameters\":{\"include_venice_system_prompt\":false}}"
},
"response": {
"status": 200,
"headers": {
"content-type": "text/event-stream; charset=utf-8"
},
"body": "data: {\"id\":\"msg_011CfiLtM3Rj4dVUWFJAEjoy\",\"object\":\"chat.completion.chunk\",\"created\":1791169233,\"model\":\"claude-opus-4-8\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\"},\"finish_reason\":null}]}\n\n: processing\n\ndata: {\"id\":\"msg_011CfiLtM3Rj4dVUWFJAEjoy\",\"object\":\"chat.completion.chunk\",\"created\":1791169234,\"model\":\"claude-opus-4-8\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\"I need to find the rate for\",\"reasoning_details\":[{\"type\":\"reasoning.text\",\"format\":\"anthropic-claude-v1\",\"index\":0,\"text\":\"I need to find the rate for\"}],\"content\":\"\"},\"finish_reason\":null}]}\n\ndata: {\"id\":\"msg_011CfiLtM3Rj4dVUWFJAEjoy\",\"object\":\"chat.completion.chunk\",\"created\":1791169234,\"model\":\"claude-opus-4-8\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\" ZEBRA.\",\"reasoning_details\":[{\"type\":\"reasoning.text\",\"format\":\"anthropic-claude-v1\",\"index\":0,\"text\":\" ZEBRA.\"}],\"content\":\"\"},\"finish_reason\":null}]}\n\ndata: {\"id\":\"msg_011CfiLtM3Rj4dVUWFJAEjoy\",\"object\":\"chat.completion.chunk\",\"created\":1791169234,\"model\":\"claude-opus-4-8\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\"\\n\\n\",\"reasoning_details\":[{\"type\":\"reasoning.text\",\"format\":\"anthropic-claude-v1\",\"index\":0,\"text\":\"\\n\\n\"}],\"content\":\"\"},\"finish_reason\":null}]}\n\ndata: {\"id\":\"msg_011CfiLtM3Rj4dVUWFJAEjoy\",\"object\":\"chat.completion.chunk\",\"created\":1791169234,\"model\":\"claude-opus-4-8\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning_details\":[{\"type\":\"reasoning.text\",\"format\":\"anthropic-claude-v1\",\"index\":0,\"text\":\"\",\"signature\":\"EsECCrwBCBIYAipASM082xA/ckQU1q1MLSuAGRD/F94kvqoBlyXoEKdxvH2f2kn+ESR0AZfmYtv8VB8ux7LQ+qq4DnODYQ2iBHemajIPY2xhdWRlLW9wdXMtNC04OABCCHRoaW5raW5nWiQ1YzJjNzI5YS0wMzVjLTQ5ZDYtODNiOC03NGNhZTAyMzU2NWRyEOtICOPsjcfgmcoVEjPetDWIAQGaAREKD2NsYXVkZS1vcHVzLTQtOKgB0qWM1gawAQISDFJE1AThUPJ/0OxIBxoMak46jCiQRsOzj1nIIjBV1UQ4WoUBtZX8F6kyFtKyRPkLdWp7WQKQYd+f4VT3GvmEqenUY2ANNJImtDj/BQAqMhz7JVFNqrlemYzJNRbF6ntHDPc2enCrd/tYXBG6nms1qbcfKWc7CYc2ZGogOqiItKNDGAE=\",\"id\":\"toolu_01KYDseHbTDZXSYg9bNwiwxU\"}],\"tool_calls\":[{\"index\":0,\"id\":\"toolu_01KYDseHbTDZXSYg9bNwiwxU\",\"type\":\"function\",\"function\":{\"name\":\"lookup_rate\",\"arguments\":\"\"}}],\"content\":\"\"},\"finish_reason\":null}]}\n\ndata: {\"id\":\"msg_011CfiLtM3Rj4dVUWFJAEjoy\",\"object\":\"chat.completion.chunk\",\"created\":1791169234,\"model\":\"claude-opus-4-8\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"{\\\"code\\\"\"}}]},\"finish_reason\":null}]}\n\ndata: {\"id\":\"msg_011CfiLtM3Rj4dVUWFJAEjoy\",\"object\":\"chat.completion.chunk\",\"created\":1791169234,\"model\":\"claude-opus-4-8\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\": \\\"ZEBR\"}}]},\"finish_reason\":null}]}\n\ndata: {\"id\":\"msg_011CfiLtM3Rj4dVUWFJAEjoy\",\"object\":\"chat.completion.chunk\",\"created\":1791169234,\"model\":\"claude-opus-4-8\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"A\\\"}\"}}]},\"finish_reason\":null}]}\n\ndata: {\"id\":\"msg_011CfiLtM3Rj4dVUWFJAEjoy\",\"object\":\"chat.completion.chunk\",\"created\":1791169234,\"model\":\"claude-opus-4-8\",\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"tool_calls\"}]}\n\ndata: {\"id\":\"msg_011CfiLtM3Rj4dVUWFJAEjoy\",\"object\":\"chat.completion.chunk\",\"created\":1791169234,\"model\":\"claude-opus-4-8\",\"choices\":[],\"usage\":{\"prompt_tokens\":499,\"completion_tokens\":74,\"total_tokens\":573,\"prompt_tokens_details\":{\"cached_tokens\":0}},\"cost\":{\"usd\":0.005214,\"diem\":0}}\n\ndata: [DONE]\n\n"
}
},
{
"transport": "http",
"request": {
"method": "POST",
"url": "https://api.venice.ai/api/v1/chat/completions",
"headers": {
"content-type": "application/json"
},
"body": "{\"model\":\"claude-opus-4-8\",\"messages\":[{\"role\":\"system\",\"content\":\"Use the provided tool for private data. After receiving the result, give only the requested number; do not call the tool again.\"},{\"role\":\"user\",\"content\":\"Look up conversion rate for code ZEBRA. Then multiply that rate by 17 and add 9. You must use lookup_rate before answering.\"},{\"role\":\"assistant\",\"content\":null,\"tool_calls\":[{\"id\":\"toolu_01KYDseHbTDZXSYg9bNwiwxU\",\"type\":\"function\",\"function\":{\"name\":\"lookup_rate\",\"arguments\":\"{\\\"code\\\":\\\"ZEBRA\\\"}\"}}],\"reasoning_details\":[{\"type\":\"reasoning.text\",\"text\":\"I need to find the rate for ZEBRA.\\n\\n\",\"id\":\"toolu_01KYDseHbTDZXSYg9bNwiwxU\",\"format\":\"anthropic-claude-v1\",\"index\":0,\"signature\":\"EsECCrwBCBIYAipASM082xA/ckQU1q1MLSuAGRD/F94kvqoBlyXoEKdxvH2f2kn+ESR0AZfmYtv8VB8ux7LQ+qq4DnODYQ2iBHemajIPY2xhdWRlLW9wdXMtNC04OABCCHRoaW5raW5nWiQ1YzJjNzI5YS0wMzVjLTQ5ZDYtODNiOC03NGNhZTAyMzU2NWRyEOtICOPsjcfgmcoVEjPetDWIAQGaAREKD2NsYXVkZS1vcHVzLTQtOKgB0qWM1gawAQISDFJE1AThUPJ/0OxIBxoMak46jCiQRsOzj1nIIjBV1UQ4WoUBtZX8F6kyFtKyRPkLdWp7WQKQYd+f4VT3GvmEqenUY2ANNJImtDj/BQAqMhz7JVFNqrlemYzJNRbF6ntHDPc2enCrd/tYXBG6nms1qbcfKWc7CYc2ZGogOqiItKNDGAE=\"}],\"reasoning_content\":\"I need to find the rate for ZEBRA.\\n\\n\"},{\"role\":\"tool\",\"tool_call_id\":\"toolu_01KYDseHbTDZXSYg9bNwiwxU\",\"content\":\"{\\\"code\\\":\\\"ZEBRA\\\",\\\"rate\\\":23}\"}],\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"lookup_rate\",\"description\":\"Retrieve the private conversion rate for a code. Only this tool knows the rate.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"code\":{\"type\":\"string\",\"enum\":[\"ZEBRA\",\"MOOSE\"]}},\"required\":[\"code\"],\"additionalProperties\":false},\"strict\":false}}],\"tool_choice\":\"none\",\"stream\":true,\"stream_options\":{\"include_usage\":true},\"max_completion_tokens\":4096,\"reasoning\":{\"effort\":\"high\"},\"venice_parameters\":{\"include_venice_system_prompt\":false}}"
},
"response": {
"status": 200,
"headers": {
"content-type": "text/event-stream; charset=utf-8"
},
"body": "data: {\"id\":\"msg_011CfiLtUi3ffKFSXdhWtFbp\",\"object\":\"chat.completion.chunk\",\"created\":1791169235,\"model\":\"claude-opus-4-8\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\"},\"finish_reason\":null}]}\n\n: processing\n\ndata: {\"id\":\"msg_011CfiLtUi3ffKFSXdhWtFbp\",\"object\":\"chat.completion.chunk\",\"created\":1791169236,\"model\":\"claude-opus-4-8\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\"23 times 17 is \",\"reasoning_details\":[{\"type\":\"reasoning.text\",\"format\":\"anthropic-claude-v1\",\"index\":0,\"text\":\"23 times 17 is \"}],\"content\":\"\"},\"finish_reason\":null}]}\n\ndata: {\"id\":\"msg_011CfiLtUi3ffKFSXdhWtFbp\",\"object\":\"chat.completion.chunk\",\"created\":1791169236,\"model\":\"claude-opus-4-8\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\"391, plus 9 g\",\"reasoning_details\":[{\"type\":\"reasoning.text\",\"format\":\"anthropic-claude-v1\",\"index\":0,\"text\":\"391, plus 9 g\"}],\"content\":\"\"},\"finish_reason\":null}]}\n\ndata: {\"id\":\"msg_011CfiLtUi3ffKFSXdhWtFbp\",\"object\":\"chat.completion.chunk\",\"created\":1791169236,\"model\":\"claude-opus-4-8\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\"ives 400.\",\"reasoning_details\":[{\"type\":\"reasoning.text\",\"format\":\"anthropic-claude-v1\",\"index\":0,\"text\":\"ives 400.\"}],\"content\":\"\"},\"finish_reason\":null}]}\n\ndata: {\"id\":\"msg_011CfiLtUi3ffKFSXdhWtFbp\",\"object\":\"chat.completion.chunk\",\"created\":1791169236,\"model\":\"claude-opus-4-8\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\"\\n\\n\",\"reasoning_details\":[{\"type\":\"reasoning.text\",\"format\":\"anthropic-claude-v1\",\"index\":0,\"text\":\"\\n\\n\"}],\"content\":\"\"},\"finish_reason\":null}]}\n\ndata: {\"id\":\"msg_011CfiLtUi3ffKFSXdhWtFbp\",\"object\":\"chat.completion.chunk\",\"created\":1791169236,\"model\":\"claude-opus-4-8\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning_details\":[{\"type\":\"reasoning.text\",\"format\":\"anthropic-claude-v1\",\"index\":0,\"text\":\"\",\"signature\":\"ErgCCrwBCBIYAipAzm6Uz1fiUimJZ7ry16/5jOPhPfQ9Zm3zSb449atO4+Dv3KroxOwD17dLjPUhdRWeFNghT/lQsCRlsY9T6SWyMzIPY2xhdWRlLW9wdXMtNC04OABCCHRoaW5raW5nWiQ1YzJjNzI5YS0wMzVjLTQ5ZDYtODNiOC03NGNhZTAyMzU2NWRyEGhpfnmythMRLK5wcAMHasmIAQGaAREKD2NsYXVkZS1vcHVzLTQtOKgB1KWM1gawAQISDA4K+cR55K4LQVXyhhoMBUHsSBuaNQlHp5ZPIjBvSX58DhVBxFRl9sVZYgZwrMDqGzC1/tgyH1wflAP4TkHTPje+Gdbv0yUxBBIQtaEqKSS4Bh4olHSAKxLEG1SyH4Iy46lp50SxdXxGJ/PIwTfHI47SEaE9pEfNGAE=\"}],\"content\":\"\"},\"finish_reason\":null}]}\n\ndata: {\"id\":\"msg_011CfiLtUi3ffKFSXdhWtFbp\",\"object\":\"chat.completion.chunk\",\"created\":1791169236,\"model\":\"claude-opus-4-8\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"400\"},\"finish_reason\":null}]}\n\ndata: {\"id\":\"msg_011CfiLtUi3ffKFSXdhWtFbp\",\"object\":\"chat.completion.chunk\",\"created\":1791169236,\"model\":\"claude-opus-4-8\",\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"stop\"}]}\n\ndata: {\"id\":\"msg_011CfiLtUi3ffKFSXdhWtFbp\",\"object\":\"chat.completion.chunk\",\"created\":1791169236,\"model\":\"claude-opus-4-8\",\"choices\":[],\"usage\":{\"prompt_tokens\":183,\"completion_tokens\":23,\"total_tokens\":206,\"prompt_tokens_details\":{\"cached_tokens\":0}},\"cost\":{\"usd\":0.001788,\"diem\":0}}\n\ndata: [DONE]\n\n"
}
},
{
"transport": "http",
"request": {
"method": "POST",
"url": "https://api.venice.ai/api/v1/chat/completions",
"headers": {
"content-type": "application/json"
},
"body": "{\"model\":\"claude-opus-4-8\",\"messages\":[{\"role\":\"system\",\"content\":\"Use the provided tool for private data. After receiving the result, give only the requested number; do not call the tool again.\"},{\"role\":\"user\",\"content\":\"Look up conversion rate for code ZEBRA. Then multiply that rate by 17 and add 9. You must use lookup_rate before answering.\"},{\"role\":\"assistant\",\"content\":null,\"tool_calls\":[{\"id\":\"toolu_01KYDseHbTDZXSYg9bNwiwxU\",\"type\":\"function\",\"function\":{\"name\":\"lookup_rate\",\"arguments\":\"{\\\"code\\\":\\\"ZEBRA\\\"}\"}}],\"reasoning_details\":[{\"type\":\"reasoning.text\",\"text\":\"I need to find the rate for ZEBRA.\\n\\n\",\"id\":\"toolu_01KYDseHbTDZXSYg9bNwiwxU\",\"format\":\"anthropic-claude-v1\",\"index\":0,\"signature\":\"EsECCrwBCBIYAipASM082xA/ckQU1q1MLSuAGRD/F94kvqoBlyXoEKdxvH2f2kn+ESR0AZfmYtv8VB8ux7LQ+qq4DnODYQ2iBHemajIPY2xhdWRlLW9wdXMtNC04OABCCHRoaW5raW5nWiQ1YzJjNzI5YS0wMzVjLTQ5ZDYtODNiOC03NGNhZTAyMzU2NWRyEOtICOPsjcfgmcoVEjPetDWIAQGaAREKD2NsYXVkZS1vcHVzLTQtOKgB0qWM1gawAQISDFJE1AThUPJ/0OxIBxoMak46jCiQRsOzj1nIIjBV1UQ4WoUBtZX8F6kyFtKyRPkLdWp7WQKQYd+f4VT3GvmEqenUY2ANNJImtDj/BQAqMhz7JVFNqrlemYzJNRbF6ntHDPc2enCrd/tYXBG6nms1qbcfKWc7CYc2ZGogOqiItKNDGAE=\"}],\"reasoning_content\":\"I need to find the rate for ZEBRA.\\n\\n\"},{\"role\":\"tool\",\"tool_call_id\":\"toolu_01KYDseHbTDZXSYg9bNwiwxU\",\"content\":\"{\\\"code\\\":\\\"ZEBRA\\\",\\\"rate\\\":23}\"},{\"role\":\"assistant\",\"content\":\"400\",\"reasoning_details\":[{\"type\":\"reasoning.text\",\"text\":\"23 times 17 is 391, plus 9 gives 400.\\n\\n\",\"format\":\"anthropic-claude-v1\",\"index\":0,\"signature\":\"ErgCCrwBCBIYAipAzm6Uz1fiUimJZ7ry16/5jOPhPfQ9Zm3zSb449atO4+Dv3KroxOwD17dLjPUhdRWeFNghT/lQsCRlsY9T6SWyMzIPY2xhdWRlLW9wdXMtNC04OABCCHRoaW5raW5nWiQ1YzJjNzI5YS0wMzVjLTQ5ZDYtODNiOC03NGNhZTAyMzU2NWRyEGhpfnmythMRLK5wcAMHasmIAQGaAREKD2NsYXVkZS1vcHVzLTQtOKgB1KWM1gawAQISDA4K+cR55K4LQVXyhhoMBUHsSBuaNQlHp5ZPIjBvSX58DhVBxFRl9sVZYgZwrMDqGzC1/tgyH1wflAP4TkHTPje+Gdbv0yUxBBIQtaEqKSS4Bh4olHSAKxLEG1SyH4Iy46lp50SxdXxGJ/PIwTfHI47SEaE9pEfNGAE=\"}],\"reasoning_content\":\"23 times 17 is 391, plus 9 gives 400.\\n\\n\"},{\"role\":\"user\",\"content\":\"Add 7 to your previous final answer. Reply with just the number.\"}],\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"lookup_rate\",\"description\":\"Retrieve the private conversion rate for a code. Only this tool knows the rate.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"code\":{\"type\":\"string\",\"enum\":[\"ZEBRA\",\"MOOSE\"]}},\"required\":[\"code\"],\"additionalProperties\":false},\"strict\":false}}],\"tool_choice\":\"none\",\"stream\":true,\"stream_options\":{\"include_usage\":true},\"max_completion_tokens\":4096,\"reasoning\":{\"effort\":\"high\"},\"venice_parameters\":{\"include_venice_system_prompt\":false}}"
},
"response": {
"status": 200,
"headers": {
"content-type": "text/event-stream; charset=utf-8"
},
"body": "data: {\"id\":\"msg_011CfiLtbruiyeuxJmswkPPt\",\"object\":\"chat.completion.chunk\",\"created\":1791169237,\"model\":\"claude-opus-4-8\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\"},\"finish_reason\":null}]}\n\n: processing\n\ndata: {\"id\":\"msg_011CfiLtbruiyeuxJmswkPPt\",\"object\":\"chat.completion.chunk\",\"created\":1791169237,\"model\":\"claude-opus-4-8\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"407\"},\"finish_reason\":null}]}\n\ndata: {\"id\":\"msg_011CfiLtbruiyeuxJmswkPPt\",\"object\":\"chat.completion.chunk\",\"created\":1791169237,\"model\":\"claude-opus-4-8\",\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"stop\"}]}\n\ndata: {\"id\":\"msg_011CfiLtbruiyeuxJmswkPPt\",\"object\":\"chat.completion.chunk\",\"created\":1791169237,\"model\":\"claude-opus-4-8\",\"choices\":[],\"usage\":{\"prompt_tokens\":229,\"completion_tokens\":3,\"total_tokens\":232,\"prompt_tokens_details\":{\"cached_tokens\":0}},\"cost\":{\"usd\":0.001464,\"diem\":0}}\n\ndata: [DONE]\n\n"
}
}
]
}
@@ -1,75 +0,0 @@
{
"version": 1,
"metadata": {
"model": "deepseek-v4-flash",
"tags": [
"prefix:venice-chat",
"provider:venice",
"protocol:venice-chat",
"reasoning",
"tool",
"tool-loop",
"continuation",
"usage",
"scalar"
],
"name": "venice-chat/deepseek-v4-flash-preserves-reasoning-through-a-tool-loop-and-follow-up",
"recordedAt": "2026-10-05T02:59:53.799Z"
},
"interactions": [
{
"transport": "http",
"request": {
"method": "POST",
"url": "https://api.venice.ai/api/v1/chat/completions",
"headers": {
"content-type": "application/json"
},
"body": "{\"model\":\"deepseek-v4-flash\",\"messages\":[{\"role\":\"system\",\"content\":\"Use the provided tool for private data. After receiving the result, give only the requested number; do not call the tool again.\"},{\"role\":\"user\",\"content\":\"Look up conversion rate for code ZEBRA. Then multiply that rate by 17 and add 9. You must use lookup_rate before answering.\"}],\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"lookup_rate\",\"description\":\"Retrieve the private conversion rate for a code. Only this tool knows the rate.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"code\":{\"type\":\"string\",\"enum\":[\"ZEBRA\",\"MOOSE\"]}},\"required\":[\"code\"],\"additionalProperties\":false},\"strict\":false}}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"max_completion_tokens\":4096,\"venice_parameters\":{\"include_venice_system_prompt\":false}}"
},
"response": {
"status": 200,
"headers": {
"content-type": "text/event-stream; charset=utf-8"
},
"body": "data: {\"service_tier\":null,\"id\":\"chatcmpl-Rhr7pZI7Hv6zIafL3B1KoyzY\",\"object\":\"chat.completion.chunk\",\"created\":1791169188,\"model\":\"deepseek-v4-flash\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"\",\"reasoning_content\":null,\"tool_calls\":null},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"service_tier\":\"default\",\"id\":\"chatcmpl-Rhr7pZI7Hv6zIafL3B1KoyzY\",\"object\":\"chat.completion.chunk\",\"created\":1791169188,\"model\":\"deepseek-v4-flash\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"\",\"reasoning_content\":null,\"tool_calls\":null},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"service_tier\":\"default\",\"id\":\"chatcmpl-Rhr7pZI7Hv6zIafL3B1KoyzY\",\"object\":\"chat.completion.chunk\",\"created\":1791169188,\"model\":\"deepseek-v4-flash\",\"choices\":[{\"index\":0,\"delta\":{\"role\":null,\"content\":\"\",\"reasoning_content\":\"Th\",\"tool_calls\":null},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"service_tier\":\"default\",\"id\":\"chatcmpl-Rhr7pZI7Hv6zIafL3B1KoyzY\",\"object\":\"chat.completion.chunk\",\"created\":1791169188,\"model\":\"deepseek-v4-flash\",\"choices\":[{\"index\":0,\"delta\":{\"role\":null,\"content\":\"\",\"reasoning_content\":\"e user wants me to look\",\"tool_calls\":null},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"service_tier\":\"default\",\"id\":\"chatcmpl-Rhr7pZI7Hv6zIafL3B1KoyzY\",\"object\":\"chat.completion.chunk\",\"created\":1791169188,\"model\":\"deepseek-v4-flash\",\"choices\":[{\"index\":0,\"delta\":{\"role\":null,\"content\":\"\",\"reasoning_content\":\" up the conversi\",\"tool_calls\":null},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"service_tier\":\"default\",\"id\":\"chatcmpl-Rhr7pZI7Hv6zIafL3B1KoyzY\",\"object\":\"chat.completion.chunk\",\"created\":1791169188,\"model\":\"deepseek-v4-flash\",\"choices\":[{\"index\":0,\"delta\":{\"role\":null,\"content\":\"\",\"reasoning_content\":\"on rate fo\",\"tool_calls\":null},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"service_tier\":\"default\",\"id\":\"chatcmpl-Rhr7pZI7Hv6zIafL3B1KoyzY\",\"object\":\"chat.completion.chunk\",\"created\":1791169188,\"model\":\"deepseek-v4-flash\",\"choices\":[{\"index\":0,\"delta\":{\"role\":null,\"content\":\"\",\"reasoning_content\":\"r code ZEBRA\",\"tool_calls\":null},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"service_tier\":\"default\",\"id\":\"chatcmpl-Rhr7pZI7Hv6zIafL3B1KoyzY\",\"object\":\"chat.completion.chunk\",\"created\":1791169188,\"model\":\"deepseek-v4-flash\",\"choices\":[{\"index\":0,\"delta\":{\"role\":null,\"content\":\"\",\"reasoning_content\":\", then mult\",\"tool_calls\":null},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"service_tier\":\"default\",\"id\":\"chatcmpl-Rhr7pZI7Hv6zIafL3B1KoyzY\",\"object\":\"chat.completion.chunk\",\"created\":1791169188,\"model\":\"deepseek-v4-flash\",\"choices\":[{\"index\":0,\"delta\":{\"role\":null,\"content\":\"\",\"reasoning_content\":\"iply by\",\"tool_calls\":null},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"service_tier\":\"default\",\"id\":\"chatcmpl-Rhr7pZI7Hv6zIafL3B1KoyzY\",\"object\":\"chat.completion.chunk\",\"created\":1791169188,\"model\":\"deepseek-v4-flash\",\"choices\":[{\"index\":0,\"delta\":{\"role\":null,\"content\":\"\",\"reasoning_content\":\" 17 and add 9. Let m\",\"tool_calls\":null},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"service_tier\":\"default\",\"id\":\"chatcmpl-Rhr7pZI7Hv6zIafL3B1KoyzY\",\"object\":\"chat.completion.chunk\",\"created\":1791169188,\"model\":\"deepseek-v4-flash\",\"choices\":[{\"index\":0,\"delta\":{\"role\":null,\"content\":\"\",\"reasoning_content\":\"e start by lo\",\"tool_calls\":null},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"service_tier\":\"default\",\"id\":\"chatcmpl-Rhr7pZI7Hv6zIafL3B1KoyzY\",\"object\":\"chat.completion.chunk\",\"created\":1791169188,\"model\":\"deepseek-v4-flash\",\"choices\":[{\"index\":0,\"delta\":{\"role\":null,\"content\":\"\",\"reasoning_content\":\"oking up the rate.\",\"tool_calls\":null},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"service_tier\":\"default\",\"id\":\"chatcmpl-Rhr7pZI7Hv6zIafL3B1KoyzY\",\"object\":\"chat.completion.chunk\",\"created\":1791169188,\"model\":\"deepseek-v4-flash\",\"choices\":[{\"index\":0,\"delta\":{\"role\":null,\"content\":\"\",\"reasoning_content\":null,\"tool_calls\":[{\"index\":0,\"id\":\"chatcmpl-tool-ab656a8b683341f2\",\"function\":{\"arguments\":\"\",\"name\":\"lookup_rate\"},\"type\":\"function\"}]},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"service_tier\":\"default\",\"id\":\"chatcmpl-Rhr7pZI7Hv6zIafL3B1KoyzY\",\"object\":\"chat.completion.chunk\",\"created\":1791169188,\Line truncated
}
},
{
"transport": "http",
"request": {
"method": "POST",
"url": "https://api.venice.ai/api/v1/chat/completions",
"headers": {
"content-type": "application/json"
},
"body": "{\"model\":\"deepseek-v4-flash\",\"messages\":[{\"role\":\"system\",\"content\":\"Use the provided tool for private data. After receiving the result, give only the requested number; do not call the tool again.\"},{\"role\":\"user\",\"content\":\"Look up conversion rate for code ZEBRA. Then multiply that rate by 17 and add 9. You must use lookup_rate before answering.\"},{\"role\":\"assistant\",\"content\":null,\"tool_calls\":[{\"id\":\"chatcmpl-tool-ab656a8b683341f2\",\"type\":\"function\",\"function\":{\"name\":\"lookup_rate\",\"arguments\":\"{\\\"code\\\":\\\"ZEBRA\\\"}\"}}],\"reasoning_content\":\"The user wants me to look up the conversion rate for code ZEBRA, then multiply by 17 and add 9. Let me start by looking up the rate.\"},{\"role\":\"tool\",\"tool_call_id\":\"chatcmpl-tool-ab656a8b683341f2\",\"content\":\"{\\\"code\\\":\\\"ZEBRA\\\",\\\"rate\\\":23}\"}],\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"lookup_rate\",\"description\":\"Retrieve the private conversion rate for a code. Only this tool knows the rate.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"code\":{\"type\":\"string\",\"enum\":[\"ZEBRA\",\"MOOSE\"]}},\"required\":[\"code\"],\"additionalProperties\":false},\"strict\":false}}],\"tool_choice\":\"none\",\"stream\":true,\"stream_options\":{\"include_usage\":true},\"max_completion_tokens\":4096,\"venice_parameters\":{\"include_venice_system_prompt\":false}}"
},
"response": {
"status": 200,
"headers": {
"content-type": "text/event-stream; charset=utf-8"
},
"body": "data: {\"service_tier\":null,\"id\":\"chatcmpl-RtwpMZBraGwqJiKRKQFrmmIq\",\"object\":\"chat.completion.chunk\",\"created\":1791169191,\"model\":\"deepseek-v4-flash\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"\",\"reasoning_content\":null,\"tool_calls\":null},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"service_tier\":\"default\",\"id\":\"chatcmpl-RtwpMZBraGwqJiKRKQFrmmIq\",\"object\":\"chat.completion.chunk\",\"created\":1791169191,\"model\":\"deepseek-v4-flash\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"\",\"reasoning_content\":null,\"tool_calls\":null},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"service_tier\":\"default\",\"id\":\"chatcmpl-RtwpMZBraGwqJiKRKQFrmmIq\",\"object\":\"chat.completion.chunk\",\"created\":1791169191,\"model\":\"deepseek-v4-flash\",\"choices\":[{\"index\":0,\"delta\":{\"role\":null,\"content\":\"\",\"reasoning_content\":\"We have the\",\"tool_calls\":null},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"service_tier\":\"default\",\"id\":\"chatcmpl-RtwpMZBraGwqJiKRKQFrmmIq\",\"object\":\"chat.completion.chunk\",\"created\":1791169191,\"model\":\"deepseek-v4-flash\",\"choices\":[{\"index\":0,\"delta\":{\"role\":null,\"content\":\"\",\"reasoning_content\":\" rate: 2\",\"tool_calls\":null},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"service_tier\":\"default\",\"id\":\"chatcmpl-RtwpMZBraGwqJiKRKQFrmmIq\",\"object\":\"chat.completion.chunk\",\"created\":1791169191,\"model\":\"deepseek-v4-flash\",\"choices\":[{\"index\":0,\"delta\":{\"role\":null,\"content\":\"\",\"reasoning_content\":\"3. Mul\",\"tool_calls\":null},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"service_tier\":\"default\",\"id\":\"chatcmpl-RtwpMZBraGwqJiKRKQFrmmIq\",\"object\":\"chat.completion.chunk\",\"created\":1791169191,\"model\":\"deepseek-v4-flash\",\"choices\":[{\"index\":0,\"delta\":{\"role\":null,\"content\":\"\",\"reasoning_content\":\"tiply by\",\"tool_calls\":null},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"service_tier\":\"default\",\"id\":\"chatcmpl-RtwpMZBraGwqJiKRKQFrmmIq\",\"object\":\"chat.completion.chunk\",\"created\":1791169191,\"model\":\"deepseek-v4-flash\",\"choices\":[{\"index\":0,\"delta\":{\"role\":null,\"content\":\"\",\"reasoning_content\":\" 17: 2\",\"tool_calls\":null},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"service_tier\":\"default\",\"id\":\"chatcmpl-RtwpMZBraGwqJiKRKQFrmmIq\",\"object\":\"chat.completion.chunk\",\"created\":1791169191,\"model\":\"deepseek-v4-flash\",\"choices\":[{\"index\":0,\"delta\":{\"role\":null,\"content\":\"\",\"reasoning_content\":\"3 * 17\",\"tool_calls\":null},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"service_tier\":\"default\",\"id\":\"chatcmpl-RtwpMZBraGwqJiKRKQFrmmIq\",\"object\":\"chat.completion.chunk\",\"created\":1791169191,\"model\":\"deepseek-v4-flash\",\"choices\":[{\"index\":0,\"delta\":{\"role\":null,\"content\":\"\",\"reasoning_content\":\" = 391.\",\"tool_calls\":null},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"service_tier\":\"default\",\"id\":\"chatcmpl-RtwpMZBraGwqJiKRKQFrmmIq\",\"object\":\"chat.completion.chunk\",\"created\":1791169191,\"model\":\"deepseek-v4-flash\",\"choices\":[{\"index\":0,\"delta\":{\"role\":null,\"content\":\"\",\"reasoning_content\":\" Then ad\",\"tool_calls\":null},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"service_tier\":\"default\",\"id\":\"chatcmpl-RtwpMZBraGwqJiKRKQFrmmIq\",\"object\":\"chat.completion.chunk\",\"created\":1791169191,\"model\":\"deepseek-v4-flash\",\"choices\":[{\"index\":0,\"delta\":{\"role\":null,\"content\":\"\",\"reasoning_content\":\"d 9:\",\"tool_calls\":null},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"service_tier\":\"default\",\"id\":\"chatcmpl-RtwpMZBraGwqJiKRKQFrmmIq\",\"object\":\"chat.completion.chunk\",\"created\":1791169191,\"model\":\"deepseek-v4-flash\",\"choices\":[{\"index\":0,\"delta\":{\"role\":null,\"content\":\"\",\"reasoning_content\":\" 391 + 9 = 400.\",\"tool_calls\":null},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"service_tier\":\"default\",\"id\":\"chatcmpl-RtwpMZBraGwqJiKRKQFrmmIq\",\"object\":\"chat.completion.chunk\",\"created\":1791169191,\"model\":\"deepseek-v4-flash\",\"choices\":[{\"index\":0,\"delta\":{\"role\":null,\"content\":\"400\",\"reasoning_content\":\" So the answer is 400.\",\"tool_calls\":null},\"logprobs\":null,\"finish_reason\":\"stop\"}]}\n\ndata: {\"service_tier\":\"default\",\"id\":\"chatcmpl-RtwpMZBraGwqJiKRKQFrmmIq\",\"object\":\"chat.completion.chunk\",\"created\":1791169191,\"model\":\"deepseek-v4-flash\",\"choices\":[],\"usage\":{\"prompt_tokens\":128,\"completion_tokens\":46,\"total_tokens\":174,\"prompt_tokens_details\":{\"cached_tokens\":0}}Line truncated
}
},
{
"transport": "http",
"request": {
"method": "POST",
"url": "https://api.venice.ai/api/v1/chat/completions",
"headers": {
"content-type": "application/json"
},
"body": "{\"model\":\"deepseek-v4-flash\",\"messages\":[{\"role\":\"system\",\"content\":\"Use the provided tool for private data. After receiving the result, give only the requested number; do not call the tool again.\"},{\"role\":\"user\",\"content\":\"Look up conversion rate for code ZEBRA. Then multiply that rate by 17 and add 9. You must use lookup_rate before answering.\"},{\"role\":\"assistant\",\"content\":null,\"tool_calls\":[{\"id\":\"chatcmpl-tool-ab656a8b683341f2\",\"type\":\"function\",\"function\":{\"name\":\"lookup_rate\",\"arguments\":\"{\\\"code\\\":\\\"ZEBRA\\\"}\"}}],\"reasoning_content\":\"The user wants me to look up the conversion rate for code ZEBRA, then multiply by 17 and add 9. Let me start by looking up the rate.\"},{\"role\":\"tool\",\"tool_call_id\":\"chatcmpl-tool-ab656a8b683341f2\",\"content\":\"{\\\"code\\\":\\\"ZEBRA\\\",\\\"rate\\\":23}\"},{\"role\":\"assistant\",\"content\":\"400\",\"reasoning_content\":\"We have the rate: 23. Multiply by 17: 23 * 17 = 391. Then add 9: 391 + 9 = 400. So the answer is 400.\"},{\"role\":\"user\",\"content\":\"Add 7 to your previous final answer. Reply with just the number.\"}],\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"lookup_rate\",\"description\":\"Retrieve the private conversion rate for a code. Only this tool knows the rate.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"code\":{\"type\":\"string\",\"enum\":[\"ZEBRA\",\"MOOSE\"]}},\"required\":[\"code\"],\"additionalProperties\":false},\"strict\":false}}],\"tool_choice\":\"none\",\"stream\":true,\"stream_options\":{\"include_usage\":true},\"max_completion_tokens\":4096,\"venice_parameters\":{\"include_venice_system_prompt\":false}}"
},
"response": {
"status": 200,
"headers": {
"content-type": "text/event-stream; charset=utf-8"
},
"body": "data: {\"service_tier\":null,\"id\":\"chatcmpl-R0MMWxoRA5NidBHTttxrMoVe\",\"object\":\"chat.completion.chunk\",\"created\":1791169192,\"model\":\"deepseek-v4-flash\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"\",\"reasoning_content\":null,\"tool_calls\":null},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"service_tier\":\"default\",\"id\":\"chatcmpl-R0MMWxoRA5NidBHTttxrMoVe\",\"object\":\"chat.completion.chunk\",\"created\":1791169192,\"model\":\"deepseek-v4-flash\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"\",\"reasoning_content\":null,\"tool_calls\":null},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"service_tier\":\"default\",\"id\":\"chatcmpl-R0MMWxoRA5NidBHTttxrMoVe\",\"object\":\"chat.completion.chunk\",\"created\":1791169192,\"model\":\"deepseek-v4-flash\",\"choices\":[{\"index\":0,\"delta\":{\"role\":null,\"content\":\"\",\"reasoning_content\":\"We need to add\",\"tool_calls\":null},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"service_tier\":\"default\",\"id\":\"chatcmpl-R0MMWxoRA5NidBHTttxrMoVe\",\"object\":\"chat.completion.chunk\",\"created\":1791169192,\"model\":\"deepseek-v4-flash\",\"choices\":[{\"index\":0,\"delta\":{\"role\":null,\"content\":\"\",\"reasoning_content\":\" 7 to t\",\"tool_calls\":null},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"service_tier\":\"default\",\"id\":\"chatcmpl-R0MMWxoRA5NidBHTttxrMoVe\",\"object\":\"chat.completion.chunk\",\"created\":1791169192,\"model\":\"deepseek-v4-flash\",\"choices\":[{\"index\":0,\"delta\":{\"role\":null,\"content\":\"\",\"reasoning_content\":\"h\",\"tool_calls\":null},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"service_tier\":\"default\",\"id\":\"chatcmpl-R0MMWxoRA5NidBHTttxrMoVe\",\"object\":\"chat.completion.chunk\",\"created\":1791169192,\"model\":\"deepseek-v4-flash\",\"choices\":[{\"index\":0,\"delta\":{\"role\":null,\"content\":\"\",\"reasoning_content\":\"e previous ans\",\"tool_calls\":null},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"service_tier\":\"default\",\"id\":\"chatcmpl-R0MMWxoRA5NidBHTttxrMoVe\",\"object\":\"chat.completion.chunk\",\"created\":1791169192,\"model\":\"deepseek-v4-flash\",\"choices\":[{\"index\":0,\"delta\":{\"role\":null,\"content\":\"\",\"reasoning_content\":\"wer,\",\"tool_calls\":null},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"service_tier\":\"default\",\"id\":\"chatcmpl-R0MMWxoRA5NidBHTttxrMoVe\",\"object\":\"chat.completion.chunk\",\"created\":1791169192,\"model\":\"deepseek-v4-flash\",\"choices\":[{\"index\":0,\"delta\":{\"role\":null,\"content\":\"\",\"reasoning_content\":\" which\",\"tool_calls\":null},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"service_tier\":\"default\",\"id\":\"chatcmpl-R0MMWxoRA5NidBHTttxrMoVe\",\"object\":\"chat.completion.chunk\",\"created\":1791169192,\"model\":\"deepseek-v4-flash\",\"choices\":[{\"index\":0,\"delta\":{\"role\":null,\"content\":\"\",\"reasoning_content\":\" was 400. S\",\"tool_calls\":null},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"service_tier\":\"default\",\"id\":\"chatcmpl-R0MMWxoRA5NidBHTttxrMoVe\",\"object\":\"chat.completion.chunk\",\"created\":1791169192,\"model\":\"deepseek-v4-flash\",\"choices\":[{\"index\":0,\"delta\":{\"role\":null,\"content\":\"\",\"reasoning_content\":\"o 400+7=407. Reply wi\",\"tool_calls\":null},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"service_tier\":\"default\",\"id\":\"chatcmpl-R0MMWxoRA5NidBHTttxrMoVe\",\"object\":\"chat.completion.chunk\",\"created\":1791169192,\"model\":\"deepseek-v4-flash\",\"choices\":[{\"index\":0,\"delta\":{\"role\":null,\"content\":\"407\",\"reasoning_content\":\"th just the number.\",\"tool_calls\":null},\"logprobs\":null,\"finish_reason\":\"stop\"}]}\n\ndata: {\"service_tier\":\"default\",\"id\":\"chatcmpl-R0MMWxoRA5NidBHTttxrMoVe\",\"object\":\"chat.completion.chunk\",\"created\":1791169192,\"model\":\"deepseek-v4-flash\",\"choices\":[],\"usage\":{\"prompt_tokens\":148,\"completion_tokens\":33,\"total_tokens\":181,\"prompt_tokens_details\":{\"cached_tokens\":0}},\"cost\":{\"usd\":0.000029499,\"diem\":0}}\n\ndata: [DONE]\n\n"
}
}
]
}
@@ -1,75 +0,0 @@
{
"version": 1,
"metadata": {
"model": "gemini-3-1-pro-preview",
"tags": [
"prefix:venice-chat",
"provider:venice",
"protocol:venice-chat",
"reasoning",
"tool",
"tool-loop",
"continuation",
"usage",
"details"
],
"name": "venice-chat/gemini-3-1-pro-preview-preserves-reasoning-through-a-tool-loop-and-follow-up",
"recordedAt": "2026-10-05T03:00:58.104Z"
},
"interactions": [
{
"transport": "http",
"request": {
"method": "POST",
"url": "https://api.venice.ai/api/v1/chat/completions",
"headers": {
"content-type": "application/json"
},
"body": "{\"model\":\"gemini-3-1-pro-preview\",\"messages\":[{\"role\":\"system\",\"content\":\"Use the provided tool for private data. After receiving the result, give only the requested number; do not call the tool again.\"},{\"role\":\"user\",\"content\":\"Look up conversion rate for code ZEBRA. Then multiply that rate by 17 and add 9. You must use lookup_rate before answering.\"}],\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"lookup_rate\",\"description\":\"Retrieve the private conversion rate for a code. Only this tool knows the rate.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"code\":{\"type\":\"string\",\"enum\":[\"ZEBRA\",\"MOOSE\"]}},\"required\":[\"code\"],\"additionalProperties\":false},\"strict\":false}}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"max_completion_tokens\":4096,\"reasoning\":{\"effort\":\"high\"},\"venice_parameters\":{\"include_venice_system_prompt\":false}}"
},
"response": {
"status": 200,
"headers": {
"content-type": "text/event-stream; charset=utf-8"
},
"body": "data: {\"id\":\"gen-1791169245-MjiWKnEhpgHVKzFcOlS0\",\"object\":\"chat.completion.chunk\",\"created\":1791169245,\"model\":\"gemini-3-1-pro-preview\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"\",\"role\":\"assistant\",\"reasoning_details\":[{\"type\":\"reasoning.text\",\"text\":\"**Calculating ZEBRA Conversion**\\n\\nI'm currently processing the calculation involving ZEBRA's conversion rate. My next step is to use the `lookup_rate` tool with the code \\\"ZEBRA\\\" to fetch the necessary value, which will then be used in the full equation.\\n\\n\",\"format\":\"google-gemini-v1\",\"index\":0}],\"reasoning_content\":\"**Calculating ZEBRA Conversion**\\n\\nI'm currently processing the calculation involving ZEBRA's conversion rate. My next step is to use the `lookup_rate` tool with the code \\\"ZEBRA\\\" to fetch the necessary value, which will then be used in the full equation.\\n\\n\"},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1791169245-MjiWKnEhpgHVKzFcOlS0\",\"object\":\"chat.completion.chunk\",\"created\":1791169245,\"model\":\"gemini-3-1-pro-preview\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"\",\"role\":\"assistant\",\"reasoning_details\":[{\"type\":\"reasoning.encrypted\",\"data\":\"AY89a19Tkd2zuyuB3m6as2cMeOoFk8xFfcBdsUI+RBkTCE1GVBd5hMIfNsjOj+YTucPL1hYoWodr2oZsMKDnPuYee0AmGYl8jHzVKgQFSXzgIeWEhG+hOMvTEa4BoruFowcHzbLhym48OlBys47Tlo7WF4tK4s8Z7q8cpbqGsA0NkUqPt8P7Uk3ZaRTUJBbIE08+JgNUUwiJkT4AVSO635aMUYTcIkt+8TVbqOLITfSdDtkxhAmrInfn7FqGlNop3RH1TUcb3xnUOsnHXoreWLlAyWTlWNvOa7hjUH8vUwILPmtG6ixKZdf4i/UIgm551Z5tgE05SJBcR/m7ALagyOyEilWqxGkyf+5jvNycUlk/mBkNHLV45amgUG5zhboVEAEVFYPRa+Nnfzxi+DEdM1ntDURrB5dl4Jbotx67627EbsZm4z2sKUZ4jo/eBj9fI3MUqjTYKtMXBSyuen/m/Uw2Ji9iIQIlWUt/G86kOjQUYFY60g6TxEO4BLouwLZJ0fXxWdolvtMnH90+skJ+I7aFQjGrnwdjGsT0oXA0/vG3W8GhRSokdICVQBq14EFvjcVvI1kEgj7NTtKkB/+UWRtRbRhSA5F25/dHvqAT4ioYy/5sRwYFXMwJmM52I8DpLiaBrp8wTidLNjTpD9KlImbd2r89LS9GG5NeqNWksF9XbEQAMannsu19HC9xf+CCz5FBz5PjpR0+pvybIJLmph94u4I0cdLsbycbbdIaihqeIc593sQv3nvm9VYZMJcgnQWIKmXofSlV4zi6p0wRPvmgeiPo+Lv6DrtPTtcaRLkkyGrg8uvkq2Is9H6me8y8HCXGIze3np22MjWM5r2+UYMia+xY+E3neBC1AZrlN+GJarvf0THgHInLLnE78f+w3mJByFgXMy6OOEs4QI0+F8yxF9GembhqebXYKWGWUkuArnLfV6TZ13v8s1J9IiNObPU0AJjq+wmlg2ChAIs082sh8Q==\",\"format\":\"google-gemini-v1\",\"id\":\"call_4908320\",\"index\":0}],\"reasoning_encrypted\":true},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1791169245-MjiWKnEhpgHVKzFcOlS0\",\"object\":\"chat.completion.chunk\",\"created\":1791169245,\"model\":\"gemini-3-1-pro-preview\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"\",\"role\":\"assistant\",\"tool_calls\":[{\"index\":0,\"id\":\"call_4908320\",\"type\":\"function\",\"function\":{\"name\":\"lookup_rate\",\"arguments\":\"\"}}]},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1791169245-MjiWKnEhpgHVKzFcOlS0\",\"object\":\"chat.completion.chunk\",\"created\":1791169245,\"model\":\"gemini-3-1-pro-preview\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"\",\"role\":\"assistant\",\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"{\\\"code\\\":\\\"ZEBRA\\\"}\"}}]},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1791169245-MjiWKnEhpgHVKzFcOlS0\",\"object\":\"chat.completion.chunk\",\"created\":1791169245,\"model\":\"gemini-3-1-pro-preview\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"\",\"role\":\"assistant\"},\"finish_reason\":\"tool_calls\",\"native_finish_reason\":\"STOP\"}]}\n\ndata: {\"id\":\"gen-1791169245-MjiWKnEhpgHVKzFcOlS0\",\"object\":\"chat.completion.chunk\",\"created\":1791169245,\"model\":\"gemini-3-1-pro-preview\",\"service_tier\":\"provisioned\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"\",\"role\":\"assistant\"},\"finish_reason\":\"tool_calls\",\"native_finish_reason\":\"STOP\"}]}\n\ndata: {\"id\":\"gen-1791169245-MjiWKnEhpgHVKzFcOlS0\",\"object\":\"chat.completion.chunk\",\"created\":1791169245,\"model\":\"gemini-3-1-pro-preview\",\"choices\":[],\"usage\":{\"prompt_tokens\":96,\"completion_tokens\":169,\"total_tokens\":265,\"prompt_tokens_details\":{\"cached_tokens\":0},\"completion_tokens_details\":{\"reasoning_tokens\":152,\"image_tokens\":0,\"audio_tokens\":0}},\"cost\":{\"usd\":0.002775,\"diem\":0}}\n\ndata: [DONE]\n\n"
}
},
{
"transport": "http",
"request": {
"method": "POST",
"url": "https://api.venice.ai/api/v1/chat/completions",
"headers": {
"content-type": "application/json"
},
"body": "{\"model\":\"gemini-3-1-pro-preview\",\"messages\":[{\"role\":\"system\",\"content\":\"Use the provided tool for private data. After receiving the result, give only the requested number; do not call the tool again.\"},{\"role\":\"user\",\"content\":\"Look up conversion rate for code ZEBRA. Then multiply that rate by 17 and add 9. You must use lookup_rate before answering.\"},{\"role\":\"assistant\",\"content\":null,\"tool_calls\":[{\"id\":\"call_4908320\",\"type\":\"function\",\"function\":{\"name\":\"lookup_rate\",\"arguments\":\"{\\\"code\\\":\\\"ZEBRA\\\"}\"}}],\"reasoning_details\":[{\"type\":\"reasoning.text\",\"text\":\"**Calculating ZEBRA Conversion**\\n\\nI'm currently processing the calculation involving ZEBRA's conversion rate. My next step is to use the `lookup_rate` tool with the code \\\"ZEBRA\\\" to fetch the necessary value, which will then be used in the full equation.\\n\\n\",\"format\":\"google-gemini-v1\",\"index\":0},{\"type\":\"reasoning.encrypted\",\"data\":\"AY89a19Tkd2zuyuB3m6as2cMeOoFk8xFfcBdsUI+RBkTCE1GVBd5hMIfNsjOj+YTucPL1hYoWodr2oZsMKDnPuYee0AmGYl8jHzVKgQFSXzgIeWEhG+hOMvTEa4BoruFowcHzbLhym48OlBys47Tlo7WF4tK4s8Z7q8cpbqGsA0NkUqPt8P7Uk3ZaRTUJBbIE08+JgNUUwiJkT4AVSO635aMUYTcIkt+8TVbqOLITfSdDtkxhAmrInfn7FqGlNop3RH1TUcb3xnUOsnHXoreWLlAyWTlWNvOa7hjUH8vUwILPmtG6ixKZdf4i/UIgm551Z5tgE05SJBcR/m7ALagyOyEilWqxGkyf+5jvNycUlk/mBkNHLV45amgUG5zhboVEAEVFYPRa+Nnfzxi+DEdM1ntDURrB5dl4Jbotx67627EbsZm4z2sKUZ4jo/eBj9fI3MUqjTYKtMXBSyuen/m/Uw2Ji9iIQIlWUt/G86kOjQUYFY60g6TxEO4BLouwLZJ0fXxWdolvtMnH90+skJ+I7aFQjGrnwdjGsT0oXA0/vG3W8GhRSokdICVQBq14EFvjcVvI1kEgj7NTtKkB/+UWRtRbRhSA5F25/dHvqAT4ioYy/5sRwYFXMwJmM52I8DpLiaBrp8wTidLNjTpD9KlImbd2r89LS9GG5NeqNWksF9XbEQAMannsu19HC9xf+CCz5FBz5PjpR0+pvybIJLmph94u4I0cdLsbycbbdIaihqeIc593sQv3nvm9VYZMJcgnQWIKmXofSlV4zi6p0wRPvmgeiPo+Lv6DrtPTtcaRLkkyGrg8uvkq2Is9H6me8y8HCXGIze3np22MjWM5r2+UYMia+xY+E3neBC1AZrlN+GJarvf0THgHInLLnE78f+w3mJByFgXMy6OOEs4QI0+F8yxF9GembhqebXYKWGWUkuArnLfV6TZ13v8s1J9IiNObPU0AJjq+wmlg2ChAIs082sh8Q==\",\"id\":\"call_4908320\",\"format\":\"google-gemini-v1\",\"index\":0}],\"reasoning_content\":\"**Calculating ZEBRA Conversion**\\n\\nI'm currently processing the calculation involving ZEBRA's conversion rate. My next step is to use the `lookup_rate` tool with the code \\\"ZEBRA\\\" to fetch the necessary value, which will then be used in the full equation.\\n\\n\"},{\"role\":\"tool\",\"tool_call_id\":\"call_4908320\",\"content\":\"{\\\"code\\\":\\\"ZEBRA\\\",\\\"rate\\\":23}\"}],\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"lookup_rate\",\"description\":\"Retrieve the private conversion rate for a code. Only this tool knows the rate.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"code\":{\"type\":\"string\",\"enum\":[\"ZEBRA\",\"MOOSE\"]}},\"required\":[\"code\"],\"additionalProperties\":false},\"strict\":false}}],\"tool_choice\":\"none\",\"stream\":true,\"stream_options\":{\"include_usage\":true},\"max_completion_tokens\":4096,\"reasoning\":{\"effort\":\"high\"},\"venice_parameters\":{\"include_venice_system_prompt\":false}}"
},
"response": {
"status": 200,
"headers": {
"content-type": "text/event-stream; charset=utf-8"
},
"body": "data: {\"id\":\"gen-1791169249-6wlEwbJv7MicDxVsZViY\",\"object\":\"chat.completion.chunk\",\"created\":1791169249,\"model\":\"gemini-3-1-pro-preview\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"400\",\"role\":\"assistant\"},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1791169249-6wlEwbJv7MicDxVsZViY\",\"object\":\"chat.completion.chunk\",\"created\":1791169249,\"model\":\"gemini-3-1-pro-preview\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"\",\"role\":\"assistant\",\"reasoning_details\":[{\"type\":\"reasoning.text\",\"signature\":\"AY89a1+fQ9YpZF71EoH4pNDO6g5NeqvLRAav/WG6sv9V/5fpifhK6ZDc6m89qgCpOEBhjTJ+a+ux1dFog+eB2nR/G8VVKt1Zr2+2NKXO3Q3ksKE+E51IRRzK/ywpDYpE8AlE6Kb7EG2x/ZVMpy24TOLY+LL8JNsrz8uHBC1PtPtiZe/gP+Kvu0Yqk+VNYv16wAubtiA0oy0c+wyRzd80jRvqCZpBIGPlLAU515MPpjXNGu0A5ft4uu3oGPJHl5G/Ry4Fm5fvWKl759+Jwx9m7d50+vcttr5SpHm//MvNsUTnqjYkraw1S69+9oB57HoBn8d8pwTHpud/6FARun33EjfPW86Xx2fS448QtKc3c9nxSdGyZgTV0TpA63Uz7n0nGd9fRYrghp3lQv5QyoRIgNg+YeBf/WDNzSW2MO6Y2HLFoo3K0vJh1KMfWhjECNxP1Qfjfz+VjwSV7+OzF9CNw23f8R2udRBr1EUYhTu13ckjFLWjCD5fGjvmLhsae3CgmuFV2Rvaxqrf/H79Tz+X9W/Tw8PuQ0E1pCCpiMe1gR1CT+LJPWxjV96DPHQ0r+1lnekrr5NkO7UAZ64HVALHJRpACdJsVhwI1TBi4f89b1by1s7OaWlQTw7thX7CLRV2W3HoMUccJaofszVEfbD8+SaeZtZgyw9mkYvxgYtu3Odxx2YGHgs=\",\"format\":\"google-gemini-v1\",\"index\":0}]},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1791169249-6wlEwbJv7MicDxVsZViY\",\"object\":\"chat.completion.chunk\",\"created\":1791169249,\"model\":\"gemini-3-1-pro-preview\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"\",\"role\":\"assistant\"},\"finish_reason\":\"stop\",\"native_finish_reason\":\"STOP\"}]}\n\ndata: {\"id\":\"gen-1791169249-6wlEwbJv7MicDxVsZViY\",\"object\":\"chat.completion.chunk\",\"created\":1791169249,\"model\":\"gemini-3-1-pro-preview\",\"service_tier\":\"provisioned\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"\",\"role\":\"assistant\"},\"finish_reason\":\"stop\",\"native_finish_reason\":\"STOP\"}]}\n\ndata: {\"id\":\"gen-1791169249-6wlEwbJv7MicDxVsZViY\",\"object\":\"chat.completion.chunk\",\"created\":1791169249,\"model\":\"gemini-3-1-pro-preview\",\"choices\":[],\"usage\":{\"prompt_tokens\":233,\"completion_tokens\":129,\"total_tokens\":362,\"prompt_tokens_details\":{\"cached_tokens\":0},\"completion_tokens_details\":{\"reasoning_tokens\":126,\"image_tokens\":0,\"audio_tokens\":0}},\"cost\":{\"usd\":0.0025175,\"diem\":0}}\n\ndata: [DONE]\n\n"
}
},
{
"transport": "http",
"request": {
"method": "POST",
"url": "https://api.venice.ai/api/v1/chat/completions",
"headers": {
"content-type": "application/json"
},
"body": "{\"model\":\"gemini-3-1-pro-preview\",\"messages\":[{\"role\":\"system\",\"content\":\"Use the provided tool for private data. After receiving the result, give only the requested number; do not call the tool again.\"},{\"role\":\"user\",\"content\":\"Look up conversion rate for code ZEBRA. Then multiply that rate by 17 and add 9. You must use lookup_rate before answering.\"},{\"role\":\"assistant\",\"content\":null,\"tool_calls\":[{\"id\":\"call_4908320\",\"type\":\"function\",\"function\":{\"name\":\"lookup_rate\",\"arguments\":\"{\\\"code\\\":\\\"ZEBRA\\\"}\"}}],\"reasoning_details\":[{\"type\":\"reasoning.text\",\"text\":\"**Calculating ZEBRA Conversion**\\n\\nI'm currently processing the calculation involving ZEBRA's conversion rate. My next step is to use the `lookup_rate` tool with the code \\\"ZEBRA\\\" to fetch the necessary value, which will then be used in the full equation.\\n\\n\",\"format\":\"google-gemini-v1\",\"index\":0},{\"type\":\"reasoning.encrypted\",\"data\":\"AY89a19Tkd2zuyuB3m6as2cMeOoFk8xFfcBdsUI+RBkTCE1GVBd5hMIfNsjOj+YTucPL1hYoWodr2oZsMKDnPuYee0AmGYl8jHzVKgQFSXzgIeWEhG+hOMvTEa4BoruFowcHzbLhym48OlBys47Tlo7WF4tK4s8Z7q8cpbqGsA0NkUqPt8P7Uk3ZaRTUJBbIE08+JgNUUwiJkT4AVSO635aMUYTcIkt+8TVbqOLITfSdDtkxhAmrInfn7FqGlNop3RH1TUcb3xnUOsnHXoreWLlAyWTlWNvOa7hjUH8vUwILPmtG6ixKZdf4i/UIgm551Z5tgE05SJBcR/m7ALagyOyEilWqxGkyf+5jvNycUlk/mBkNHLV45amgUG5zhboVEAEVFYPRa+Nnfzxi+DEdM1ntDURrB5dl4Jbotx67627EbsZm4z2sKUZ4jo/eBj9fI3MUqjTYKtMXBSyuen/m/Uw2Ji9iIQIlWUt/G86kOjQUYFY60g6TxEO4BLouwLZJ0fXxWdolvtMnH90+skJ+I7aFQjGrnwdjGsT0oXA0/vG3W8GhRSokdICVQBq14EFvjcVvI1kEgj7NTtKkB/+UWRtRbRhSA5F25/dHvqAT4ioYy/5sRwYFXMwJmM52I8DpLiaBrp8wTidLNjTpD9KlImbd2r89LS9GG5NeqNWksF9XbEQAMannsu19HC9xf+CCz5FBz5PjpR0+pvybIJLmph94u4I0cdLsbycbbdIaihqeIc593sQv3nvm9VYZMJcgnQWIKmXofSlV4zi6p0wRPvmgeiPo+Lv6DrtPTtcaRLkkyGrg8uvkq2Is9H6me8y8HCXGIze3np22MjWM5r2+UYMia+xY+E3neBC1AZrlN+GJarvf0THgHInLLnE78f+w3mJByFgXMy6OOEs4QI0+F8yxF9GembhqebXYKWGWUkuArnLfV6TZ13v8s1J9IiNObPU0AJjq+wmlg2ChAIs082sh8Q==\",\"id\":\"call_4908320\",\"format\":\"google-gemini-v1\",\"index\":0}],\"reasoning_content\":\"**Calculating ZEBRA Conversion**\\n\\nI'm currently processing the calculation involving ZEBRA's conversion rate. My next step is to use the `lookup_rate` tool with the code \\\"ZEBRA\\\" to fetch the necessary value, which will then be used in the full equation.\\n\\n\"},{\"role\":\"tool\",\"tool_call_id\":\"call_4908320\",\"content\":\"{\\\"code\\\":\\\"ZEBRA\\\",\\\"rate\\\":23}\"},{\"role\":\"assistant\",\"content\":\"400\",\"reasoning_details\":[{\"type\":\"reasoning.text\",\"format\":\"google-gemini-v1\",\"index\":0,\"signature\":\"AY89a1+fQ9YpZF71EoH4pNDO6g5NeqvLRAav/WG6sv9V/5fpifhK6ZDc6m89qgCpOEBhjTJ+a+ux1dFog+eB2nR/G8VVKt1Zr2+2NKXO3Q3ksKE+E51IRRzK/ywpDYpE8AlE6Kb7EG2x/ZVMpy24TOLY+LL8JNsrz8uHBC1PtPtiZe/gP+Kvu0Yqk+VNYv16wAubtiA0oy0c+wyRzd80jRvqCZpBIGPlLAU515MPpjXNGu0A5ft4uu3oGPJHl5G/Ry4Fm5fvWKl759+Jwx9m7d50+vcttr5SpHm//MvNsUTnqjYkraw1S69+9oB57HoBn8d8pwTHpud/6FARun33EjfPW86Xx2fS448QtKc3c9nxSdGyZgTV0TpA63Uz7n0nGd9fRYrghp3lQv5QyoRIgNg+YeBf/WDNzSW2MO6Y2HLFoo3K0vJh1KMfWhjECNxP1Qfjfz+VjwSV7+OzF9CNw23f8R2udRBr1EUYhTu13ckjFLWjCD5fGjvmLhsae3CgmuFV2Rvaxqrf/H79Tz+X9W/Tw8PuQ0E1pCCpiMe1gR1CT+LJPWxjV96DPHQ0r+1lnekrr5NkO7UAZ64HVALHJRpACdJsVhwI1TBi4f89b1by1s7OaWlQTw7thX7CLRV2W3HoMUccJaofszVEfbD8+SaeZtZgyw9mkYvxgYtu3Odxx2YGHgs=\"}]},{\"role\":\"user\",\"content\":\"Add 7 to your previous final answer. Reply with just the number.\"}],\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"lookup_rate\",\"description\":\"Retrieve the private conversion rate for a code. Only this tool knows the rate.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"code\":{\"type\":\"string\",\"enum\":[\"ZEBRA\",\"MOOSE\"]}},\"required\":[\"code\"],\"additionalProperties\":false},\"strict\":false}}],\"tool_choice\":\"none\",\"stream\":true,\"stream_options\":{\"include_usage\":true},\"max_completion_tokens\":4096,\"reasoning\":{\"effort\":\"high\"},\"venice_parameters\":{\"include_venice_system_prompt\":false}}"
},
"response": {
"status": 200,
"headers": {
"content-type": "text/event-stream; charset=utf-8"
},
"body": "data: {\"id\":\"gen-1791169254-bvEsScICXWimu8xzJFF1\",\"object\":\"chat.completion.chunk\",\"created\":1791169254,\"model\":\"gemini-3-1-pro-preview\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"407\",\"role\":\"assistant\"},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1791169254-bvEsScICXWimu8xzJFF1\",\"object\":\"chat.completion.chunk\",\"created\":1791169254,\"model\":\"gemini-3-1-pro-preview\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"\",\"role\":\"assistant\",\"reasoning_details\":[{\"type\":\"reasoning.text\",\"signature\":\"AY89a1/t2vneT6Dy8hJmYd66cSC0Fk3bKHK7hJI5vTtBcKe38EQ6JXMwTlxi2K47pfmvruV0B6Ks0CZCF9N7rEOT+fXTleU1WPEAHHJPX7KkncEJK4KNIeJsTzyEjSPn5t8kb/vNruIV2KE2/l0kylycTNOubuO8DGlkKOp478zuwZppB0vFaoLp1byEUtyT/MHdN/KckvvKY99ZB2ksWrc51/5Z7jx7GeXGpTl3jHEKzdLWrfsYYyjPOv4h0ZHN8ANmYMTWpGUm8jqxB0pHQFBVS0U5YhF+fuU6Nk3lUNuLnEydq6UH6VydtTxDjUDALIfHhkasmTRXnL/7X3/z3rLhRXRGhga9lHnSnEhmjdLj9iOn/73wjA4QxnT14P+8F45z4ePO19ZGKr/j5WBvBMem92Yqv9HweDo9IreBa8EbqPwlsnjbXvJB7MuBp2TJd4szb7azQ+zaPU7pf5B3fjKC3K8p26qF0C+TkGd72PezZw662/FMkkC/b88sgDgMfgSK7dMqVvv7swafRzFL1IZzTFoJJ3Ouk/VCKGWCHOx+c2EPtvv+IzGhQ1k9dn44bq953wBamkzDjQWcP46+VKp2PPz5yV9AgGCmdi1SRYNU361FiUGlU40cImBLK1MoIqa/YnYaaXMtK9L7/35uOjyg/hae3REIw5N4Y1fiSx9UCKi7eYviBwrY3kZJYdy4UdsN63ahHb2SO1gfZKAg1G36Gn5TIiYGO4dNWE9c\",\"format\":\"google-gemini-v1\",\"index\":0}]},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1791169254-bvEsScICXWimu8xzJFF1\",\"object\":\"chat.completion.chunk\",\"created\":1791169254,\"model\":\"gemini-3-1-pro-preview\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"\",\"role\":\"assistant\"},\"finish_reason\":\"stop\",\"native_finish_reason\":\"STOP\"}]}\n\ndata: {\"id\":\"gen-1791169254-bvEsScICXWimu8xzJFF1\",\"object\":\"chat.completion.chunk\",\"created\":1791169254,\"model\":\"gemini-3-1-pro-preview\",\"service_tier\":\"provisioned\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"\",\"role\":\"assistant\"},\"finish_reason\":\"stop\",\"native_finish_reason\":\"STOP\"}]}\n\ndata: {\"id\":\"gen-1791169254-bvEsScICXWimu8xzJFF1\",\"object\":\"chat.completion.chunk\",\"created\":1791169254,\"model\":\"gemini-3-1-pro-preview\",\"choices\":[],\"usage\":{\"prompt_tokens\":99,\"completion_tokens\":119,\"total_tokens\":218,\"prompt_tokens_details\":{\"cached_tokens\":0},\"completion_tokens_details\":{\"reasoning_tokens\":116,\"image_tokens\":0,\"audio_tokens\":0}},\"cost\":{\"usd\":0.0020325,\"diem\":0}}\n\ndata: [DONE]\n\n"
}
}
]
}
@@ -1,71 +0,0 @@
{
"version": 1,
"metadata": {
"tags": [
"prefix:venice-chat",
"provider:venice",
"protocol:venice-chat",
"tool",
"parallel",
"signature"
],
"name": "venice-chat/gemini-preserves-per-call-signatures-through-parallel-tools",
"recordedAt": "2026-10-05T03:21:54.383Z"
},
"interactions": [
{
"transport": "http",
"request": {
"method": "POST",
"url": "https://api.venice.ai/api/v1/chat/completions",
"headers": {
"content-type": "application/json"
},
"body": "{\"model\":\"gemini-3-8-flash\",\"messages\":[{\"role\":\"user\",\"content\":\"Look up private rates for both ZEBRA and MOOSE using two calls to lookup_rate. Then add the rates and multiply by 7. Use parallel calls if possible.\"}],\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"lookup_rate\",\"description\":\"Retrieve the private conversion rate for a code. Only this tool knows the rate.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"code\":{\"type\":\"string\",\"enum\":[\"ZEBRA\",\"MOOSE\"]}},\"required\":[\"code\"],\"additionalProperties\":false},\"strict\":false}}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"max_completion_tokens\":4096,\"reasoning\":{\"effort\":\"high\"},\"venice_parameters\":{\"include_venice_system_prompt\":false},\"parallel_tool_calls\":true}"
},
"response": {
"status": 200,
"headers": {
"content-type": "text/event-stream; charset=utf-8"
},
"body": "data: {\"id\":\"gcp-1791170510482-itouwq\",\"object\":\"chat.completion.chunk\",\"created\":1791170510,\"model\":\"gemini-3-8-flash\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"tool_calls\":[{\"index\":0,\"id\":\"call_0\",\"type\":\"function\",\"thought_signature\":\"AY89a1/bp+ihzOXpD71E6UhQv6xnBjHTAdPeNHvLEAevtqznKg46D/3qO3e9Qg7wGmccL6IpLV56PwWIT+6pXoeU6ix8JCCk7nlml/Sz2huX/rNFoSRHpB+YAZK07lHP9cbotXy7FPNMaRhsl18sPSJZLlPK0iTGhJa7MmH72r4v/G3/izffMKUPRos6i5qQ52La10FUWSunPigqNnglEvWEC3Yb+d+PnEq5iOPaVCD0awXjhsIk8L0n4+T6jM4epO+whUSSgvH9PGwgmsP197hb7l5YSjQncNX0oF1G4ch3xrFzXyS/noFFy6SlBfzfN0NJmAyP//4oVqjffUJngigTAtboI4HdDwH1fPHuI5FTfmVcUGVVIEjhAEDAAHtS77uW/XdLH4bsSigjYeCzgRTYmHP8bqFb1HjRjl2HK2Pr1y6ch0JSIgtAKv8Kj4R0PM8rIYxxf7LgrsaVWogt4rq2M4HvcUcBi6IhQ/500JUCoqdhlTGMqCFUHkN6P/qAoPGZXmo50OvtNChQ4vXVGYxWp89IRwdcv8vqjIFk75/R6oZG9BsrUaC1JnQN8i3BbYc1bz/lzFCQ1IC/g1Sm+wPhsaPmoVN77101ZAdNMiP4B5Ow7K/UycMunFKWy4xdpaT7Pb+LLNy6GYy9q2TvoRXTq1lJugLH4Q8TQZj5neWZmiDxqzsj0Xvwew7zLwGSMfkpBB4rgx9XWMx5CwqO068qjhmo+0gUkxMug2RHblEGnPO7i0i9fYj9ltDfUpFxN2adDpyk4dwuIeVSlu4N4oHOgjGms5jWLp0MqkyoKFo=\",\"function\":{\"name\":\"lookup_rate\",\"arguments\":\"{\\\"code\\\":\\\"ZEBRA\\\"}\"}}]}}]}\n\ndata: {\"id\":\"gcp-1791170510482-itouwq\",\"object\":\"chat.completion.chunk\",\"created\":1791170510,\"model\":\"gemini-3-8-flash\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":1,\"id\":\"call_1\",\"type\":\"function\",\"function\":{\"name\":\"lookup_rate\",\"arguments\":\"{\\\"code\\\":\\\"MOOSE\\\"}\"}}]}}]}\n\ndata: {\"id\":\"gcp-1791170510482-itouwq\",\"object\":\"chat.completion.chunk\",\"created\":1791170510,\"model\":\"gemini-3-8-flash\",\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"tool_calls\"}]}\n\ndata: {\"id\":\"gcp-1791170510482-itouwq\",\"object\":\"chat.completion.chunk\",\"created\":1791170510,\"model\":\"gemini-3-8-flash\",\"choices\":[],\"usage\":{\"prompt_tokens\":74,\"completion_tokens\":156,\"total_tokens\":230},\"cost\":{\"usd\":0.000800625,\"diem\":0}}\n\ndata: [DONE]\n\n"
}
},
{
"transport": "http",
"request": {
"method": "POST",
"url": "https://api.venice.ai/api/v1/chat/completions",
"headers": {
"content-type": "application/json"
},
"body": "{\"model\":\"gemini-3-8-flash\",\"messages\":[{\"role\":\"user\",\"content\":\"Look up private rates for both ZEBRA and MOOSE using two calls to lookup_rate. Then add the rates and multiply by 7. Use parallel calls if possible.\"},{\"role\":\"assistant\",\"content\":null,\"tool_calls\":[{\"id\":\"call_0\",\"type\":\"function\",\"function\":{\"name\":\"lookup_rate\",\"arguments\":\"{\\\"code\\\":\\\"ZEBRA\\\"}\"},\"thought_signature\":\"AY89a1/bp+ihzOXpD71E6UhQv6xnBjHTAdPeNHvLEAevtqznKg46D/3qO3e9Qg7wGmccL6IpLV56PwWIT+6pXoeU6ix8JCCk7nlml/Sz2huX/rNFoSRHpB+YAZK07lHP9cbotXy7FPNMaRhsl18sPSJZLlPK0iTGhJa7MmH72r4v/G3/izffMKUPRos6i5qQ52La10FUWSunPigqNnglEvWEC3Yb+d+PnEq5iOPaVCD0awXjhsIk8L0n4+T6jM4epO+whUSSgvH9PGwgmsP197hb7l5YSjQncNX0oF1G4ch3xrFzXyS/noFFy6SlBfzfN0NJmAyP//4oVqjffUJngigTAtboI4HdDwH1fPHuI5FTfmVcUGVVIEjhAEDAAHtS77uW/XdLH4bsSigjYeCzgRTYmHP8bqFb1HjRjl2HK2Pr1y6ch0JSIgtAKv8Kj4R0PM8rIYxxf7LgrsaVWogt4rq2M4HvcUcBi6IhQ/500JUCoqdhlTGMqCFUHkN6P/qAoPGZXmo50OvtNChQ4vXVGYxWp89IRwdcv8vqjIFk75/R6oZG9BsrUaC1JnQN8i3BbYc1bz/lzFCQ1IC/g1Sm+wPhsaPmoVN77101ZAdNMiP4B5Ow7K/UycMunFKWy4xdpaT7Pb+LLNy6GYy9q2TvoRXTq1lJugLH4Q8TQZj5neWZmiDxqzsj0Xvwew7zLwGSMfkpBB4rgx9XWMx5CwqO068qjhmo+0gUkxMug2RHblEGnPO7i0i9fYj9ltDfUpFxN2adDpyk4dwuIeVSlu4N4oHOgjGms5jWLp0MqkyoKFo=\"},{\"id\":\"call_1\",\"type\":\"function\",\"function\":{\"name\":\"lookup_rate\",\"arguments\":\"{\\\"code\\\":\\\"MOOSE\\\"}\"}}]},{\"role\":\"tool\",\"tool_call_id\":\"call_0\",\"content\":\"{\\\"rate\\\":23}\"},{\"role\":\"tool\",\"tool_call_id\":\"call_1\",\"content\":\"{\\\"rate\\\":31}\"}],\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"lookup_rate\",\"description\":\"Retrieve the private conversion rate for a code. Only this tool knows the rate.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"code\":{\"type\":\"string\",\"enum\":[\"ZEBRA\",\"MOOSE\"]}},\"required\":[\"code\"],\"additionalProperties\":false},\"strict\":false}}],\"tool_choice\":\"none\",\"stream\":true,\"stream_options\":{\"include_usage\":true},\"max_completion_tokens\":4096,\"reasoning\":{\"effort\":\"high\"},\"venice_parameters\":{\"include_venice_system_prompt\":false},\"parallel_tool_calls\":true}"
},
"response": {
"status": 200,
"headers": {
"content-type": "text/event-stream; charset=utf-8"
},
"body": "data: {\"id\":\"gcp-1791170512529-w6lmsz\",\"object\":\"chat.completion.chunk\",\"created\":1791170512,\"model\":\"gemini-3-8-flash\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"The retrieved rates are:\\n- **\"}}]}\n\ndata: {\"id\":\"gcp-1791170512529-w6lmsz\",\"object\":\"chat.completion.chunk\",\"created\":1791170512,\"model\":\"gemini-3-8-flash\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"ZEBRA:** 23\\n- **MOOSE:** 31\\n\\n### Calculation:\\n1. **Add\"}}]}\n\ndata: {\"id\":\"gcp-1791170512529-w6lmsz\",\"object\":\"chat.completion.chunk\",\"created\":1791170512,\"model\":\"gemini-3-8-flash\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" the rates:** \\n $$23 + 31 = 54$$\\n\\n2. **Multiply by 7:** \"}}]}\n\ndata: {\"id\":\"gcp-1791170512529-w6lmsz\",\"object\":\"chat.completion.chunk\",\"created\":1791170512,\"model\":\"gemini-3-8-flash\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"\\n $$54 \\\\times 7 = 378$$\\n\\nThe final result is **378\"}}]}\n\ndata: {\"id\":\"gcp-1791170512529-w6lmsz\",\"object\":\"chat.completion.chunk\",\"created\":1791170512,\"model\":\"gemini-3-8-flash\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"**.\"}}]}\n\ndata: {\"id\":\"gcp-1791170512529-w6lmsz\",\"object\":\"chat.completion.chunk\",\"created\":1791170512,\"model\":\"gemini-3-8-flash\",\"choices\":[{\"index\":0,\"delta\":{\"thought_signature\":\"AY89a18wXUWI7m5/5GGqxfoJDqE4MU2X2SR/ZQMCX+JQ/clHQynyEjplK4a8w9tXAi8uuxYPVxYj1ULcb83zUQBsr77tSkJN5R0J2Jgc2HzYtmTW7OYHoFQqmMESnDjvDiO51y3xpXGbVkqRlXwfmap+Tpivfx45hiXlns/hObEQo1gE70NzD7d8CQOseYVz2N7MPMmGDqpAy+5SAHXGAcQ9K2ufHNUdfWM/uUi+sGUcDhGnjPXPlzIuYk7IjUQXNAH9FFqo19dBQPhE106C3aI69QAuMvyZZ/no4ou9XSOUXrstjzQwUM+TOJXsab80F1+C6DghvYm0UZSb827xCKPtj7oCQwtwg+f4sWELhflp42IL3URo2UxpZaDNpt7PYXmmcCvKAN7/PTirCijNWMtlDL0LDbiKj+DfOFEuS2VxVq+yQgWRbKJZF7bl8BCSe7+Qab0bAsu0ZJXKBlxs46ulaXn5cc933y/Yv7O0XGHbVULMzeEpZndzkHqZYs5D+ve+CJFfjWsVk1NKrTdXqt11Ov470+MCpSlHTXdjug7oUd0A0b5AH+bF4yAZAGj4snJ7hi04EQ4Ch7rASB3OB+YB+8SnStAGf0XuimNgY2wzR68qaQ==\"}}]}\n\ndata: {\"id\":\"gcp-1791170512529-w6lmsz\",\"object\":\"chat.completion.chunk\",\"created\":1791170512,\"model\":\"gemini-3-8-flash\",\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"stop\"}]}\n\ndata: {\"id\":\"gcp-1791170512529-w6lmsz\",\"object\":\"chat.completion.chunk\",\"created\":1791170512,\"model\":\"gemini-3-8-flash\",\"choices\":[],\"usage\":{\"prompt_tokens\":206,\"completion_tokens\":208,\"total_tokens\":414},\"cost\":{\"usd\":0.001168125,\"diem\":0}}\n\ndata: [DONE]\n\n"
}
},
{
"transport": "http",
"request": {
"method": "POST",
"url": "https://api.venice.ai/api/v1/chat/completions",
"headers": {
"content-type": "application/json"
},
"body": "{\"model\":\"gemini-3-8-flash\",\"messages\":[{\"role\":\"user\",\"content\":\"Look up private rates for both ZEBRA and MOOSE using two calls to lookup_rate. Then add the rates and multiply by 7. Use parallel calls if possible.\"},{\"role\":\"assistant\",\"content\":null,\"tool_calls\":[{\"id\":\"call_0\",\"type\":\"function\",\"function\":{\"name\":\"lookup_rate\",\"arguments\":\"{\\\"code\\\":\\\"ZEBRA\\\"}\"},\"thought_signature\":\"AY89a1/bp+ihzOXpD71E6UhQv6xnBjHTAdPeNHvLEAevtqznKg46D/3qO3e9Qg7wGmccL6IpLV56PwWIT+6pXoeU6ix8JCCk7nlml/Sz2huX/rNFoSRHpB+YAZK07lHP9cbotXy7FPNMaRhsl18sPSJZLlPK0iTGhJa7MmH72r4v/G3/izffMKUPRos6i5qQ52La10FUWSunPigqNnglEvWEC3Yb+d+PnEq5iOPaVCD0awXjhsIk8L0n4+T6jM4epO+whUSSgvH9PGwgmsP197hb7l5YSjQncNX0oF1G4ch3xrFzXyS/noFFy6SlBfzfN0NJmAyP//4oVqjffUJngigTAtboI4HdDwH1fPHuI5FTfmVcUGVVIEjhAEDAAHtS77uW/XdLH4bsSigjYeCzgRTYmHP8bqFb1HjRjl2HK2Pr1y6ch0JSIgtAKv8Kj4R0PM8rIYxxf7LgrsaVWogt4rq2M4HvcUcBi6IhQ/500JUCoqdhlTGMqCFUHkN6P/qAoPGZXmo50OvtNChQ4vXVGYxWp89IRwdcv8vqjIFk75/R6oZG9BsrUaC1JnQN8i3BbYc1bz/lzFCQ1IC/g1Sm+wPhsaPmoVN77101ZAdNMiP4B5Ow7K/UycMunFKWy4xdpaT7Pb+LLNy6GYy9q2TvoRXTq1lJugLH4Q8TQZj5neWZmiDxqzsj0Xvwew7zLwGSMfkpBB4rgx9XWMx5CwqO068qjhmo+0gUkxMug2RHblEGnPO7i0i9fYj9ltDfUpFxN2adDpyk4dwuIeVSlu4N4oHOgjGms5jWLp0MqkyoKFo=\"},{\"id\":\"call_1\",\"type\":\"function\",\"function\":{\"name\":\"lookup_rate\",\"arguments\":\"{\\\"code\\\":\\\"MOOSE\\\"}\"}}]},{\"role\":\"tool\",\"tool_call_id\":\"call_0\",\"content\":\"{\\\"rate\\\":23}\"},{\"role\":\"tool\",\"tool_call_id\":\"call_1\",\"content\":\"{\\\"rate\\\":31}\"},{\"role\":\"assistant\",\"content\":\"The retrieved rates are:\\n- **ZEBRA:** 23\\n- **MOOSE:** 31\\n\\n### Calculation:\\n1. **Add the rates:** \\n $$23 + 31 = 54$$\\n\\n2. **Multiply by 7:** \\n $$54 \\\\times 7 = 378$$\\n\\nThe final result is **378**.\",\"thought_signature\":\"AY89a18wXUWI7m5/5GGqxfoJDqE4MU2X2SR/ZQMCX+JQ/clHQynyEjplK4a8w9tXAi8uuxYPVxYj1ULcb83zUQBsr77tSkJN5R0J2Jgc2HzYtmTW7OYHoFQqmMESnDjvDiO51y3xpXGbVkqRlXwfmap+Tpivfx45hiXlns/hObEQo1gE70NzD7d8CQOseYVz2N7MPMmGDqpAy+5SAHXGAcQ9K2ufHNUdfWM/uUi+sGUcDhGnjPXPlzIuYk7IjUQXNAH9FFqo19dBQPhE106C3aI69QAuMvyZZ/no4ou9XSOUXrstjzQwUM+TOJXsab80F1+C6DghvYm0UZSb827xCKPtj7oCQwtwg+f4sWELhflp42IL3URo2UxpZaDNpt7PYXmmcCvKAN7/PTirCijNWMtlDL0LDbiKj+DfOFEuS2VxVq+yQgWRbKJZF7bl8BCSe7+Qab0bAsu0ZJXKBlxs46ulaXn5cc933y/Yv7O0XGHbVULMzeEpZndzkHqZYs5D+ve+CJFfjWsVk1NKrTdXqt11Ov470+MCpSlHTXdjug7oUd0A0b5AH+bF4yAZAGj4snJ7hi04EQ4Ch7rASB3OB+YB+8SnStAGf0XuimNgY2wzR68qaQ==\"},{\"role\":\"user\",\"content\":\"Add 7 to your previous final answer. Reply with just the number.\"}],\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"lookup_rate\",\"description\":\"Retrieve the private conversion rate for a code. Only this tool knows the rate.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"code\":{\"type\":\"string\",\"enum\":[\"ZEBRA\",\"MOOSE\"]}},\"required\":[\"code\"],\"additionalProperties\":false},\"strict\":false}}],\"tool_choice\":\"none\",\"stream\":true,\"stream_options\":{\"include_usage\":true},\"max_completion_tokens\":4096,\"reasoning\":{\"effort\":\"high\"},\"venice_parameters\":{\"include_venice_system_prompt\":false},\"parallel_tool_calls\":true}"
},
"response": {
"status": 200,
"headers": {
"content-type": "text/event-stream; charset=utf-8"
},
"body": "data: {\"id\":\"gcp-1791170514341-j5gh1h\",\"object\":\"chat.completion.chunk\",\"created\":1791170514,\"model\":\"gemini-3-8-flash\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"385\"}}]}\n\ndata: {\"id\":\"gcp-1791170514341-j5gh1h\",\"object\":\"chat.completion.chunk\",\"created\":1791170514,\"model\":\"gemini-3-8-flash\",\"choices\":[{\"index\":0,\"delta\":{\"thought_signature\":\"AY89a1/DMIB/2qixC5QVQQH1tA2QiQFpEsXnIDd7wlCgwuYQXBGWgSsP1bpTZJUJM/yqKdQh96+/c6c28VMbW5iovCDxxeK56KfmSBSBIPtNW8BUHHLxQw9vdsxQvQyHcGW6rLb4W3gElqx6JFmNkTMvijUS9QDgkPlPipirbv7pqLejFyQ040GD4IVhP8ovyR49EJm61W0JvJOPFT30/gVvaKA6RnRgTKGiHrZWVqLxhKVkb0gC7jI9CNNASOYH67uelFQ2trbcf5Qbhx4q93vAErFsBzvzliSuaELDrxZAF9VCNIiWz4AUtEWI1FLCMYxkgNry/K+iNpwcXu8BxABETbNkEb722wjpt1A2EZK/5qhqKQvUOq+WgSCPCLoI1zfUiGN6UV9D/QGtyS2Iyt3AYlvKjZVVzP217ItWSDacGzigLd4t+4MMW7Dvbs7ffBvBV+EzMCP1dnsLkKCxicfvT3e2EgY+liDSaSZG3sE7Lm4WKCYbI4rrBLMupERSaFfAHWdTlea5HeM=\"}}]}\n\ndata: {\"id\":\"gcp-1791170514341-j5gh1h\",\"object\":\"chat.completion.chunk\",\"created\":1791170514,\"model\":\"gemini-3-8-flash\",\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"stop\"}]}\n\ndata: {\"id\":\"gcp-1791170514341-j5gh1h\",\"object\":\"chat.completion.chunk\",\"created\":1791170514,\"model\":\"gemini-3-8-flash\",\"choices\":[],\"usage\":{\"prompt_tokens\":429,\"completion_tokens\":83,\"total_tokens\":512},\"cost\":{\"usd\":0.00079125,\"diem\":0}}\n\ndata: [DONE]\n\n"
}
}
]
}
@@ -1,75 +0,0 @@
{
"version": 1,
"metadata": {
"model": "openai-gpt-54-mini",
"tags": [
"prefix:venice-chat",
"provider:venice",
"protocol:venice-chat",
"reasoning",
"tool",
"tool-loop",
"continuation",
"usage",
"encrypted"
],
"name": "venice-chat/openai-gpt-54-mini-preserves-reasoning-through-a-tool-loop-and-follow-up",
"recordedAt": "2026-10-05T03:01:10.096Z"
},
"interactions": [
{
"transport": "http",
"request": {
"method": "POST",
"url": "https://api.venice.ai/api/v1/chat/completions",
"headers": {
"content-type": "application/json"
},
"body": "{\"model\":\"openai-gpt-54-mini\",\"messages\":[{\"role\":\"system\",\"content\":\"Use the provided tool for private data. After receiving the result, give only the requested number; do not call the tool again.\"},{\"role\":\"user\",\"content\":\"Look up conversion rate for code ZEBRA. Then multiply that rate by 17 and add 9. You must use lookup_rate before answering.\"}],\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"lookup_rate\",\"description\":\"Retrieve the private conversion rate for a code. Only this tool knows the rate.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"code\":{\"type\":\"string\",\"enum\":[\"ZEBRA\",\"MOOSE\"]}},\"required\":[\"code\"],\"additionalProperties\":false},\"strict\":false}}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"max_completion_tokens\":4096,\"venice_parameters\":{\"include_venice_system_prompt\":false}}"
},
"response": {
"status": 200,
"headers": {
"content-type": "text/event-stream; charset=utf-8"
},
"body": "data: {\"id\":\"resp_014406f0f45e19c6016ac312efd07487d28fc51d6572174c85\",\"object\":\"chat.completion.chunk\",\"created\":1791169264,\"model\":\"openai-gpt-54-mini\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\"**Calculating private data**\\n\\nI\"},\"finish_reason\":null}]}\n\ndata: {\"id\":\"resp_014406f0f45e19c6016ac312efd07487d28fc51d6572174c85\",\"object\":\"chat.completion.chunk\",\"created\":1791169264,\"model\":\"openai-gpt-54-mini\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\" need\"},\"finish_reason\":null}]}\n\ndata: {\"id\":\"resp_014406f0f45e19c6016ac312efd07487d28fc51d6572174c85\",\"object\":\"chat.completion.chunk\",\"created\":1791169264,\"model\":\"openai-gpt-54-mini\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\" to\"},\"finish_reason\":null}]}\n\ndata: {\"id\":\"resp_014406f0f45e19c6016ac312efd07487d28fc51d6572174c85\",\"object\":\"chat.completion.chunk\",\"created\":1791169264,\"model\":\"openai-gpt-54-mini\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\" follow\"},\"finish_reason\":null}]}\n\ndata: {\"id\":\"resp_014406f0f45e19c6016ac312efd07487d28fc51d6572174c85\",\"object\":\"chat.completion.chunk\",\"created\":1791169264,\"model\":\"openai-gpt-54-mini\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\" the\"},\"finish_reason\":null}]}\n\ndata: {\"id\":\"resp_014406f0f45e19c6016ac312efd07487d28fc51d6572174c85\",\"object\":\"chat.completion.chunk\",\"created\":1791169264,\"model\":\"openai-gpt-54-mini\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\" developer\"},\"finish_reason\":null}]}\n\ndata: {\"id\":\"resp_014406f0f45e19c6016ac312efd07487d28fc51d6572174c85\",\"object\":\"chat.completion.chunk\",\"created\":1791169264,\"model\":\"openai-gpt-54-mini\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\"'s\"},\"finish_reason\":null}]}\n\ndata: {\"id\":\"resp_014406f0f45e19c6016ac312efd07487d28fc51d6572174c85\",\"object\":\"chat.completion.chunk\",\"created\":1791169264,\"model\":\"openai-gpt-54-mini\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\" instructions\"},\"finish_reason\":null}]}\n\ndata: {\"id\":\"resp_014406f0f45e19c6016ac312efd07487d28fc51d6572174c85\",\"object\":\"chat.completion.chunk\",\"created\":1791169264,\"model\":\"openai-gpt-54-mini\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\" and\"},\"finish_reason\":null}]}\n\ndata: {\"id\":\"resp_014406f0f45e19c6016ac312efd07487d28fc51d6572174c85\",\"object\":\"chat.completion.chunk\",\"created\":1791169264,\"model\":\"openai-gpt-54-mini\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\" use\"},\"finish_reason\":null}]}\n\ndata: {\"id\":\"resp_014406f0f45e19c6016ac312efd07487d28fc51d6572174c85\",\"object\":\"chat.completion.chunk\",\"created\":1791169264,\"model\":\"openai-gpt-54-mini\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\" the\"},\"finish_reason\":null}]}\n\ndata: {\"id\":\"resp_014406f0f45e19c6016ac312efd07487d28fc51d6572174c85\",\"object\":\"chat.completion.chunk\",\"created\":1791169264,\"model\":\"openai-gpt-54-mini\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\" provided\"},\"finish_reason\":null}]}\n\ndata: {\"id\":\"resp_014406f0f45e19c6016ac312efd07487d28fc51d6572174c85\",\"object\":\"chat.completion.chunk\",\"created\":1791169264,\"model\":\"openai-gpt-54-mini\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\" tool\"},\"finish_reason\":null}]}\n\ndata: {\"id\":\"resp_014406f0f45e19c6016ac312efd07487d28fc51d6572174c85\",\"object\":\"chat.completion.chunk\",\"created\":1791169264,\"model\":\"openai-gpt-54-mini\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\" to\"},\"finish_reason\":null}]}\n\ndata: {\"id\":\"resp_014406f0f45e19c6016ac312efd07487d28fc51d6572174c85\",\"object\":\"chat.completion.chunk\",\"created\":1791169264,\"model\":\"openai-gpt-54-mini\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\" handle\"},\"finish_reason\":null}]}\n\ndata: {\"id\":\"resp_014406f0f45e19c6016ac312efd07487d28fc51d6572174c85\",\"object\":\"chat.completion.chunk\",\"created\":1791169264,\"model\":\"openai-gpt-54-mini\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\" private\"},\"finish_reason\":null}]}\n\ndata: {\"id\":\"resp_014406f0f45e19c6016ac312efd07487d28fc51d6572174c85\",\"object\":\"chat.completion.chunk\",\"created\":1791169264,\"model\":\"openai-gpt-54-mini\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\" data\"},\"finish_reason\":null}]}\n\ndata: {\"id\":\"resp_014406f0f45e19c6016ac312efd07487d28fc51d6572174c85\",\"object\":\"chat.completion.chunk\",\"created\":1791169264,\"model\":\"openai-gpt-54-mini\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\".\"},\"finish_reason\":null}]}\n\ndata: {\"id\":\"resp_014406f0f45e19c6016ac312efd07487d28fc51d6572174c85\",\"object\":\"chat.completion.chunk\",\"created\":1791169264,\"model\":\"openai-gpLine truncated
}
},
{
"transport": "http",
"request": {
"method": "POST",
"url": "https://api.venice.ai/api/v1/chat/completions",
"headers": {
"content-type": "application/json"
},
"body": "{\"model\":\"openai-gpt-54-mini\",\"messages\":[{\"role\":\"system\",\"content\":\"Use the provided tool for private data. After receiving the result, give only the requested number; do not call the tool again.\"},{\"role\":\"user\",\"content\":\"Look up conversion rate for code ZEBRA. Then multiply that rate by 17 and add 9. You must use lookup_rate before answering.\"},{\"role\":\"assistant\",\"content\":null,\"tool_calls\":[{\"id\":\"call_onp7EArhOgXu0hGRF6Y5WHLu\",\"type\":\"function\",\"function\":{\"name\":\"lookup_rate\",\"arguments\":\"{\\\"code\\\":\\\"ZEBRA\\\"}\"}}],\"reasoning_details\":[{\"type\":\"reasoning.encrypted\",\"data\":\"gAAAAABqwxLxEN4vtmI2WcZ_aDDttdmPwAHgnV2gLZBo_f_mmTdGm4oZzE2JVzcj72AeXHyABmmo2VQN4nXaZK9GSSUZRlYL8mECMxKTj9AMgL8zlnuN5WDexxU-e9SduGP36RN9nH56-go4dz8eu5DEMvMf_TMoTnxz5AnC_UWbty63Vp8jEgkD-DoRjd0cg9LjthP_FLyMvP2Wj9qMJxEuO7Mw2_Pu4I3YeChnygLvmRfRR2QkamL5vjb9yHkl0Lb502vEGB2DW_czNEpHxisifvwRBjA7vk0mbn2dlhiR80aNHO71hC-QqrtUfPXx2HIbVLfbPdJf0saI0wD7ZuCJSQCBfB7fMCnj2ZLQeXa67lgXwsZDLBg4HmSJFvsMinx1IHIjUk7iebsKvmYx40cjqpXL3wj7htZWk2hO9OyGGUwlRsn5THdhTsgwg2SPbuHxb-sLPQnEFZ7bRp5lsVm31X2dlHDa1uZ7KLJZyy9g2CLIKsBwFFoCK7jY3HBXfL21ac5TAkOzVmz-XKl6OMPSl-PSdByOO15uHHpyOcJvJvuaea5Tj1nihtuDlhUP5EQPi7MiWpJMFo9AhDCafAbaQOEF5W8AQPeb_GXvPTzhySRAC8PxrhraEcZ4fjpjLqkv6mi_A1YHwbHEHHWjcK5VEKc3dadlIM_s6djclFBocVXrvnQQElVhWBDM-RtK9sgR07BFj5LVk6fYvg0b6gQnbDuQH18jhtPoj0SX0lN921XEQ1ebMCWGLANND2ew_wyNShYouJohefLULicWG57xh_yV1KEG1ufBedq2CPvCHA9Hm5UssbeJx5yBriyUA2Yh4D7j2UBT_KDWEHydDLQ4mWEyLTfQn0ZB3ZOqkU79z3HpK2w_VHUnkl5nEYEU2bmF5lsG33B4gtbQIBtZT76-fizXsvVZx__taOWXg96VMi3CD3DY0TS76B4U92KFmAFJ4aeiM8bkPuzf1Qd202EWE39CotUDoxAM6mzQu0NObTDnGQE776P6kzUoIKUmP8sDwYG5xot8ZTuXcBYysvj4-pFC_fvqMhsdfgonbke78SIjHtmePsOkfHBnCjSSUFKEbaM0nm8jJ6nYVwiYFuXBKD31QD462TAC7ORVIn32sWSgV2t3Sd7vq4ws2Yy7nUoILRAt_NYQUEXt7_R8J5YWj1L6Ruagk7Vtt-CAWxT6FhVyU4JhMb6-tJrYHgdmtunYrjpHBiOVdQR25aOmLbc_WlDvpO4azcRazpgkASAjPaaDycYceKcheiyUDBgFrvcSYrS1fJb6YZrTzOKw1Ztd0YsklS4nc3jffLNLrBQVvnkajj0OHjGcW8wkP93TCMJyt8tfIH0Rd5hIZLY5EqUWj9yZgXO7CuDKQZIEI0oZXef3mjfQqH8Q9rZsSgS3DI3mCF3CN003R_ZKKLGWkG8VzwlvdocNxaV_Z-ujp_O0YMyOcmEoTJI-j3JP3nefNboyP7EWND6VWDjJbqg64dvu0PK0EW5L0lnAu0l-WVb2qmXLYao_7bOlD8JLFZOyUgHAWmetmnm0NuxzevJEVBkNUu1jQatPzf2Jzf9SGKM_3bM_z0TM5McCZ2Pikzwp96QlaiEEWV_j2g02PT5Y3td-lfpoKxoFD5tltGNY9TlPzK-4a9Iz0hloiCprxWMLGKw2JzctrPF1xr42PKGNgPE84xAQ3sQ4mQ==\",\"id\":\"rs_014406f0f45e19c6016ac312f01d2487d2a5f2bdb7fbc42f76\",\"format\":\"openai-responses-v1\",\"index\":0}],\"reasoning_content\":\"**Calculating private data**\\n\\nI need to follow the developer's instructions and use the provided tool to handle private data. First, I should look up the rate for \\\"ZEBRA\\\" before I answer. After that, I’ll multiply the rate by 17 and then add 9 to get the required number. It’s important to give only the requested number and avoid making another tool call. I’ll likely use the commentary channel for this tool call.\\n\\n__ENCRYPTED_REASONING__id=rs_014406f0f45e19c6016ac312f01d2487d2a5f2bdb7fbc42f76\\ngAAAAABqwxLxEN4vtmI2WcZ_aDDttdmPwAHgnV2gLZBo_f_mmTdGm4oZzE2JVzcj72AeXHyABmmo2VQN4nXaZK9GSSUZRlYL8mECMxKTj9AMgL8zlnuN5WDexxU-e9SduGP36RN9nH56-go4dz8eu5DEMvMf_TMoTnxz5AnC_UWbty63Vp8jEgkD-DoRjd0cg9LjthP_FLyMvP2Wj9qMJxEuO7Mw2_Pu4I3YeChnygLvmRfRR2QkamL5vjb9yHkl0Lb502vEGB2DW_czNEpHxisifvwRBjA7vk0mbn2dlhiR80aNHO71hC-QqrtUfPXx2HIbVLfbPdJf0saI0wD7ZuCJSQCBfB7fMCnj2ZLQeXa67lgXwsZDLBg4HmSJFvsMinx1IHIjUk7iebsKvmYx40cjqpXL3wj7htZWk2hO9OyGGUwlRsn5THdhTsgwg2SPbuHxb-sLPQnEFZ7bRp5lsVm31X2dlHDa1uZ7KLJZyy9g2CLIKsBwFFoCK7jY3HBXfL21ac5TAkOzVmz-XKl6OMPSl-PSdByOO15uHHpyOcJvJvuaea5Tj1nihtuDlhUP5EQPi7MiWpJMFo9AhDCafAbaQOEF5W8AQPeb_GXvPTzhySRAC8PxrhraEcZ4fjpjLqkv6mi_A1YHwbHEHHWjcK5VEKc3dadlIM_s6djclFBocVXrvnQQElVhWBDM-RtK9sgR07BFj5LVk6fYvg0b6gQnbDuQH18jhtPoj0SX0lN921XEQ1ebMCWGLANND2ew_wyNShYouJohefLULicWG57xh_yV1KEG1ufBedq2CPvCHA9Hm5UssbeJx5yBriyUA2Yh4D7j2UBT_KDWEHydDLQ4mWEyLTfQn0ZB3ZOqkU79z3HpK2w_VHUnkl5nEYEU2bmF5lsG33B4gtbQIBtZT76-fizXsvVZx__taOWXg96VMi3CD3DY0TS76B4U92KFmAFJ4aeiM8bkPuzf1Qd202EWE39CotUDoxAM6mzQu0NObTDnGQE776P6kzUoIKUmP8sDwYG5xot8ZTuXcBYysvj4-pFC_fvqMhsdfgonbke78SIjHtmePsOkfHBnCjSSUFKEbaM0nm8jJ6nYVwiYFuXBKD31QD462TAC7ORVIn32sWSgV2t3Sd7vq4ws2Yy7nUoILRAt_NYQUEXt7_R8J5YWj1L6Ruagk7Vtt-CAWxT6FhVyU4JhMb6-tJrYHgdmtunYrjpHBiOVdQR25aOmLbc_WlDvpO4azcRazpgkASAjPaaDycYceKcheiyUDBgFrvcSYrS1fJb6YZrTzOKw1Ztd0YsklS4nc3jffLNLrBQVvnkajj0OHjGcW8wkP93TCMJyt8tfIH0Rd5hIZLY5EqUWj9yZgXO7CuDKQZIEI0oZXef3mjfQqH8Q9rZsSgS3DI3mCF3CN003R_ZKKLGWkG8VzwlvdocNxaV_Z-ujp_O0YMyOcmEoTJI-j3JP3nefNboyP7EWND6VWDjJbqg64dvu0PK0EW5L0lnAu0l-WVb2qmXLYao_7bOlD8JLFZOyUgHAWmetmnm0NuxzevJEVBkNUu1jQatPzf2Jzf9SGKM_3bM_z0TM5McCZ2Pikzwp96QlaiEEWV_j2g02PT5Y3td-lfpoKxoFD5tltGNY9TlPzK-4a9Iz0hloiCprxWMLGKw2JzctrPF1xr42PKGNgPE84xAQ3sQ4mQ==\"},{\"role\":\"tool\",\"tool_call_id\":\"call_onp7EArhOgXu0hGRF6Y5WHLu\",\"content\":\"{\\\"code\\\":\\\"ZEBRA\\\",\\\"rate\\\":23}\"}],\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"lookup_rate\",\"description\":\"Retrieve the private conversion rate for aLine truncated
},
"response": {
"status": 200,
"headers": {
"content-type": "text/event-stream; charset=utf-8"
},
"body": "data: {\"id\":\"resp_014406f0f45e19c6016ac312f2137487d2aa3a1f180cbf07eb\",\"object\":\"chat.completion.chunk\",\"created\":1791169266,\"model\":\"openai-gpt-54-mini\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\"**Calculating a simple math problem**\\n\\nI'm\"},\"finish_reason\":null}]}\n\ndata: {\"id\":\"resp_014406f0f45e19c6016ac312f2137487d2aa3a1f180cbf07eb\",\"object\":\"chat.completion.chunk\",\"created\":1791169266,\"model\":\"openai-gpt-54-mini\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\" working\"},\"finish_reason\":null}]}\n\ndata: {\"id\":\"resp_014406f0f45e19c6016ac312f2137487d2aa3a1f180cbf07eb\",\"object\":\"chat.completion.chunk\",\"created\":1791169266,\"model\":\"openai-gpt-54-mini\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\" through\"},\"finish_reason\":null}]}\n\ndata: {\"id\":\"resp_014406f0f45e19c6016ac312f2137487d2aa3a1f180cbf07eb\",\"object\":\"chat.completion.chunk\",\"created\":1791169266,\"model\":\"openai-gpt-54-mini\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\" the\"},\"finish_reason\":null}]}\n\ndata: {\"id\":\"resp_014406f0f45e19c6016ac312f2137487d2aa3a1f180cbf07eb\",\"object\":\"chat.completion.chunk\",\"created\":1791169266,\"model\":\"openai-gpt-54-mini\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\" math\"},\"finish_reason\":null}]}\n\ndata: {\"id\":\"resp_014406f0f45e19c6016ac312f2137487d2aa3a1f180cbf07eb\",\"object\":\"chat.completion.chunk\",\"created\":1791169266,\"model\":\"openai-gpt-54-mini\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\":\"},\"finish_reason\":null}]}\n\ndata: {\"id\":\"resp_014406f0f45e19c6016ac312f2137487d2aa3a1f180cbf07eb\",\"object\":\"chat.completion.chunk\",\"created\":1791169266,\"model\":\"openai-gpt-54-mini\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\" first\"},\"finish_reason\":null}]}\n\ndata: {\"id\":\"resp_014406f0f45e19c6016ac312f2137487d2aa3a1f180cbf07eb\",\"object\":\"chat.completion.chunk\",\"created\":1791169266,\"model\":\"openai-gpt-54-mini\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\",\"},\"finish_reason\":null}]}\n\ndata: {\"id\":\"resp_014406f0f45e19c6016ac312f2137487d2aa3a1f180cbf07eb\",\"object\":\"chat.completion.chunk\",\"created\":1791169266,\"model\":\"openai-gpt-54-mini\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\" I\"},\"finish_reason\":null}]}\n\ndata: {\"id\":\"resp_014406f0f45e19c6016ac312f2137487d2aa3a1f180cbf07eb\",\"object\":\"chat.completion.chunk\",\"created\":1791169266,\"model\":\"openai-gpt-54-mini\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\" multiply\"},\"finish_reason\":null}]}\n\ndata: {\"id\":\"resp_014406f0f45e19c6016ac312f2137487d2aa3a1f180cbf07eb\",\"object\":\"chat.completion.chunk\",\"created\":1791169267,\"model\":\"openai-gpt-54-mini\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\" 23\"},\"finish_reason\":null}]}\n\ndata: {\"id\":\"resp_014406f0f45e19c6016ac312f2137487d2aa3a1f180cbf07eb\",\"object\":\"chat.completion.chunk\",\"created\":1791169267,\"model\":\"openai-gpt-54-mini\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\" by\"},\"finish_reason\":null}]}\n\ndata: {\"id\":\"resp_014406f0f45e19c6016ac312f2137487d2aa3a1f180cbf07eb\",\"object\":\"chat.completion.chunk\",\"created\":1791169267,\"model\":\"openai-gpt-54-mini\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\" 17\"},\"finish_reason\":null}]}\n\ndata: {\"id\":\"resp_014406f0f45e19c6016ac312f2137487d2aa3a1f180cbf07eb\",\"object\":\"chat.completion.chunk\",\"created\":1791169267,\"model\":\"openai-gpt-54-mini\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\",\"},\"finish_reason\":null}]}\n\ndata: {\"id\":\"resp_014406f0f45e19c6016ac312f2137487d2aa3a1f180cbf07eb\",\"object\":\"chat.completion.chunk\",\"created\":1791169267,\"model\":\"openai-gpt-54-mini\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\" and\"},\"finish_reason\":null}]}\n\ndata: {\"id\":\"resp_014406f0f45e19c6016ac312f2137487d2aa3a1f180cbf07eb\",\"object\":\"chat.completion.chunk\",\"created\":1791169267,\"model\":\"openai-gpt-54-mini\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\" that\"},\"finish_reason\":null}]}\n\ndata: {\"id\":\"resp_014406f0f45e19c6016ac312f2137487d2aa3a1f180cbf07eb\",\"object\":\"chat.completion.chunk\",\"created\":1791169267,\"model\":\"openai-gpt-54-mini\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\" gives\"},\"finish_reason\":null}]}\n\ndata: {\"id\":\"resp_014406f0f45e19c6016ac312f2137487d2aa3a1f180cbf07eb\",\"object\":\"chat.completion.chunk\",\"created\":1791169267,\"model\":\"openai-gpt-54-mini\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\" me\"},\"finish_reason\":null}]}\n\ndata: {\"id\":\"resp_014406f0f45e19c6016ac312f2137487d2aa3a1f180cbf07eb\",\"object\":\"chat.completion.chunk\",\"created\":1791169267,\"model\":\"openai-gpt-54-mini\"Line truncated
}
},
{
"transport": "http",
"request": {
"method": "POST",
"url": "https://api.venice.ai/api/v1/chat/completions",
"headers": {
"content-type": "application/json"
},
"body": "{\"model\":\"openai-gpt-54-mini\",\"messages\":[{\"role\":\"system\",\"content\":\"Use the provided tool for private data. After receiving the result, give only the requested number; do not call the tool again.\"},{\"role\":\"user\",\"content\":\"Look up conversion rate for code ZEBRA. Then multiply that rate by 17 and add 9. You must use lookup_rate before answering.\"},{\"role\":\"assistant\",\"content\":null,\"tool_calls\":[{\"id\":\"call_onp7EArhOgXu0hGRF6Y5WHLu\",\"type\":\"function\",\"function\":{\"name\":\"lookup_rate\",\"arguments\":\"{\\\"code\\\":\\\"ZEBRA\\\"}\"}}],\"reasoning_details\":[{\"type\":\"reasoning.encrypted\",\"data\":\"gAAAAABqwxLxEN4vtmI2WcZ_aDDttdmPwAHgnV2gLZBo_f_mmTdGm4oZzE2JVzcj72AeXHyABmmo2VQN4nXaZK9GSSUZRlYL8mECMxKTj9AMgL8zlnuN5WDexxU-e9SduGP36RN9nH56-go4dz8eu5DEMvMf_TMoTnxz5AnC_UWbty63Vp8jEgkD-DoRjd0cg9LjthP_FLyMvP2Wj9qMJxEuO7Mw2_Pu4I3YeChnygLvmRfRR2QkamL5vjb9yHkl0Lb502vEGB2DW_czNEpHxisifvwRBjA7vk0mbn2dlhiR80aNHO71hC-QqrtUfPXx2HIbVLfbPdJf0saI0wD7ZuCJSQCBfB7fMCnj2ZLQeXa67lgXwsZDLBg4HmSJFvsMinx1IHIjUk7iebsKvmYx40cjqpXL3wj7htZWk2hO9OyGGUwlRsn5THdhTsgwg2SPbuHxb-sLPQnEFZ7bRp5lsVm31X2dlHDa1uZ7KLJZyy9g2CLIKsBwFFoCK7jY3HBXfL21ac5TAkOzVmz-XKl6OMPSl-PSdByOO15uHHpyOcJvJvuaea5Tj1nihtuDlhUP5EQPi7MiWpJMFo9AhDCafAbaQOEF5W8AQPeb_GXvPTzhySRAC8PxrhraEcZ4fjpjLqkv6mi_A1YHwbHEHHWjcK5VEKc3dadlIM_s6djclFBocVXrvnQQElVhWBDM-RtK9sgR07BFj5LVk6fYvg0b6gQnbDuQH18jhtPoj0SX0lN921XEQ1ebMCWGLANND2ew_wyNShYouJohefLULicWG57xh_yV1KEG1ufBedq2CPvCHA9Hm5UssbeJx5yBriyUA2Yh4D7j2UBT_KDWEHydDLQ4mWEyLTfQn0ZB3ZOqkU79z3HpK2w_VHUnkl5nEYEU2bmF5lsG33B4gtbQIBtZT76-fizXsvVZx__taOWXg96VMi3CD3DY0TS76B4U92KFmAFJ4aeiM8bkPuzf1Qd202EWE39CotUDoxAM6mzQu0NObTDnGQE776P6kzUoIKUmP8sDwYG5xot8ZTuXcBYysvj4-pFC_fvqMhsdfgonbke78SIjHtmePsOkfHBnCjSSUFKEbaM0nm8jJ6nYVwiYFuXBKD31QD462TAC7ORVIn32sWSgV2t3Sd7vq4ws2Yy7nUoILRAt_NYQUEXt7_R8J5YWj1L6Ruagk7Vtt-CAWxT6FhVyU4JhMb6-tJrYHgdmtunYrjpHBiOVdQR25aOmLbc_WlDvpO4azcRazpgkASAjPaaDycYceKcheiyUDBgFrvcSYrS1fJb6YZrTzOKw1Ztd0YsklS4nc3jffLNLrBQVvnkajj0OHjGcW8wkP93TCMJyt8tfIH0Rd5hIZLY5EqUWj9yZgXO7CuDKQZIEI0oZXef3mjfQqH8Q9rZsSgS3DI3mCF3CN003R_ZKKLGWkG8VzwlvdocNxaV_Z-ujp_O0YMyOcmEoTJI-j3JP3nefNboyP7EWND6VWDjJbqg64dvu0PK0EW5L0lnAu0l-WVb2qmXLYao_7bOlD8JLFZOyUgHAWmetmnm0NuxzevJEVBkNUu1jQatPzf2Jzf9SGKM_3bM_z0TM5McCZ2Pikzwp96QlaiEEWV_j2g02PT5Y3td-lfpoKxoFD5tltGNY9TlPzK-4a9Iz0hloiCprxWMLGKw2JzctrPF1xr42PKGNgPE84xAQ3sQ4mQ==\",\"id\":\"rs_014406f0f45e19c6016ac312f01d2487d2a5f2bdb7fbc42f76\",\"format\":\"openai-responses-v1\",\"index\":0}],\"reasoning_content\":\"**Calculating private data**\\n\\nI need to follow the developer's instructions and use the provided tool to handle private data. First, I should look up the rate for \\\"ZEBRA\\\" before I answer. After that, I’ll multiply the rate by 17 and then add 9 to get the required number. It’s important to give only the requested number and avoid making another tool call. I’ll likely use the commentary channel for this tool call.\\n\\n__ENCRYPTED_REASONING__id=rs_014406f0f45e19c6016ac312f01d2487d2a5f2bdb7fbc42f76\\ngAAAAABqwxLxEN4vtmI2WcZ_aDDttdmPwAHgnV2gLZBo_f_mmTdGm4oZzE2JVzcj72AeXHyABmmo2VQN4nXaZK9GSSUZRlYL8mECMxKTj9AMgL8zlnuN5WDexxU-e9SduGP36RN9nH56-go4dz8eu5DEMvMf_TMoTnxz5AnC_UWbty63Vp8jEgkD-DoRjd0cg9LjthP_FLyMvP2Wj9qMJxEuO7Mw2_Pu4I3YeChnygLvmRfRR2QkamL5vjb9yHkl0Lb502vEGB2DW_czNEpHxisifvwRBjA7vk0mbn2dlhiR80aNHO71hC-QqrtUfPXx2HIbVLfbPdJf0saI0wD7ZuCJSQCBfB7fMCnj2ZLQeXa67lgXwsZDLBg4HmSJFvsMinx1IHIjUk7iebsKvmYx40cjqpXL3wj7htZWk2hO9OyGGUwlRsn5THdhTsgwg2SPbuHxb-sLPQnEFZ7bRp5lsVm31X2dlHDa1uZ7KLJZyy9g2CLIKsBwFFoCK7jY3HBXfL21ac5TAkOzVmz-XKl6OMPSl-PSdByOO15uHHpyOcJvJvuaea5Tj1nihtuDlhUP5EQPi7MiWpJMFo9AhDCafAbaQOEF5W8AQPeb_GXvPTzhySRAC8PxrhraEcZ4fjpjLqkv6mi_A1YHwbHEHHWjcK5VEKc3dadlIM_s6djclFBocVXrvnQQElVhWBDM-RtK9sgR07BFj5LVk6fYvg0b6gQnbDuQH18jhtPoj0SX0lN921XEQ1ebMCWGLANND2ew_wyNShYouJohefLULicWG57xh_yV1KEG1ufBedq2CPvCHA9Hm5UssbeJx5yBriyUA2Yh4D7j2UBT_KDWEHydDLQ4mWEyLTfQn0ZB3ZOqkU79z3HpK2w_VHUnkl5nEYEU2bmF5lsG33B4gtbQIBtZT76-fizXsvVZx__taOWXg96VMi3CD3DY0TS76B4U92KFmAFJ4aeiM8bkPuzf1Qd202EWE39CotUDoxAM6mzQu0NObTDnGQE776P6kzUoIKUmP8sDwYG5xot8ZTuXcBYysvj4-pFC_fvqMhsdfgonbke78SIjHtmePsOkfHBnCjSSUFKEbaM0nm8jJ6nYVwiYFuXBKD31QD462TAC7ORVIn32sWSgV2t3Sd7vq4ws2Yy7nUoILRAt_NYQUEXt7_R8J5YWj1L6Ruagk7Vtt-CAWxT6FhVyU4JhMb6-tJrYHgdmtunYrjpHBiOVdQR25aOmLbc_WlDvpO4azcRazpgkASAjPaaDycYceKcheiyUDBgFrvcSYrS1fJb6YZrTzOKw1Ztd0YsklS4nc3jffLNLrBQVvnkajj0OHjGcW8wkP93TCMJyt8tfIH0Rd5hIZLY5EqUWj9yZgXO7CuDKQZIEI0oZXef3mjfQqH8Q9rZsSgS3DI3mCF3CN003R_ZKKLGWkG8VzwlvdocNxaV_Z-ujp_O0YMyOcmEoTJI-j3JP3nefNboyP7EWND6VWDjJbqg64dvu0PK0EW5L0lnAu0l-WVb2qmXLYao_7bOlD8JLFZOyUgHAWmetmnm0NuxzevJEVBkNUu1jQatPzf2Jzf9SGKM_3bM_z0TM5McCZ2Pikzwp96QlaiEEWV_j2g02PT5Y3td-lfpoKxoFD5tltGNY9TlPzK-4a9Iz0hloiCprxWMLGKw2JzctrPF1xr42PKGNgPE84xAQ3sQ4mQ==\"},{\"role\":\"tool\",\"tool_call_id\":\"call_onp7EArhOgXu0hGRF6Y5WHLu\",\"content\":\"{\\\"code\\\":\\\"ZEBRA\\\",\\\"rate\\\":23}\"},{\"role\":\"assistant\",\"content\":\"400\",\"reasoning_details\":[{\"type\":\"reasoning.encrypted\",\"data\":\"gAAAAABqwxL0Nl6Jl5EZuOLine truncated
},
"response": {
"status": 200,
"headers": {
"content-type": "text/event-stream; charset=utf-8"
},
"body": "data: {\"id\":\"resp_014406f0f45e19c6016ac312f49b9c87d2afc60121b8910612\",\"object\":\"chat.completion.chunk\",\"created\":1791169269,\"model\":\"openai-gpt-54-mini\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\"**Calculating a simple addition**\\n\\nThe\"},\"finish_reason\":null}]}\n\ndata: {\"id\":\"resp_014406f0f45e19c6016ac312f49b9c87d2afc60121b8910612\",\"object\":\"chat.completion.chunk\",\"created\":1791169269,\"model\":\"openai-gpt-54-mini\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\" user\"},\"finish_reason\":null}]}\n\ndata: {\"id\":\"resp_014406f0f45e19c6016ac312f49b9c87d2afc60121b8910612\",\"object\":\"chat.completion.chunk\",\"created\":1791169269,\"model\":\"openai-gpt-54-mini\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\" wants\"},\"finish_reason\":null}]}\n\ndata: {\"id\":\"resp_014406f0f45e19c6016ac312f49b9c87d2afc60121b8910612\",\"object\":\"chat.completion.chunk\",\"created\":1791169269,\"model\":\"openai-gpt-54-mini\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\" me\"},\"finish_reason\":null}]}\n\ndata: {\"id\":\"resp_014406f0f45e19c6016ac312f49b9c87d2afc60121b8910612\",\"object\":\"chat.completion.chunk\",\"created\":1791169269,\"model\":\"openai-gpt-54-mini\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\" to\"},\"finish_reason\":null}]}\n\ndata: {\"id\":\"resp_014406f0f45e19c6016ac312f49b9c87d2afc60121b8910612\",\"object\":\"chat.completion.chunk\",\"created\":1791169269,\"model\":\"openai-gpt-54-mini\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\" add\"},\"finish_reason\":null}]}\n\ndata: {\"id\":\"resp_014406f0f45e19c6016ac312f49b9c87d2afc60121b8910612\",\"object\":\"chat.completion.chunk\",\"created\":1791169269,\"model\":\"openai-gpt-54-mini\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\" 7\"},\"finish_reason\":null}]}\n\ndata: {\"id\":\"resp_014406f0f45e19c6016ac312f49b9c87d2afc60121b8910612\",\"object\":\"chat.completion.chunk\",\"created\":1791169269,\"model\":\"openai-gpt-54-mini\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\" to\"},\"finish_reason\":null}]}\n\ndata: {\"id\":\"resp_014406f0f45e19c6016ac312f49b9c87d2afc60121b8910612\",\"object\":\"chat.completion.chunk\",\"created\":1791169269,\"model\":\"openai-gpt-54-mini\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\" the\"},\"finish_reason\":null}]}\n\ndata: {\"id\":\"resp_014406f0f45e19c6016ac312f49b9c87d2afc60121b8910612\",\"object\":\"chat.completion.chunk\",\"created\":1791169269,\"model\":\"openai-gpt-54-mini\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\" previous\"},\"finish_reason\":null}]}\n\ndata: {\"id\":\"resp_014406f0f45e19c6016ac312f49b9c87d2afc60121b8910612\",\"object\":\"chat.completion.chunk\",\"created\":1791169269,\"model\":\"openai-gpt-54-mini\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\" final\"},\"finish_reason\":null}]}\n\ndata: {\"id\":\"resp_014406f0f45e19c6016ac312f49b9c87d2afc60121b8910612\",\"object\":\"chat.completion.chunk\",\"created\":1791169269,\"model\":\"openai-gpt-54-mini\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\" answer\"},\"finish_reason\":null}]}\n\ndata: {\"id\":\"resp_014406f0f45e19c6016ac312f49b9c87d2afc60121b8910612\",\"object\":\"chat.completion.chunk\",\"created\":1791169269,\"model\":\"openai-gpt-54-mini\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\",\"},\"finish_reason\":null}]}\n\ndata: {\"id\":\"resp_014406f0f45e19c6016ac312f49b9c87d2afc60121b8910612\",\"object\":\"chat.completion.chunk\",\"created\":1791169269,\"model\":\"openai-gpt-54-mini\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\" which\"},\"finish_reason\":null}]}\n\ndata: {\"id\":\"resp_014406f0f45e19c6016ac312f49b9c87d2afc60121b8910612\",\"object\":\"chat.completion.chunk\",\"created\":1791169269,\"model\":\"openai-gpt-54-mini\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\" was\"},\"finish_reason\":null}]}\n\ndata: {\"id\":\"resp_014406f0f45e19c6016ac312f49b9c87d2afc60121b8910612\",\"object\":\"chat.completion.chunk\",\"created\":1791169269,\"model\":\"openai-gpt-54-mini\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\" 400\"},\"finish_reason\":null}]}\n\ndata: {\"id\":\"resp_014406f0f45e19c6016ac312f49b9c87d2afc60121b8910612\",\"object\":\"chat.completion.chunk\",\"created\":1791169269,\"model\":\"openai-gpt-54-mini\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\".\"},\"finish_reason\":null}]}\n\ndata: {\"id\":\"resp_014406f0f45e19c6016ac312f49b9c87d2afc60121b8910612\",\"object\":\"chat.completion.chunk\",\"created\":1791169269,\"model\":\"openai-gpt-54-mini\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\" This\"},\"finish_reason\":null}]}\n\ndata: {\"id\":\"resp_014406f0f45e19c6016ac312f49b9c87d2afc60121b8910612\",\"object\":\"chat.completion.chunk\",\"created\":1791169269,\"model\":\"openai-gpt-54-mini\",\"Line truncated
}
}
]
}
@@ -1,35 +0,0 @@
{
"version": 1,
"metadata": {
"tags": [
"prefix:venice-chat",
"provider:venice",
"protocol:venice-chat",
"text",
"reasoning",
"toggle"
],
"name": "venice-chat/qwen-disables-reasoning",
"recordedAt": "2026-10-05T02:59:04.614Z"
},
"interactions": [
{
"transport": "http",
"request": {
"method": "POST",
"url": "https://api.venice.ai/api/v1/chat/completions",
"headers": {
"content-type": "application/json"
},
"body": "{\"model\":\"qwen3-6-27b\",\"messages\":[{\"role\":\"user\",\"content\":\"What is 23 multiplied by 17 plus 9? Reply with just the number.\"}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"max_completion_tokens\":4096,\"reasoning\":{\"enabled\":false},\"venice_parameters\":{\"include_venice_system_prompt\":false}}"
},
"response": {
"status": 200,
"headers": {
"content-type": "text/event-stream; charset=utf-8"
},
"body": "data: {\"service_tier\":null,\"id\":\"chatcmpl-RxYUpStKYw7YgEZcXjsb2dOt\",\"object\":\"chat.completion.chunk\",\"created\":1791169144,\"model\":\"qwen3-6-27b\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"\",\"reasoning_content\":null,\"tool_calls\":null},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"service_tier\":\"default\",\"id\":\"chatcmpl-RxYUpStKYw7YgEZcXjsb2dOt\",\"object\":\"chat.completion.chunk\",\"created\":1791169144,\"model\":\"qwen3-6-27b\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"\",\"reasoning_content\":null,\"tool_calls\":null},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"service_tier\":\"default\",\"id\":\"chatcmpl-RxYUpStKYw7YgEZcXjsb2dOt\",\"object\":\"chat.completion.chunk\",\"created\":1791169144,\"model\":\"qwen3-6-27b\",\"choices\":[{\"index\":0,\"delta\":{\"role\":null,\"content\":\"4\",\"reasoning_content\":null,\"tool_calls\":null},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"service_tier\":\"default\",\"id\":\"chatcmpl-RxYUpStKYw7YgEZcXjsb2dOt\",\"object\":\"chat.completion.chunk\",\"created\":1791169144,\"model\":\"qwen3-6-27b\",\"choices\":[{\"index\":0,\"delta\":{\"role\":null,\"content\":\"0\",\"reasoning_content\":null,\"tool_calls\":null},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"service_tier\":\"default\",\"id\":\"chatcmpl-RxYUpStKYw7YgEZcXjsb2dOt\",\"object\":\"chat.completion.chunk\",\"created\":1791169144,\"model\":\"qwen3-6-27b\",\"choices\":[{\"index\":0,\"delta\":{\"role\":null,\"content\":\"0\",\"reasoning_content\":null,\"tool_calls\":null},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"service_tier\":\"default\",\"id\":\"chatcmpl-RxYUpStKYw7YgEZcXjsb2dOt\",\"object\":\"chat.completion.chunk\",\"created\":1791169144,\"model\":\"qwen3-6-27b\",\"choices\":[{\"index\":0,\"delta\":{\"role\":null,\"content\":\"\",\"reasoning_content\":null,\"tool_calls\":null},\"logprobs\":null,\"finish_reason\":\"stop\"}]}\n\ndata: {\"service_tier\":\"default\",\"id\":\"chatcmpl-RxYUpStKYw7YgEZcXjsb2dOt\",\"object\":\"chat.completion.chunk\",\"created\":1791169144,\"model\":\"qwen3-6-27b\",\"choices\":[],\"usage\":{\"prompt_tokens\":32,\"completion_tokens\":4,\"total_tokens\":36},\"cost\":{\"usd\":0.0000234,\"diem\":0}}\n\ndata: [DONE]\n\n"
}
}
]
}
@@ -1,33 +0,0 @@
{
"version": 1,
"metadata": {
"tags": [
"prefix:venice-chat",
"provider:venice",
"protocol:venice-chat",
"error"
],
"name": "venice-chat/surfaces-venice-model-errors",
"recordedAt": "2026-10-05T02:59:11.768Z"
},
"interactions": [
{
"transport": "http",
"request": {
"method": "POST",
"url": "https://api.venice.ai/api/v1/chat/completions",
"headers": {
"content-type": "application/json"
},
"body": "{\"model\":\"no-such-model-xyz\",\"messages\":[{\"role\":\"user\",\"content\":\"Hello\"}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"venice_parameters\":{\"include_venice_system_prompt\":false}}"
},
"response": {
"status": 404,
"headers": {
"content-type": "application/json; charset=utf-8"
},
"body": "{\"error\":\"Specified model not found: no-such-model-xyz. Did you mean: z-ai-glm-5-3, z-ai-glm-5-3-flash, z-ai-glm-5-turbo?\"}"
}
}
]
}
@@ -1,26 +0,0 @@
import { LLM } from "../../src/index.js"
import { Venice } from "../../src/providers.js"
LLM.request({
model: Venice.chat("qwen3-6-27b"),
providerOptions: { reasoningEffort: "high", reasoning: { summary: "concise" }, veniceParameters: { includeVeniceSystemPrompt: false } },
})
LLM.request({
model: Venice.configure({ providerOptions: { reasoningEffort: "future-effort" } }).model("future-model"),
providerOptions: { reasoning: { enabled: false }, promptCacheRetention: "future-retention", parallelToolCalls: false },
})
LLM.request({
model: Venice.chat("qwen3-6-27b"),
// @ts-expect-error Thinking toggles are boolean.
providerOptions: { reasoning: { enabled: "false" } },
})
LLM.request({
model: Venice.chat("qwen3-6-27b"),
// @ts-expect-error Venice uses nested reasoning, not Anthropic thinking controls.
providerOptions: { thinking: { type: "disabled" } },
})
LLM.request({
model: Venice.chat("qwen3-6-27b"),
// @ts-expect-error Venice's system-prompt toggle is boolean.
providerOptions: { veniceParameters: { includeVeniceSystemPrompt: "false" } },
})
@@ -431,115 +431,42 @@ describe("Anthropic Messages route", () => {
(yield* compileRequest(
LLM.request({
model: opus48,
messages: [
Message.user("Start."),
Message.assistant("One."),
Message.system("Update."),
Message.assistant("Two."),
],
messages: [Message.user("Before."), Message.system("One."), Message.system("Two.")],
cache: "none",
}),
)).body.messages,
).toEqual([
{ role: "user", content: [{ type: "text", text: "Start." }] },
{ role: "assistant", content: [{ type: "text", text: "One." }] },
{ role: "user", content: [{ type: "text", text: "<system-update>\nUpdate.\n</system-update>" }] },
{ role: "assistant", content: [{ type: "text", text: "Two." }] },
{
role: "user",
content: [
{ type: "text", text: "Before." },
{ type: "text", text: "<system-update>\nOne.\n</system-update>" },
{ type: "text", text: "<system-update>\nTwo.\n</system-update>" },
],
},
])
}),
)
it.effect("moves system updates to the next assistant turn and sends consecutive updates together", () =>
Effect.gen(function* () {
const lower = (messages: ReadonlyArray<Message>) =>
compileRequest(LLM.request({ model: opus48, messages: [...messages], cache: "none" })).pipe(
Effect.map((prepared) => prepared.body.messages),
)
const system = (text: string) => ({ role: "system", content: [{ type: "text", text, cache_control: undefined }] })
const user = (text: string) => ({ role: "user", content: [{ type: "text", text }] })
const assistant = (text: string) => ({ role: "assistant", content: [{ type: "text", text }] })
expect(
yield* lower([
Message.user("Fix it."),
Message.assistant("Done."),
Message.system("Update."),
Message.user("Next."),
]),
).toEqual([user("Fix it."), assistant("Done."), user("Next."), system("Update.")])
expect(yield* lower([Message.user("Before."), Message.system("One."), Message.system("Two.")])).toEqual([
user("Before."),
system("One."),
system("Two."),
])
expect(
yield* lower([
Message.user("Fix it."),
Message.assistant("Done."),
Message.system("One."),
Message.user("Next."),
Message.system("Two."),
Message.assistant("After."),
]),
).toEqual([
user("Fix it."),
assistant("Done."),
user("Next."),
system("One."),
system("Two."),
assistant("After."),
])
expect(
yield* lower([
Message.user("Use the tool."),
Message.assistant([ToolCallPart.make({ id: "call_1", name: "lookup", input: {} })]),
Message.tool({ id: "call_1", name: "lookup", result: "Done." }),
Message.system("Update."),
Message.user("Also check tests."),
]),
).toEqual([
user("Use the tool."),
{ role: "assistant", content: [{ type: "tool_use", id: "call_1", name: "lookup", input: {} }] },
{ role: "user", content: [{ type: "tool_result", tool_use_id: "call_1", content: '"Done."' }] },
user("Also check tests."),
system("Update."),
])
}),
)
it.effect("keeps wrapped system updates in place for models without native system updates", () =>
it.effect("keeps a terminal Vertex system update in the tool-result turn", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model,
model: vertexOpus48,
messages: [
Message.user("Fix it."),
Message.assistant("Done."),
Message.system("Update."),
Message.user("Next."),
Message.assistant([ToolCallPart.make({ id: "call_1", name: "lookup", input: {} })]),
Message.tool({ id: "call_1", name: "lookup", result: "Done." }),
Message.system("Operator update."),
],
cache: "none",
}),
)
expect(prepared.body.messages).toEqual([
{ role: "user", content: [{ type: "text", text: "Fix it." }] },
{ role: "assistant", content: [{ type: "text", text: "Done." }] },
{ role: "user", content: [{ type: "text", text: "<system-update>\nUpdate.\n</system-update>" }] },
{ role: "user", content: [{ type: "text", text: "Next." }] },
])
}),
)
it.effect("sends Vertex system updates after local tool results as native system messages", () =>
Effect.gen(function* () {
const toolTurn = [
Message.assistant([ToolCallPart.make({ id: "call_1", name: "lookup", input: {} })]),
Message.tool({ id: "call_1", name: "lookup", result: "Done." }),
Message.system("Operator update."),
]
const lowered = [
{ role: "assistant", content: [{ type: "tool_use", id: "call_1", name: "lookup", input: {} }] },
{
role: "assistant",
content: [{ type: "tool_use", id: "call_1", name: "lookup", input: {} }],
},
{
role: "user",
content: [
@@ -550,23 +477,55 @@ describe("Anthropic Messages route", () => {
is_error: undefined,
cache_control: undefined,
},
{
type: "text",
text: "<system-update>\nOperator update.\n</system-update>",
cache_control: undefined,
},
],
},
{ role: "system", content: [{ type: "text", text: "Operator update.", cache_control: undefined }] },
]
])
}),
)
const terminal = yield* compileRequest(LLM.request({ model: vertexOpus48, messages: toolTurn, cache: "none" }))
const history = yield* compileRequest(
it.effect("preserves folded tool-result system updates across multi-turn Vertex history", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model: vertexOpus48,
messages: [...toolTurn, Message.assistant("Acknowledged."), Message.user("Next step.")],
messages: [
Message.assistant([ToolCallPart.make({ id: "call_1", name: "lookup", input: {} })]),
Message.tool({ id: "call_1", name: "lookup", result: "Done." }),
Message.system("Operator update."),
Message.assistant("Acknowledged."),
Message.user("Next step."),
],
cache: "none",
}),
)
expect(terminal.body.messages).toEqual(lowered)
expect(history.body.messages).toEqual([
...lowered,
expect(prepared.body.messages).toEqual([
{
role: "assistant",
content: [{ type: "tool_use", id: "call_1", name: "lookup", input: {} }],
},
{
role: "user",
content: [
{
type: "tool_result",
tool_use_id: "call_1",
content: '"Done."',
is_error: undefined,
cache_control: undefined,
},
{
type: "text",
text: "<system-update>\nOperator update.\n</system-update>",
cache_control: undefined,
},
],
},
{ role: "assistant", content: [{ type: "text", text: "Acknowledged." }] },
{ role: "user", content: [{ type: "text", text: "Next step." }] },
])
@@ -1,233 +0,0 @@
import { expect } from "bun:test"
import { Effect, Schema } from "effect"
import { LLM, LLMEvent, LLMRequest, Message, ToolChoice, ToolDefinition } from "../../src/index.js"
import { Venice } from "../../src/providers.js"
import { LLMClient } from "../../src/route.js"
import { compileRequest } from "../../src/route/client.js"
import { recordedTests } from "../recorded-test.js"
const apiKey = process.env.VENICE_API_KEY ?? "fixture"
const recorded = recordedTests({
prefix: "venice-chat",
provider: "venice",
protocol: "venice-chat",
requires: ["VENICE_API_KEY"],
})
const lookup = ToolDefinition.make({
name: "lookup_rate",
description: "Retrieve the private conversion rate for a code. Only this tool knows the rate.",
inputSchema: {
type: "object",
properties: { code: { type: "string", enum: ["ZEBRA", "MOOSE"] } },
required: ["code"],
additionalProperties: false,
},
})
for (const item of [
{ model: "deepseek-v4-flash", reasoning: "scalar" },
{ model: "claude-opus-4-8", reasoning: "signed", effort: "high" },
{ model: "gemini-3-1-pro-preview", reasoning: "details", effort: "high" },
{ model: "openai-gpt-54-mini", reasoning: "encrypted" },
] as const) {
recorded.effect.with(
`${item.model} preserves reasoning through a tool loop and follow-up`,
{
tags: ["reasoning", "tool", "tool-loop", "continuation", "usage", item.reasoning],
metadata: { model: item.model },
},
() =>
Effect.gen(function* () {
const request = LLM.request({
model: Venice.configure({ apiKey }).chat(item.model),
system:
"Use the provided tool for private data. After receiving the result, give only the requested number; do not call the tool again.",
prompt:
"Look up conversion rate for code ZEBRA. Then multiply that rate by 17 and add 9. You must use lookup_rate before answering.",
tools: [lookup],
generation: { maxTokens: 4096 },
providerOptions: "effort" in item ? { reasoningEffort: item.effort } : undefined,
})
const first = yield* LLMClient.generate(request)
expect(first.toolCalls).toMatchObject([{ name: "lookup_rate", input: { code: "ZEBRA" } }])
expect(first.finishReason.normalized).toBe("tool-calls")
expect(first.events.filter(LLMEvent.is.finish)).toHaveLength(1)
const continuation = LLMRequest.update(request, {
toolChoice: ToolChoice.make("none"),
messages: [
...request.messages,
first.message,
...first.toolCalls.map((call) =>
Message.tool({ id: call.id, name: call.name, result: { code: "ZEBRA", rate: 23 } }),
),
],
})
const compiled = yield* compileRequest(continuation)
const assistant = compiled.body.messages.find((message) => message.role === "assistant")
expect(assistant).toBeDefined()
// Some models call a tool immediately and only reason after its result.
if (item.reasoning === "scalar" && first.reasoning.length > 0) {
expect(assistant).toMatchObject({ reasoning_content: first.reasoning })
}
if (item.reasoning === "signed") {
const details = first.message.content
.filter((part) => part.type === "reasoning")
.flatMap((part) => part.providerMetadata?.venice?.reasoningDetails ?? [])
expect(details).toEqual(
expect.arrayContaining([
expect.objectContaining({ type: "reasoning.text", signature: expect.any(String) }),
]),
)
expect(assistant).toMatchObject({ reasoning_details: details })
}
if (item.reasoning === "details")
expect(assistant).toMatchObject({
reasoning_details: expect.arrayContaining([
expect.objectContaining({ type: "reasoning.encrypted", data: expect.any(String) }),
]),
})
if (item.reasoning === "encrypted") {
expect(first.reasoning).not.toContain("__ENCRYPTED_REASONING__")
const details = first.message.content
.filter((part) => part.type === "reasoning")
.flatMap((part) => part.providerMetadata?.venice?.reasoningDetails ?? [])
if (details.length > 0) expect(assistant).toMatchObject({ reasoning_details: details })
}
const second = yield* LLMClient.generate(continuation)
expect(second.text).toContain("400")
expect(second.reasoning).not.toContain("__ENCRYPTED_REASONING__")
expect(second.toolCalls).toHaveLength(0)
expect(second.finishReason.normalized).toBe("stop")
expect(second.usage?.inputTokens).toBeGreaterThan(0)
expect(second.usage?.outputTokens).toBeGreaterThan(0)
const followUp = LLMRequest.update(continuation, {
messages: [
...continuation.messages,
second.message,
Message.user("Add 7 to your previous final answer. Reply with just the number."),
],
})
const third = yield* LLMClient.generate(followUp)
expect(third.text).toContain("407")
expect(third.finishReason.normalized).toBe("stop")
if (item.reasoning === "encrypted") {
expect(
[first, second, third].flatMap((response) =>
response.message.content
.filter((part) => part.type === "reasoning")
.flatMap((part) => part.providerMetadata?.venice?.reasoningDetails ?? []),
),
).toEqual(
expect.arrayContaining([
expect.objectContaining({ type: "reasoning.encrypted", data: expect.any(String) }),
]),
)
}
}),
180_000,
)
}
recorded.effect.with(
"Gemini preserves per-call signatures through parallel tools",
{ tags: ["tool", "parallel", "signature"] },
() =>
Effect.gen(function* () {
const request = LLM.request({
model: Venice.configure({ apiKey }).chat("gemini-3-8-flash"),
prompt:
"Look up private rates for both ZEBRA and MOOSE using two calls to lookup_rate. Then add the rates and multiply by 7. Use parallel calls if possible.",
tools: [lookup],
providerOptions: { reasoningEffort: "high", parallelToolCalls: true },
generation: { maxTokens: 4096 },
})
const first = yield* LLMClient.generate(request)
expect(first.toolCalls).toHaveLength(2)
const continuation = LLMRequest.update(request, {
toolChoice: ToolChoice.make("none"),
messages: [
...request.messages,
first.message,
...first.toolCalls.map((call) =>
Message.tool({
id: call.id,
name: call.name,
result: {
rate:
Schema.decodeUnknownSync(Schema.Struct({ code: Schema.String }))(call.input).code === "ZEBRA"
? 23
: 31,
},
}),
),
],
})
const compiled = yield* compileRequest(continuation)
expect(compiled.body.messages).toEqual(
expect.arrayContaining([
expect.objectContaining({
tool_calls: first.toolCalls.map((call) =>
expect.objectContaining({
id: call.id,
...(call.providerMetadata?.venice?.thoughtSignature === undefined
? {}
: { thought_signature: call.providerMetadata.venice.thoughtSignature }),
}),
),
}),
]),
)
const second = yield* LLMClient.generate(continuation)
expect(second.text).toContain("378")
const signature = second.message.content
.filter((part) => part.type === "text")
.map((part) => part.providerMetadata?.venice?.messageThoughtSignature)
.find((value) => value !== undefined)
expect(signature).toEqual(expect.any(String))
const followUp = LLMRequest.update(continuation, {
messages: [
...continuation.messages,
second.message,
Message.user("Add 7 to your previous final answer. Reply with just the number."),
],
})
expect((yield* compileRequest(followUp)).body.messages).toEqual(
expect.arrayContaining([expect.objectContaining({ role: "assistant", thought_signature: signature })]),
)
expect((yield* LLMClient.generate(followUp)).text).toContain("385")
}),
180_000,
)
recorded.effect.with(
"Qwen disables reasoning",
{ tags: ["text", "reasoning", "toggle"] },
() =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(
LLM.request({
model: Venice.configure({ apiKey }).chat("qwen3-6-27b"),
prompt: "What is 23 multiplied by 17 plus 9? Reply with just the number.",
providerOptions: { reasoning: { enabled: false } },
generation: { maxTokens: 4096 },
}),
)
expect(response.text).toContain("400")
expect(response.reasoning).toBe("")
}),
120_000,
)
recorded.effect.with("surfaces Venice model errors", { tags: ["error"] }, () =>
Effect.gen(function* () {
const result = yield* LLMClient.generate(
LLM.request({ model: Venice.configure({ apiKey }).chat("no-such-model-xyz"), prompt: "Hello" }),
).pipe(Effect.result)
expect(result._tag).toBe("Failure")
if (result._tag === "Failure") {
expect(result.failure.reason._tag).toBe("InvalidRequest")
expect(result.failure.message).toContain("Specified model not found")
expect(result.failure.reason.http?.status).toBe(404)
}
}),
)
-370
View File
@@ -1,370 +0,0 @@
import { expect } from "bun:test"
import { ConfigProvider, Effect } from "effect"
import { Headers } from "effect/unstable/http"
import { Auth, LLM, LLMClient, Message } from "../../src/index.js"
import { OpenAI, Venice } from "../../src/providers.js"
import { compileRequest } from "../../src/route/client.js"
import { Endpoint } from "../../src/route/endpoint.js"
import { it } from "../lib/effect.js"
import { fixedResponse } from "../lib/http.js"
import { sseEvents } from "../lib/sse.js"
const model = Venice.configure({ apiKey: "fixture" }).chat("fixture-model")
const frame = (delta: Record<string, unknown>, finish_reason: string | null = null) => ({
choices: [{ delta, finish_reason }],
})
it.effect("Venice owns its endpoint, environment auth, aliases, and SDK-compatible prompt default", () =>
Effect.gen(function* () {
expect(Venice.provider.model).toBe(Venice.provider.chat)
const selected = Venice.chat("qwen3-6-27b")
const request = LLM.request({ model: selected, prompt: "Hello", promptCacheKey: "session-123" })
const compiled = yield* compileRequest(request)
const url = Endpoint.render(selected.route.endpoint, { request, body: compiled.body }).toString()
expect(url).toBe("https://api.venice.ai/api/v1/chat/completions")
expect(selected.provider).toBe("venice")
expect(compiled.body.venice_parameters).toEqual({ include_venice_system_prompt: false })
expect(compiled.body.prompt_cache_key).toBe("session-123")
expect(compiled.body.store).toBeUndefined()
expect(compiled.body.stream_options).toEqual({ include_usage: true })
const headers = yield* selected.route.auth.apply({
request,
method: "POST",
url,
body: "{}",
headers: Headers.empty,
})
expect(headers.authorization).toBe("Bearer fixture")
const custom = Venice.configure({
baseURL: "https://gateway.example/v1",
queryParams: { deployment: "private" },
auth: Auth.header("x-key", "fixture"),
}).chat("qwen3-6-27b")
expect(Endpoint.render(custom.route.endpoint, { request, body: compiled.body }).toString()).toBe(
"https://gateway.example/v1/chat/completions?deployment=private",
)
}).pipe(Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnv({ env: { VENICE_API_KEY: "fixture" } })))),
)
it.effect("Venice lowers typed options, model defaults, and explicit reasoning overrides", () =>
Effect.gen(function* () {
const compiled = yield* compileRequest(
LLM.request({
model: Venice.configure({ apiKey: "fixture", providerOptions: { reasoningEffort: "low" } }).model(
"qwen3-6-27b",
),
generation: { maxTokens: 200, topK: 20 },
providerOptions: {
reasoningEffort: "future-effort",
reasoning: { effort: "high", enabled: true, summary: "concise" },
veniceParameters: { includeVeniceSystemPrompt: true, enableWebSearch: "auto", stripThinkingResponse: false },
parallelToolCalls: false,
maxCompletionTokens: 123,
minP: 0.1,
repetitionPenalty: 1.1,
stopTokenIds: [12],
promptCacheRetention: "24h",
logprobs: true,
topLogprobs: 2,
minTemp: 0.1,
maxTemp: 1.5,
responseFormat: { type: "json_object" },
user: "user-123",
},
}),
)
expect(compiled.body).toMatchObject({
max_completion_tokens: 123,
top_k: 20,
min_p: 0.1,
repetition_penalty: 1.1,
reasoning: { effort: "future-effort", enabled: true, summary: "concise" },
venice_parameters: {
include_venice_system_prompt: true,
enable_web_search: "auto",
strip_thinking_response: false,
},
parallel_tool_calls: false,
stop_token_ids: [12],
prompt_cache_retention: "24h",
logprobs: true,
top_logprobs: 2,
min_temp: 0.1,
max_temp: 1.5,
response_format: { type: "json_object" },
user: "user-123",
})
expect(compiled.body.reasoning_effort).toBeUndefined()
expect(compiled.body.max_tokens).toBeUndefined()
expect(
(yield* compileRequest(LLM.request({ model, providerOptions: { reasoning: { enabled: false } } }))).body
.reasoning,
).toEqual({ enabled: false })
expect(
(yield* compileRequest(LLM.request({ model, cache: "none", promptCacheKey: "session-123" }))).body
.prompt_cache_key,
).toBeUndefined()
}),
)
it.effect("Venice hides encrypted trailers across every marker split while preserving exact replay", () =>
Effect.gen(function* () {
const marker = "__ENCRYPTED_REASONING__"
for (const split of Array.from({ length: marker.length + 1 }, (_, index) => index)) {
const raw = `Visible summary\n${marker}id=rs_123\nopaque-payload`
const response = yield* LLMClient.generate(LLM.request({ model, prompt: "Hello" })).pipe(
Effect.provide(
fixedResponse(
sseEvents(
frame({ reasoning_content: `Visible summary\n${marker.slice(0, split)}` }),
frame({
reasoning_content: `${marker.slice(split)}id=rs_123\nopaque-payload`,
reasoning_details: [
{
type: "reasoning.encrypted",
data: "opaque-payload",
format: "openai-responses-v1",
id: "rs_123",
index: 0,
},
],
}),
frame({ content: "42" }, "stop"),
),
),
),
)
expect(response.reasoning).toBe("Visible summary\n")
expect(
response.events
.filter((event) => event.type === "reasoning-delta")
.map((event) => event.text)
.join(""),
).toBe("Visible summary\n")
const replay = yield* compileRequest(LLM.request({ model, messages: [Message.user("Hello"), response.message] }))
expect(replay.body.messages).toEqual(
expect.arrayContaining([
expect.objectContaining({
role: "assistant",
reasoning_content: raw,
reasoning_details: [
{
type: "reasoning.encrypted",
data: "opaque-payload",
format: "openai-responses-v1",
id: "rs_123",
index: 0,
},
],
}),
]),
)
}
}),
)
it.effect("Venice preserves opaque-only reasoning and does not eat an incomplete marker prefix", () =>
Effect.gen(function* () {
for (const scalar of ["__ENCRYPTED_REASONING__opaque", "normal _", "normal __ENCRYPTED_"]) {
const response = yield* LLMClient.generate(LLM.request({ model, prompt: "Hello" })).pipe(
Effect.provide(
fixedResponse(sseEvents(frame({ reasoning_content: scalar }), frame({ content: "42" }, "stop"))),
),
)
expect(response.reasoning).toBe(scalar.startsWith("__ENCRYPTED_REASONING__") ? "" : scalar)
const body = (yield* compileRequest(LLM.request({ model, messages: [response.message] }))).body
expect(body.messages).toEqual(expect.arrayContaining([expect.objectContaining({ reasoning_content: scalar })]))
}
}),
)
it.effect("Venice encrypted trailers do not change the base OpenAI Chat protocol", () =>
LLMClient.generate(
LLM.request({ model: OpenAI.configure({ apiKey: "fixture" }).chat("gpt-4o"), prompt: "Hello" }),
).pipe(
Effect.provide(
fixedResponse(
sseEvents(
frame({ reasoning_content: "text __ENCRYPTED_REASONING__payload" }),
frame({ content: "42" }, "stop"),
),
),
),
Effect.tap((response) => Effect.sync(() => expect(response.reasoning).toBe("text __ENCRYPTED_REASONING__payload"))),
),
)
it.effect("Venice keeps late Gemini signatures attached to the correct parallel call", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(LLM.request({ model, prompt: "Lookup" })).pipe(
Effect.provide(
fixedResponse(
sseEvents(
frame({
tool_calls: [
{ index: 0, id: "call_0", function: { name: "lookup", arguments: '{"key":"A"}' } },
{ index: 1, id: "call_1", function: { name: "lookup", arguments: '{"key":"B"}' } },
],
}),
frame({ tool_calls: [{ index: 0, thought_signature: "signed-first-call" }] }),
frame({}, "tool_calls"),
),
),
),
)
expect(response.toolCalls).toHaveLength(2)
expect(response.toolCalls[0].providerMetadata?.venice?.thoughtSignature).toBe("signed-first-call")
expect(response.toolCalls[1].providerMetadata?.venice?.thoughtSignature).toBeUndefined()
const replay = yield* compileRequest(LLM.request({ model, messages: [Message.user("Lookup"), response.message] }))
expect(replay.body.messages).toEqual(
expect.arrayContaining([
expect.objectContaining({
tool_calls: [
expect.objectContaining({ id: "call_0", thought_signature: "signed-first-call" }),
expect.not.objectContaining({ thought_signature: expect.anything() }),
],
}),
]),
)
}),
)
it.effect("Venice preserves message signatures even for identical answers and empty intervening messages", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(LLM.request({ model, prompt: "Hello" })).pipe(
Effect.provide(
fixedResponse(
sseEvents(frame({ content: "42" }), frame({ thought_signature: "second-signature" }), frame({}, "stop")),
),
),
)
const request = LLM.request({
model,
messages: [
Message.assistant([
{ type: "text", text: "42", providerMetadata: { venice: { messageThoughtSignature: "first-signature" } } },
]),
Message.assistant(""),
response.message,
],
})
expect((yield* compileRequest(request)).body.messages).toEqual([
{ role: "assistant", content: "42", thought_signature: "first-signature" },
{ role: "assistant", content: "42", thought_signature: "second-signature" },
])
}),
)
it.effect("Venice merges signed Claude reasoning without losing its final signature", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(LLM.request({ model, prompt: "Hello" })).pipe(
Effect.provide(
fixedResponse(
sseEvents(
frame({
reasoning_content: "Think ",
reasoning_details: [{ type: "reasoning.text", text: "Think ", format: "anthropic-claude-v1", index: 0 }],
}),
frame({
reasoning_content: "carefully",
reasoning_details: [
{ type: "reasoning.text", text: "carefully", format: "anthropic-claude-v1", index: 0 },
],
}),
frame({
reasoning_details: [
{
type: "reasoning.text",
text: "",
signature: "claude-signature",
format: "anthropic-claude-v1",
index: 0,
},
],
}),
frame({ content: "42" }, "stop"),
),
),
),
)
expect(response.reasoning).toBe("Think carefully")
expect((yield* compileRequest(LLM.request({ model, messages: [response.message] }))).body.messages).toEqual(
expect.arrayContaining([
expect.objectContaining({
reasoning_details: [
{
type: "reasoning.text",
text: "Think carefully",
signature: "claude-signature",
format: "anthropic-claude-v1",
index: 0,
},
],
}),
]),
)
}),
)
it.effect("Venice maps cache-write usage and retains the raw wire spelling", () =>
LLMClient.generate(LLM.request({ model, prompt: "Hello" })).pipe(
Effect.provide(
fixedResponse(
sseEvents(frame({ content: "42" }, "stop"), {
choices: [],
usage: {
prompt_tokens: 100,
completion_tokens: 20,
total_tokens: 120,
prompt_tokens_details: { cached_tokens: 40, cache_creation_input_tokens: 30 },
completion_tokens_details: { reasoning_tokens: 10 },
},
}),
),
),
Effect.tap((response) =>
Effect.sync(() => {
expect(response.usage).toMatchObject({
inputTokens: 100,
outputTokens: 20,
nonCachedInputTokens: 30,
cacheReadInputTokens: 40,
cacheWriteInputTokens: 30,
reasoningTokens: 10,
})
expect(response.usage?.providerMetadata?.venice).toMatchObject({
prompt_tokens_details: { cache_creation_input_tokens: 30 },
})
}),
),
),
)
it.effect("Venice surfaces HTTP string errors and streaming validation details without losing the payload", () =>
Effect.gen(function* () {
const http = yield* LLMClient.generate(LLM.request({ model, prompt: "Hello" })).pipe(
Effect.provide(
fixedResponse('{"error":"Specified model not found"}', {
status: 404,
headers: { "content-type": "application/json" },
}),
),
Effect.result,
)
expect(http._tag).toBe("Failure")
if (http._tag === "Failure") expect(http.failure.message).toContain("Specified model not found")
const payload = {
error: "Invalid request parameters",
issues: [{ message: "Unsupported value", path: ["reasoning", "effort"] }],
}
const streamed = yield* LLMClient.generate(LLM.request({ model, prompt: "Hello" })).pipe(
Effect.provide(fixedResponse(sseEvents(payload))),
Effect.result,
)
expect(streamed._tag).toBe("Failure")
if (streamed._tag === "Failure") {
expect(streamed.failure.message).toContain("reasoning.effort: Unsupported value")
expect(JSON.parse(streamed.failure.reason.body ?? "{}")).toEqual(payload)
}
}),
)
+12 -12
View File
@@ -83,23 +83,23 @@ Changes merged into `v2` reach the beta site when they are promoted to `beta`. T
only the web app, using the same `WebApp` StaticSite definition as production. It sets the build channel
and Sentry environment to `beta` without deploying the API, console, database, or billing infrastructure.
`VITE_OPENCODE_SERVER_MODE` controls which server the web build provides at startup:
`VITE_OPENCODE_SERVER_MODE` controls whether the web build expects an API on its own origin:
| Mode | Initial server |
| ------------------ | ------------------------------------------------------------------------------------ |
| `none` | No initial server. The beta deployment uses this mode. |
| `origin` (default) | The current page's origin. CLI builds explicitly use this mode for `opencode serve`. |
| Mode | Same-origin API |
| ------------------ | ------------------------------------------------------------------------------------- |
| `none` | Not expected. The beta deployment and `bun run dev:web` use this mode. |
| `origin` (default) | Expected. CLI builds use this mode because `opencode serve` hosts the API and web UI. |
In Vite development mode, `origin` uses `VITE_OPENCODE_SERVER_HOST` / `VITE_OPENCODE_SERVER_PORT`
(default: `http://localhost:4096`) instead of the frontend origin. Both modes restore user-added servers
from storage. Desktop provides the local server it discovers or starts through native initialization.
(default: `http://localhost:4096`) instead of the frontend origin. The expected server is registered at startup
only when the URL includes credentials; an unauthenticated visit starts with an empty server list. Both modes
restore user-added servers from storage. Desktop provides the local server it discovers or starts through native
initialization.
With no configured servers, or when the only server rejects the saved credentials, the app shows a full-screen
connection form. Enter a server address and password, paste a link from `opencode pair`, or choose
**Scan QR code** to read its QR code. Pairing links are single-use; the app exchanges them for a session token
and immediately attempts to connect. Failed connections leave the details available
to edit and retry with **Connect**. Credentials are checked before saving the server. Camera access requires
HTTPS (or localhost) and browser permission. Saved offline servers continue to use the normal app UI.
connection form. Enter a server address and password or scan a pairing QR code to connect. Failed connections
leave the details available to edit and retry with **Connect**. Credentials are checked before saving the server.
Saved offline servers continue to use the normal app UI.
When the service is exposed through an HTTPS reverse proxy, advertise its external address at runtime:
@@ -15,25 +15,21 @@ export function mountTimelineVirtualizer(input: { count: number; rowHeight: numb
function Fixture() {
const [state, setState] = createStore({ pinned: true, ready: false })
const rows = Array.from(
{ length: input.count },
(_, index) => new TimelineRow.UserMessage({ userMessageID: `message-${index}` }),
)
const rowByKey = new Map(rows.map((row) => [TimelineRow.key(row), row]))
const indexes = new Map(rows.map((row, index) => [row.userMessageID, index]))
let viewport!: HTMLDivElement
let content!: HTMLDivElement
let container!: HTMLDivElement
const timeline = createTimelineVirtualizer({
sessionKey: () => "cold-reveal-fixture",
projection: {
rows: () => rows,
rowByKey: () => rowByKey,
activeMessageID: () => undefined,
messageByID: () => new Map(),
messageRowIndex: () => indexes,
messageLastRowIndex: () => indexes,
},
@@ -66,7 +62,6 @@ export function mountTimelineVirtualizer(input: { count: number; rowHeight: numb
host.dataset.observedHeight = String(entries[0].borderBoxSize[0].blockSize)
host.dataset.viewportResizes = String(Number(host.dataset.viewportResizes) + 1)
})
const reveal = new MutationObserver(() => {
if (content.style.visibility === "hidden" || host.dataset.firstReveal) return
// Capture the first reveal, not a later frame after geometry has recovered.
@@ -76,14 +71,11 @@ export function mountTimelineVirtualizer(input: { count: number; rowHeight: numb
pendingMarkdown: content.querySelectorAll('[data-component="markdown"]:not([data-markdown-ready])').length,
viewportHeight: viewport.clientHeight,
scrollTop: viewport.scrollTop,
clipped: mounted.flatMap((element) =>
element.firstElementChild!.getBoundingClientRect().height > element.offsetHeight + 1
? [element.dataset.timelineKey]
: [],
),
clipped: mounted
.filter((element) => element.firstElementChild!.getBoundingClientRect().height > element.offsetHeight + 1)
.map((element) => element.dataset.timelineKey),
})
})
onCleanup(() => {
resize.disconnect()
reveal.disconnect()
+11 -171
View File
@@ -5,149 +5,11 @@ import { expectSessionTitle } from "../utils/waits"
test.use({ viewport: { width: 1440, height: 900 } })
test("keeps separate one-shot /btw answers and restores them after reload", async ({ page }, testInfo) => {
const prompts: unknown[] = []
const generations: { sessionID: string; prompt: string }[] = []
const { editor } = await openSession(page, {
name: "BtwQuestions",
onPrompt: (input) => prompts.push(input),
generate: (input) => {
generations.push(input)
return {
text: input.prompt.includes("second question")
? "Second answer"
: `First answer\n\n${"A detailed explanation of the first question.\n\n".repeat(100)}End of first answer`,
}
},
})
const panel = page.locator('[data-slot="session-btw-panel"]')
await editor.fill("/btw first question")
await editor.press("Enter")
await expect(panel.getByText("First answer", { exact: true })).toBeVisible()
await expect(panel.getByRole("textbox")).toHaveCount(0)
await expect(panel.getByRole("button", { name: "Send", exact: true })).toHaveCount(0)
await panel.getByText("End of first answer", { exact: true }).scrollIntoViewIfNeeded()
await expect(panel.getByText("End of first answer", { exact: true })).toBeInViewport()
await editor.fill("/btw second question")
await editor.press("Enter")
await expect(panel.getByText("Second answer", { exact: true })).toBeVisible()
const first = page.getByRole("tab", { name: "first question", exact: true })
const second = page.getByRole("tab", { name: "second question", exact: true })
await expect(first).toBeVisible()
await expect(second).toHaveAttribute("data-selected", "")
await page.screenshot({ path: testInfo.outputPath("separate-tabs.png") })
expect(generations).toHaveLength(2)
expect(generations[1]?.prompt).not.toContain("first question")
expect(generations[1]?.prompt).not.toContain("First answer")
await page.reload()
await expect(first).toBeVisible()
await expect(second).toHaveAttribute("data-selected", "")
await expect(panel.getByText("Second answer", { exact: true })).toBeVisible()
await first.click()
await expect(panel.getByText("First answer", { exact: true })).toBeVisible()
await expect(panel.getByText("Second answer", { exact: true })).toHaveCount(0)
await expect(panel.getByRole("textbox")).toHaveCount(0)
await page.screenshot({ path: testInfo.outputPath("restored-answer.png") })
// Closing a tab forgets its question; the other tab and its answer survive a reload.
await first.click({ button: "middle" })
await expect(first).toHaveCount(0)
await expect(second).toHaveAttribute("data-selected", "")
await expect(panel.getByText("Second answer", { exact: true })).toBeVisible()
await page.reload()
await expect(second).toHaveAttribute("data-selected", "")
await expect(panel.getByText("Second answer", { exact: true })).toBeVisible()
await expect(first).toHaveCount(0)
expect(generations).toHaveLength(2)
expect(prompts).toEqual([])
})
test("isolates concurrent side questions and makes an interrupted reload retryable", async ({ page }) => {
const held = Promise.withResolvers<void>()
const attempts: string[] = []
const prompts: unknown[] = []
const { editor } = await openSession(page, {
name: "BtwConcurrent",
onPrompt: (input) => prompts.push(input),
generate: async (input) => {
attempts.push(input.prompt)
if (input.prompt.includes("slow question") && attempts.length === 1) {
await held.promise
return { text: "Abandoned answer" }
}
return { text: input.prompt.includes("fast question") ? "Fast answer" : "Retried answer" }
},
})
const panel = page.locator('[data-slot="session-btw-panel"]')
await editor.fill("/btw slow question")
await editor.press("Enter")
await expect(panel.getByRole("status")).toContainText("Working")
await editor.fill("/btw fast question")
await editor.press("Enter")
await expect(panel.getByText("Fast answer", { exact: true })).toBeVisible()
await page.getByRole("tab", { name: "slow question", exact: true }).click()
await expect(panel.getByRole("status")).toContainText("Working")
await expect(panel.getByText("Fast answer", { exact: true })).toHaveCount(0)
await page.reload()
await expect(panel.getByText("Couldn’t answer that question", { exact: true })).toBeVisible()
await expect(panel.getByRole("status")).toHaveCount(0)
expect(attempts).toHaveLength(2)
await panel.getByRole("button", { name: "Retry", exact: true }).click()
await expect(panel.getByText("Retried answer", { exact: true })).toBeVisible()
expect(attempts).toHaveLength(3)
expect(attempts[2]).toContain("slow question")
expect(attempts[2]).not.toContain("fast question")
held.resolve()
await page.getByRole("tab", { name: "fast question", exact: true }).click()
await expect(panel.getByText("Fast answer", { exact: true })).toBeVisible()
await page.getByRole("tab", { name: "slow question", exact: true }).click()
await expect(panel.getByText("Retried answer", { exact: true })).toBeVisible()
await expect(panel.getByText("Abandoned answer", { exact: true })).toHaveCount(0)
await expect(panel.getByRole("textbox")).toHaveCount(0)
expect(prompts).toEqual([])
})
test("keeps many long-titled side tabs usable at a narrow desktop width", async ({ page }, testInfo) => {
await page.setViewportSize({ width: 900, height: 700 })
const { editor } = await openSession(page, {
name: "BtwOverflow",
generate: () => ({ text: "Saved overflow answer" }),
})
const panel = page.locator('[data-slot="session-btw-panel"]')
const questions = Array.from({ length: 12 }, (_, index) => `Side ${index + 1}: ${"long-title-".repeat(30)}`)
for (const question of questions) {
await editor.fill(`/btw ${question}`)
await editor.press("Enter")
await expect(page.getByRole("tab", { name: question, exact: true })).toHaveAttribute("data-selected", "")
await expect(panel.getByText("Saved overflow answer", { exact: true })).toBeVisible()
}
await expect(page.getByRole("tab", { name: /^Side \d+:/ })).toHaveCount(12)
await expect(panel.getByRole("textbox")).toHaveCount(0)
await page.reload()
await expect(page.getByRole("tab", { name: /^Side \d+:/ })).toHaveCount(12)
await expect(panel.getByText("Saved overflow answer", { exact: true })).toBeVisible()
await page.screenshot({ path: testInfo.outputPath("narrow-many-tabs.png") })
})
test("answers /btw in the side panel without admitting a prompt", async ({ page }) => {
const generations: { sessionID: string; prompt: string }[] = []
const prompts: unknown[] = []
const generated = Promise.withResolvers<void>()
const leftBehind = Promise.withResolvers<void>()
const abandoned = Promise.withResolvers<void>()
const main = { id: "ses_btw_sidebar", title: "Side question session" }
const other = { id: "ses_btw_sidebar_other", title: "Other side question session" }
const ownerWarnings: string[] = []
@@ -166,7 +28,7 @@ test("answers /btw in the side panel without admitting a prompt", async ({ page
if (input.sessionID === other.id) return { text: "This answer belongs to the **other session**." }
if (input.prompt.includes("left behind")) {
await leftBehind.promise
await abandoned.promise
return { text: "This answer arrived after the user left." }
}
@@ -185,6 +47,7 @@ test("answers /btw in the side panel without admitting a prompt", async ({ page
await suggestion.click()
await expect(editor).toHaveText("/btw ")
await editor.press("Enter")
const panel = page.locator('[data-slot="session-btw-panel"]')
await expect(panel).toBeHidden()
await expect(page.getByText("Add a question after /btw", { exact: true })).toBeVisible()
@@ -196,10 +59,7 @@ test("answers /btw in the side panel without admitting a prompt", async ({ page
await expect(panel).toBeVisible()
await expect(panel.getByRole("textbox")).toHaveCount(0)
await expect(panel.getByRole("status")).toContainText("Working")
await expect(page.getByRole("tab", { name: "how does the retry loop work?", exact: true })).toHaveAttribute(
"data-selected",
"",
)
await expect(page.getByRole("tab", { name: "/btw" })).toHaveAttribute("data-selected", "")
generated.resolve()
await expect(panel.getByText("how does the retry loop work?", { exact: true })).toBeVisible()
await expect(panel.getByText("exponential backoff", { exact: false })).toBeVisible()
@@ -215,11 +75,13 @@ test("answers /btw in the side panel without admitting a prompt", async ({ page
await editor.fill("/btw what belongs here?")
await editor.press("Enter")
await expect(panel.getByText("other session", { exact: false })).toBeVisible()
await page.locator(`[data-titlebar-tab-link][href="${sessionHref(main.id)}"]`).click()
await expectSessionTitle(page, main.title)
await expect(panel.getByText("exponential backoff", { exact: false })).toBeVisible()
await expect(panel.getByText("other session", { exact: false })).toHaveCount(0)
// Leaving a session abandons its in-flight question, so it reads as failed on return.
await editor.fill("/btw is this question left behind?")
await editor.press("Enter")
await expect(panel.getByRole("status")).toContainText("Working")
@@ -227,35 +89,13 @@ test("answers /btw in the side panel without admitting a prompt", async ({ page
await expectSessionTitle(page, other.title)
await page.locator(`[data-titlebar-tab-link][href="${sessionHref(main.id)}"]`).click()
await expectSessionTitle(page, main.title)
// Leaving the session does not abandon its question: it is still working, and its answer lands once it arrives.
await expect(panel.getByRole("status")).toContainText("Working")
await page.getByRole("button", { name: "Home", exact: true }).click()
await expect(page).toHaveURL(/\/$/)
leftBehind.resolve()
await page.locator(`[data-titlebar-tab-link][href="${sessionHref(main.id)}"]`).click()
await expectSessionTitle(page, main.title)
await expect(panel.getByText("This answer arrived after the user left.", { exact: true })).toBeVisible()
await expect(panel.getByText("Couldn’t answer that question", { exact: true })).toHaveCount(0)
expect(generations.filter((item) => item.prompt.includes("left behind"))).toHaveLength(1)
await expect(panel.getByText("Couldn’t answer that question", { exact: true })).toBeVisible()
await expect(panel.getByRole("button", { name: "Retry", exact: true })).toBeVisible()
abandoned.resolve()
await page.reload()
await expectSessionTitle(page, main.title)
await expect(page.getByRole("tab", { name: "is this question left behind?", exact: true })).toHaveAttribute(
"data-selected",
"",
)
await expect(panel.getByText("This answer arrived after the user left.", { exact: true })).toBeVisible()
await expect(panel.getByRole("status")).toHaveCount(0)
await page.getByRole("tab", { name: "how does the retry loop work?", exact: true }).click()
await expect(panel.getByText("exponential backoff", { exact: false })).toBeVisible()
await page.getByRole("button", { name: "Home", exact: true }).click()
await expect(page).toHaveURL(/\/$/)
await page.locator(`[data-titlebar-tab-link][href="${sessionHref(main.id)}"]`).click()
await expectSessionTitle(page, main.title)
await expect(page.getByRole("tab", { name: "how does the retry loop work?", exact: true })).toHaveAttribute(
"data-selected",
"",
)
await expect(panel.getByText("exponential backoff", { exact: false })).toBeVisible()
await expect(page.getByRole("tab", { name: "/btw" })).toHaveCount(0)
await expect(panel).toHaveCount(0)
expect(ownerWarnings).toEqual([])
})
@@ -7,7 +7,6 @@ test.use({ permissions: ["clipboard-read", "clipboard-write"] })
async function draft(page: Page) {
const { editor } = await openDraft(page, { name: "ComposerDraft", provider: NO_PROVIDER })
await editor.click()
return editor
}
@@ -16,11 +15,9 @@ async function expectCaretVisible(input: Locator) {
.poll(() =>
input.evaluate((element) => {
const selection = window.getSelection()
if (!selection?.isCollapsed || !selection.rangeCount || !element.contains(selection.anchorNode)) return false
const caret = selection.getRangeAt(0).getBoundingClientRect()
const viewport = (element.closest("[data-scrollable]") ?? element).getBoundingClientRect()
return caret.height > 0 && caret.top >= viewport.top - 1 && caret.bottom <= viewport.bottom + 1
}),
)
@@ -31,14 +28,11 @@ test("keeps a 25000-line crash report editable in a new session", async ({ page
const input = await draft(page)
const text = "Thread 0 Crashed:\n" + "0 Example 0x0000000100000000 frame + 32\n".repeat(25000) + "End of report"
await page.evaluate((text) => navigator.clipboard.writeText(text), text)
const events = await input.evaluateHandle((element) => {
const events = { count: 0 }
element.addEventListener("input", () => events.count++)
return events
})
await page.keyboard.press("ControlOrMeta+V")
await expect.poll(async () => (await input.innerText()) === text).toBe(true)
expect(await events.evaluate((events) => events.count)).toBe(1)
@@ -52,7 +46,6 @@ test("keeps a 25000-line crash report editable in a new session", async ({ page
await expectCaretVisible(input)
const thumb = await scroll.locator(".scroll-view__thumb").boundingBox()
const bounds = await scroll.boundingBox()
if (!thumb || !bounds) throw new Error("Missing composer scrollbar bounds")
await page.mouse.move(thumb.x + thumb.width / 2, thumb.y + thumb.height / 2)
await page.mouse.down()
@@ -65,26 +58,6 @@ test("keeps a 25000-line crash report editable in a new session", async ({ page
await expectCaretVisible(input)
})
test("a key-up never moves the caret back after later navigation", async ({ page }) => {
const input = await draft(page)
await input.pressSequentially("first line")
await expect(input).toHaveText("first line")
// Navigation can land before the next frame; the cursor a key-up recorded must not overwrite it there.
const offset = await input.evaluate(async (element) => {
const text = document.createTreeWalker(element, NodeFilter.SHOW_TEXT).nextNode()!
const selection = window.getSelection()!
selection.collapse(text, 0)
element.dispatchEvent(new KeyboardEvent("keyup", { key: "Home", bubbles: true }))
selection.collapse(text, text.textContent!.length)
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)))
return selection.anchorOffset
})
expect(offset).toBe("first line".length)
})
for (const [width, direction] of [
[390, "rtl"],
[1280, "ltr"],
@@ -110,7 +83,6 @@ for (const [width, direction] of [
test("pastes plain text without markup and keeps native undo", async ({ page }) => {
const input = await draft(page)
for (const text of [
"single line <b> &amp;",
"first\nsecond",
@@ -138,16 +110,13 @@ test("replaces only the selected text and leaves the caret after the paste", asy
await page.keyboard.type("before replace after")
await expect(input).toHaveText("before replace after")
await page.evaluate(() => document.fonts.ready)
const word = await input.evaluate((element) => {
const range = document.createRange()
range.setStart(element.firstChild!, 7)
range.setEnd(element.firstChild!, 14)
const rect = range.getBoundingClientRect()
return { x: rect.x, y: rect.y + rect.height / 2, width: rect.width }
})
await page.mouse.move(word.x, word.y)
await page.mouse.down()
await page.mouse.move(word.x + word.width, word.y, { steps: 5 })
@@ -172,11 +141,9 @@ test("shows the dropzone and attaches a dropped file", async ({ page }) => {
})
const surface = page.locator('[data-component="new-session"]')
const dropzone = page.locator('[data-component="session-dropzone"]')
const transfer = await page.evaluateHandle(() => {
const value = new DataTransfer()
value.items.add(new File(["Dropzone fixture"], "dropzone.txt", { type: "text/plain" }))
return value
})
@@ -227,7 +194,6 @@ test("lists slash commands in their built-in order", async ({ page }) => {
// A sent message enables /undo, /compact and /fork.
pageMessages: () => ({ items: [{ id: "msg_slash", type: "user", text: "Hello", time: { created: T0 } }] }),
})
await editor.fill("/")
await expect(page.locator('[data-component="composer-suggestions"] [data-suggestion-id] bdi')).toHaveText([
"/init",
@@ -247,7 +213,6 @@ test("lists slash commands in their built-in order", async ({ page }) => {
})
const followUp = "Add follow-up, / for commands, @ for context…"
for (const row of [
{ state: "an idle", copy: "Ask anything, / for commands, @ for context…" },
{ state: "a running", copy: followUp, sessionStatus: { ses_placeholder: { type: "running" } } },
@@ -273,7 +238,6 @@ for (const row of [
sessionStatus: row.sessionStatus,
inbox: row.inbox,
})
const scroll = page.locator('[data-component="composer-scroll"]')
await expect(scroll).toHaveText(row.copy)
await editor.pressSequentially("!")
@@ -286,7 +250,6 @@ test("shows thinking on hover or a non-default selection while preserving keyboa
name: "ComposerThinking",
provider: provider({ id: "thinking-model", name: "Thinking Model", variants: { high: {} } }),
})
const composer = page.locator('[data-component="composer"]')
const control = composer.getByRole("button", { name: "Choose model variant" })
const option = (name: string) => page.getByRole("menuitemradio", { name, exact: true })
@@ -30,11 +30,9 @@ for (const position of ["top", "bottom"] as const) {
const navigation = page.locator('[data-slot="session-mobile-view-navigation"]')
const more = navigation.getByRole("button", { name: "More options", exact: true })
const picker = tabs.getByRole("tab", { selected: true })
const message = page.locator(
`[data-timeline-row="UserMessage"][data-message-id="${fixture.expected.targetMessageIDs.at(-1)}"]`,
)
const composer = page.getByRole("textbox", { name: "Prompt", exact: true })
await expect(picker).toHaveText("Session")
await expect(message).toBeVisible()
@@ -42,17 +40,9 @@ for (const position of ["top", "bottom"] as const) {
await expect(tabs.getByRole("tab")).toHaveText(["Session", "Changes", "Files", "Terminal"])
await expect(tabs).toHaveCSS("padding-left", "0px")
await expect(tabs).toHaveCSS("padding-right", "0px")
if (position === "top") {
const titlebar = page.locator('[data-slot="titlebar-v2"]')
await expect(titlebar).toHaveCSS("padding-top", "16px")
await expect(titlebar).toHaveCSS("height", "44px")
}
await expect
.poll(async () => {
const bounds = await navigation.boundingBox()
return !!bounds && bounds.x >= 8 && bounds.x <= 9 && bounds.width >= 372 && bounds.width <= 374
})
.toBe(true)
@@ -61,7 +51,6 @@ for (const position of ["top", "bottom"] as const) {
const bar = await tabs.boundingBox()
const input = await composer.boundingBox()
const panel = await page.locator('[data-slot="session-chat-panel"]').boundingBox()
return !!bar && !!input && !!panel && Math.abs(bar.y - panel.y) <= 1 && bar.y + bar.height <= input.y
})
.toBe(true)
@@ -160,19 +149,16 @@ for (const position of ["top", "bottom"] as const) {
// The view resets to Session whenever the routed session changes, including through Home.
const trigger = page.locator('[data-slot="mobile-tabs-trigger"]')
const openTabs = async () => {
await trigger.click()
await expect(drawer).not.toHaveAttribute("data-transitioning")
}
const openTab = async (title: string) => {
await openTabs()
await drawer.locator('[data-slot="tab-link"]').filter({ hasText: title }).click()
await expect(drawer).toBeHidden()
await expect(trigger).toContainText(title)
}
await more.click()
await page.getByRole("menuitem", { name: "Usage", exact: true }).click()
await expect(page.getByText("Total Cost", { exact: true })).toBeVisible()
@@ -352,7 +338,6 @@ test.describe("touch", () => {
.poll(async () => {
const navigation = await settings.getByRole("button", { name: "Models", exact: true }).boundingBox()
const content = await panel.boundingBox()
return !!navigation && !!content && navigation.y + navigation.height <= content.y
})
.toBe(true)
@@ -833,32 +833,6 @@ test("a chosen mode the session stops offering shows as Git, and comes back when
await expect(file).toHaveText("beta.ts")
})
test("names a single change source and keeps the review header rows at 48px", async ({ page }) => {
// Turn changes are offered only for Git, so another VCS offers one source.
await openSession(page, { name: "ReviewHeader", project: { vcs: "hg" }, vcsDiff: [fileDiff("src/alpha.ts")] })
await page.getByRole("button", { name: "Toggle review", exact: true }).click()
const panel = page.locator("#review-panel")
const toggle = panel.getByRole("button", { name: "Toggle file tree" })
const header = panel.locator('[data-slot="session-review-v2-sidebar-header"]')
const toolbar = panel.locator('[data-slot="session-review-v2-toolbar"]')
await expect(header.getByText("Git changes", { exact: true })).toBeVisible()
await expect(panel.getByRole("button", { name: "Git changes" })).toHaveCount(0)
await expect(toggle).toHaveAttribute("aria-expanded", "true")
await expect(header).toHaveCSS("height", "48px")
await expect(toolbar).toHaveCSS("height", "48px")
await toggle.click()
await expect(toggle).toHaveAttribute("aria-expanded", "false")
await expect(toolbar.getByText("Git changes", { exact: true })).toBeVisible()
await expect(toolbar).toHaveCSS("height", "48px")
await toggle.click()
await expect(toggle).toHaveAttribute("aria-expanded", "true")
await expect(header).toHaveCSS("height", "48px")
await expect(toolbar).toHaveCSS("height", "48px")
})
for (const direction of ["ltr", "rtl"] as const) {
test(`review toggle stays at the header edge in ${direction}`, async ({ page }) => {
await page.setViewportSize({ width: 1000, height: 900 })
@@ -29,14 +29,12 @@ for (const direction of ["ltr", "rtl"] as const) {
await expect(page.locator('[data-slot="titlebar-v2"]').getByRole("button", { name: "Status" })).toHaveCount(0)
const titleBounds = await header.getByRole("heading").boundingBox()
expect(titleBounds).not.toBeNull()
for (const editing of [false, true]) {
if (editing) {
await header.getByRole("heading").click()
await expect(header.getByRole("textbox")).toHaveValue(fixture.expected.targetTitle)
await expect(header.getByRole("textbox")).toBeFocused()
}
await expect(header.locator('[data-slot="session-title-child"]')).toHaveCSS("padding-left", "4px")
await expect(header.locator('[data-slot="session-title-child"]')).toHaveCSS("padding-right", "4px")
await expect
@@ -46,11 +44,8 @@ for (const direction of ["ltr", "rtl"] as const) {
control.boundingBox(),
),
)
if (!icon || !title || !menu || !sidebar || !summary || !titleBounds) return false
if (Math.abs(title.y - titleBounds.y) > 0.5 || Math.abs(title.height - titleBounds.height) > 0.5) return false
return direction === "ltr"
? Math.abs(title.x - icon.x - icon.width - 2) <= 0.5 &&
Math.abs(menu.x - title.x - title.width - 2) <= 0.5 &&
@@ -63,7 +58,6 @@ for (const direction of ["ltr", "rtl"] as const) {
})
.toBe(true)
}
await header.getByRole("textbox").press("Escape")
await expect(header.getByRole("heading")).toHaveText(fixture.expected.targetTitle)
@@ -76,7 +70,6 @@ for (const direction of ["ltr", "rtl"] as const) {
await more.click()
const options = page.getByRole("menu")
await expect(options.getByRole("menuitem")).toHaveText(["Rename", "Export…", "Delete…"])
if (direction === "ltr") {
await expect
.poll(async () => {
@@ -84,24 +77,20 @@ for (const direction of ["ltr", "rtl"] as const) {
header.getByRole("button", { name: "More options", exact: true, includeHidden: true }).boundingBox(),
options.boundingBox(),
])
return button && menu ? Math.abs(button.x - menu.x) : Infinity
})
.toBeLessThanOrEqual(1)
}
await expect
.poll(() =>
options.evaluate((element) => {
const menu = element.getBoundingClientRect()
const rtl = getComputedStyle(element).direction === "rtl"
return Math.min(
...Array.from(element.querySelectorAll('[data-slot="menu-v2-item-content"]'), (label) => {
const range = document.createRange()
range.selectNodeContents(label)
const text = range.getBoundingClientRect()
return rtl ? text.left - menu.left : menu.right - text.right
}),
)
@@ -114,7 +103,6 @@ for (const direction of ["ltr", "rtl"] as const) {
const menu = element.getBoundingClientRect()
const divider = element.querySelector('[data-slot="menu-v2-separator"]')?.getBoundingClientRect()
const rows = Array.from(element.querySelectorAll('[role="menuitem"]'), (row) => row.getBoundingClientRect())
return (
!!divider &&
Math.abs(divider.left - menu.left) <= 0.5 &&
@@ -147,7 +135,6 @@ test.describe("rename", () => {
renames.length = 0
page.on("request", (request) => {
const match = new URL(request.url()).pathname.match(/^\/api\/session\/([^/]+)$/)
if (request.method() === "PATCH" && match) renames.push({ sessionID: match[1]!, body: request.postDataJSON() })
})
await mockStressTimeline(page)
@@ -162,11 +149,8 @@ test.describe("rename", () => {
const input = page.locator('input[data-slot="session-title-child"]')
await expect(input).toBeFocused()
await input.fill("Renamed session")
if (commit === "Enter") await input.press("Enter")
if (commit === "blur") await input.press("Tab")
if (commit === "click outside") await page.locator('[data-component="composer-editor"]').click()
await expect(heading(page, "Renamed session")).toBeVisible()
await expect(tabs(page).filter({ hasText: "Renamed session" })).toBeVisible()
@@ -268,7 +252,6 @@ test.describe("revert", () => {
},
{ id: "msg_second", type: "user", text: "Second prompt", time: { created: 4 } },
] satisfies SessionMessageInfo[]
const workspace = {
name: "SessionMessageRevert",
pageMessages: () => ({ items: messages }),
@@ -277,54 +260,18 @@ test.describe("revert", () => {
test("reverts directly to the selected user message", async ({ page }) => {
const staged: { sessionID: string; messageID: string }[] = []
const { session } = await openSession(page, { ...workspace, onRevertStage: (input) => staged.push(input) })
const settles: string[] = []
page.on("request", (request) => {
const path = new URL(request.url()).pathname
const step = ["/interrupt", "/wait", "/revert/stage"].find((suffix) => path.endsWith(`/${session.id}${suffix}`))
if (step) settles.push(step)
})
const message = page.locator('[data-message-id="msg_second"]')
await message.hover()
const response = page.waitForResponse(
(response) =>
response.request().method() === "POST" &&
new URL(response.url()).pathname === `/api/session/${session.id}/revert/stage`,
)
await message.getByRole("button", { name: "Revert message" }).click()
expect((await response).ok()).toBe(true)
await expect(page.getByRole("textbox", { name: "Prompt" })).toHaveText("Second prompt")
expect(staged).toEqual([{ sessionID: session.id, messageID: "msg_second" }])
// Interrupt acknowledges before the run settles, and the server refuses to stage while it is active.
expect(settles).toEqual(["/interrupt", "/wait", "/revert/stage"])
})
test("redo restores every reverted message at once, as in the TUI", async ({ page }) => {
const staged: { sessionID: string; messageID: string }[] = []
const { editor } = await openSession(page, {
...workspace,
sessions: [{ id: "ses_revert_redo", title: "Session message revert", revert: { messageID: "msg_first" } }],
onRevertStage: (input) => staged.push(input),
})
const cleared = page.waitForResponse(
(response) =>
response.request().method() === "DELETE" &&
new URL(response.url()).pathname === "/api/session/ses_revert_redo/revert",
)
await editor.pressSequentially("/redo")
await expect(
page.locator('[data-component="composer-suggestions"] [data-suggestion-id][data-active]'),
).toContainText("/redo")
await editor.press("Enter")
expect((await cleared).ok()).toBe(true)
expect(staged).toEqual([])
})
test("hides revert actions in a child session", async ({ page }) => {
@@ -66,72 +66,6 @@ for (const endpoint of ["/api/location", "/api/agent"]) {
}
}
test("replaces the composer when the location is not found and recovers by moving to another worktree", async ({
page,
}) => {
const directory = "/projects/deleted-worktree"
const destination = "/projects/existing-worktree"
const sessionID = "ses_location_not_found"
const session = { id: sessionID, projectID: fixture.project.id, directory, title: "Missing location" }
const moves: unknown[] = []
const transport = await installSseTransport(page, { server: fixture.serverKey })
await mockOpenCodeServer(page, {
directory: fixture.directory,
project: fixture.project,
provider: fixture.provider,
sessions: [session],
fileList: () => [],
worktrees: [{ directory: fixture.directory }, { directory: destination, strategy: "git" }],
pageMessages: () => ({
items: [{ id: "msg_saved", type: "user", text: "Keep this session history", time: { created: 1 } }],
}),
})
let requests = 0
await page.route("**/api/location?**", (route) => {
if (new URL(route.request().url()).searchParams.get("location[directory]") !== directory) return route.fallback()
requests++
return route.fulfill({
status: 404,
// SAFETY: the server's wire body for a missing Location, which the client decodes into its own error.
// oxlint-disable-next-line anti-slop-effect/no-manual-tagged-construction -- see SAFETY above
json: { _tag: "LocationNotFoundError", location: { directory }, message: `Location not found: ${directory}` },
headers: { "access-control-allow-origin": "*" },
})
})
await page.route(`**/api/session/${sessionID}/move`, (route) => {
if (route.request().method() !== "POST") return route.fallback()
moves.push(route.request().postDataJSON())
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
})
await page.goto(`/server/${base64Encode(fixture.serverKey)}/session/${sessionID}`)
const status = page.getByRole("status").filter({ hasText: "Session location unavailable" })
await expect(status).toContainText(directory)
await expect(page.getByText("Keep this session history", { exact: true })).toBeVisible()
await expect(page.getByRole("textbox", { name: "Prompt", exact: true })).toHaveCount(0)
// A typed not-found answer is final; retrying cannot succeed until the folder changes.
expect(requests).toBe(1)
await transport.waitForConnection()
await page.getByRole("button", { name: "Choose worktree", exact: true }).click()
await page.getByRole("menuitem", { name: "existing-worktree", exact: true }).click()
await expect.poll(() => moves).toEqual([{ directory: destination }])
session.directory = destination
await transport.send({
id: "evt_location_not_found_moved",
type: "session.moved",
created: 2,
durable: { aggregateID: sessionID, seq: 1, version: 1 },
data: { sessionID, location: { directory: destination }, projectID: fixture.project.id },
})
await expect(page.getByRole("textbox", { name: "Prompt", exact: true })).toBeEditable()
await expect(status).toHaveCount(0)
await expect(page.getByText("Keep this session history", { exact: true })).toBeVisible()
})
test("follows a live session move while the agent catalog is still loading", async ({ page }) => {
const recovery = recoveryRequests(page)
const directory = "/projects/old-tree"
+31 -569
View File
@@ -1,26 +1,10 @@
import { expect, test, type Page } from "@playwright/test"
import type { OpenCodeEvent, SessionInboxInfo, SessionMessageInfo } from "@opencode/client/promise"
import { PromptInput } from "@opencode/schema/prompt-input"
import { SessionInbox } from "@opencode/schema/session-inbox"
import { SessionMessage } from "@opencode/schema/session-message"
import { Schema } from "effect"
import { provider } from "../utils/app"
import type { MockServerConfig } from "../utils/mock-server"
import { openSession } from "../utils/workspace"
const sessionID = "ses_session_queue_regression"
// The session.prompt payload, as the server's endpoint validates it.
const PromptBody = Schema.Struct({
id: SessionMessage.ID.pipe(Schema.optional),
...PromptInput.Prompt.fields,
metadata: SessionInbox.UserPayload.fields.metadata,
delivery: SessionInbox.Delivery.pipe(Schema.optional),
resume: Schema.Boolean.pipe(Schema.optional),
})
const decodePromptBody = Schema.decodeUnknownSync(PromptBody)
type InboxRow = {
id: string
sessionID: string
@@ -28,26 +12,11 @@ type InboxRow = {
type: "user"
payload: {
text: string
metadata?: typeof PromptBody.Type.metadata
metadata?: Record<string, unknown>
files?: Extract<SessionInboxInfo, { type: "user" }>["payload"]["files"]
agents?: Extract<SessionInboxInfo, { type: "user" }>["payload"]["agents"]
}
delivery: SessionInbox.Delivery
}
function storedFile(file: NonNullable<typeof PromptBody.Type.files>[number]) {
const inline = file.uri.startsWith("data:")
const stored: NonNullable<InboxRow["payload"]["files"]>[number] = {
data: inline ? file.uri.slice(file.uri.indexOf(",") + 1) : "",
mime: inline ? file.uri.slice("data:".length, file.uri.indexOf(";")) : "text/plain",
source: inline ? { type: "inline" } : { type: "uri", uri: file.uri },
name: file.name,
description: file.description,
mention: file.mention,
}
return stored
delivery: "steer" | "queue"
}
function createQueueMock(seed: string[], messages: SessionMessageInfo[] = []) {
@@ -59,20 +28,16 @@ function createQueueMock(seed: string[], messages: SessionMessageInfo[] = []) {
payload: { text },
delivery: "queue",
}))
const events: OpenCodeEvent[] = []
const prompts: (typeof PromptBody.Type)[] = []
const compactions: SessionInboxInfo[] = []
const prompts: Record<string, unknown>[] = []
const changes: { inboxID: string; action: "cancel" | "steer" | "queue" }[] = []
const log: string[] = []
let sequence = 0
const emit = <Type extends OpenCodeEvent["type"]>(
type: Type,
data: Extract<OpenCodeEvent, { type: Type }>["data"],
) => {
sequence += 1
// SAFETY: `type` selects `data` from the same OpenCodeEvent member, so the pair is that member.
events.push({
id: `evt_queue_${sequence}`,
type,
@@ -81,37 +46,28 @@ function createQueueMock(seed: string[], messages: SessionMessageInfo[] = []) {
data,
} as OpenCodeEvent)
}
return {
rows,
compactions,
prompts,
changes,
log,
messages,
emit,
events: () => events.splice(0),
onPrompt: (input: Parameters<NonNullable<MockServerConfig["onPrompt"]>>[0]) => {
const body = decodePromptBody(input.body)
prompts.push(body)
log.push(`prompt:${body.delivery ?? "steer"}`)
onPrompt: (input: { sessionID: string; body: Record<string, unknown> }) => {
prompts.push(input.body)
log.push(`prompt:${String(input.body.delivery ?? "steer")}`)
const row: InboxRow = {
id: body.id ?? `inb_mock_${sequence}`,
id: typeof input.body.id === "string" ? input.body.id : `inb_mock_${sequence}`,
sessionID: input.sessionID,
time: { created: Date.now() },
type: "user",
payload: { text: body.text },
delivery: body.delivery ?? "steer",
payload: {
text: typeof input.body.text === "string" ? input.body.text : "",
...(input.body.metadata === undefined ? {} : { metadata: input.body.metadata as Record<string, unknown> }),
},
delivery: input.body.delivery === "queue" ? "queue" : "steer",
}
if (body.metadata !== undefined) row.payload.metadata = body.metadata
// Store attachments the way the server materializes them, so re-admissions round-trip.
if (body.files !== undefined) row.payload.files = body.files.map(storedFile)
if (body.agents !== undefined)
row.payload.agents = body.agents.map((agent) => ({ name: agent.name, mention: agent.mention }))
rows.push(row)
emit("session.inbox.enqueued", {
sessionID: input.sessionID,
@@ -119,32 +75,17 @@ function createQueueMock(seed: string[], messages: SessionMessageInfo[] = []) {
item: { type: "user", payload: row.payload, delivery: row.delivery },
})
},
onCompact: (input: Parameters<NonNullable<MockServerConfig["onCompact"]>>[0]) => {
log.push("compact")
compactions.push({
id: Schema.decodeUnknownSync(SessionMessage.ID)(input.body.id),
sessionID: input.sessionID,
time: { created: Date.now() },
type: "compaction",
payload: {},
delivery: "steer",
})
},
onInboxChange: (input: { sessionID: string; inboxID: string; action: "cancel" | "steer" | "queue" }) => {
changes.push({ inboxID: input.inboxID, action: input.action })
log.push(`${input.action}:${input.inboxID}`)
const index = rows.findIndex((row) => row.id === input.inboxID)
const row = rows[index]
if (!row) return
if (input.action === "cancel") {
rows.splice(index, 1)
emit("session.inbox.cancelled", { sessionID: input.sessionID, inboxID: input.inboxID })
return
}
row.delivery = input.action
emit("session.inbox.delivery.changed", {
sessionID: input.sessionID,
@@ -155,38 +96,21 @@ function createQueueMock(seed: string[], messages: SessionMessageInfo[] = []) {
}
}
async function openQueue(
page: Page,
mock: ReturnType<typeof createQueueMock>,
followUpBehavior?: "queue" | "steer",
revert?: string,
) {
async function openQueue(page: Page, mock: ReturnType<typeof createQueueMock>, followUpBehavior?: "queue" | "steer") {
const model = { id: "queue-model", name: "Queue Model" }
const options: Parameters<typeof openSession>[1] = {
await openSession(page, {
name: "SessionQueueRegression",
sessions: [
{
id: sessionID,
title: "Session queue regression",
model: { id: model.id, providerID: "opencode" },
revert: revert ? { messageID: revert } : undefined,
},
],
sessions: [{ id: sessionID, title: "Session queue regression", model: { id: model.id, providerID: "opencode" } }],
provider: provider(model),
pageMessages: () => ({ items: mock.messages }),
sessionStatus: () => ({ [sessionID]: { type: "running" } }),
inbox: () => [...mock.rows.map((row) => ({ ...row, payload: { ...row.payload } })), ...mock.compactions],
inbox: () => mock.rows.map((row) => ({ ...row, payload: { ...row.payload } })),
onPrompt: mock.onPrompt,
onCompact: mock.onCompact,
onInboxChange: mock.onInboxChange,
events: mock.events,
}
if (followUpBehavior) options.seed = { settings: { general: { followUpBehavior } } }
await openSession(page, options)
...(followUpBehavior ? { seed: { settings: { general: { followUpBehavior } } } } : {}),
})
const composer = page.locator('[data-component="composer"]')
return {
composer,
input: composer.locator('[data-component="composer-editor"]'),
@@ -194,20 +118,6 @@ async function openQueue(
}
}
async function runSlash(page: Page, input: Awaited<ReturnType<typeof openQueue>>["input"], name: string) {
await input.pressSequentially(`/${name}`)
await expect(page.locator('[data-component="composer-suggestions"] [data-suggestion-id][data-active]')).toContainText(
`/${name}`,
)
await input.press("Enter")
}
function userRow(page: Page, messageID: string) {
return page.locator(
`[data-timeline-virtual-content] [data-timeline-row="UserMessage"][data-message-id="${messageID}"]`,
)
}
test("follow-up preference controls Enter while Mod+Enter uses the alternate delivery", async ({ page }) => {
const mock = createQueueMock([])
const view = await openQueue(page, mock, "queue")
@@ -225,9 +135,6 @@ test("follow-up preference controls Enter while Mod+Enter uses the alternate del
test("dragging reorders queued prompts", async ({ page }) => {
const mock = createQueueMock(["first queued prompt", "second queued prompt", "third queued prompt"])
mock.rows[0].payload.files = [
{ data: "aGk=", mime: "text/plain", source: { type: "uri", uri: "file:///repo/main.ts" }, name: "main.ts" },
]
const view = await openQueue(page, mock)
await expect(view.rows).toHaveCount(3)
@@ -236,7 +143,6 @@ test("dragging reorders queued prompts", async ({ page }) => {
await first.getByRole("button", { name: "Reorder queued prompt" }).hover()
await page.mouse.down()
const target = await third.boundingBox()
if (!target) throw new Error("The target queue row is not visible")
await page.mouse.move(target.x + target.width / 2, target.y + target.height / 2, { steps: 10 })
await page.mouse.up()
@@ -251,8 +157,6 @@ test("dragging reorders queued prompts", async ({ page }) => {
"third queued prompt",
"first queued prompt",
])
// A re-admitted file keeps the URI it came from instead of becoming an inline snapshot.
expect(mock.prompts[2].files).toMatchObject([{ uri: "file:///repo/main.ts", name: "main.ts" }])
expect(mock.changes).toEqual([
{ inboxID: "inb_seed_1", action: "cancel" },
{ inboxID: "inb_seed_2", action: "cancel" },
@@ -260,56 +164,6 @@ test("dragging reorders queued prompts", async ({ page }) => {
])
})
for (const change of ["reorder", "edit"] as const) {
test(`a ${change} that cannot re-admit a prompt leaves the queue unchanged`, async ({ page }) => {
const order = ["first queued prompt", "second queued prompt", "third queued prompt"]
const mock = createQueueMock(order)
const admit = mock.onPrompt
// The last prompt's re-admission fails after the server admitted it, so its response is lost.
mock.onPrompt = (input) => {
admit(input)
if (input.body.text === order[2]) throw new Error("Connection lost after admission")
}
const view = await openQueue(page, mock)
await expect(view.rows).toHaveCount(3)
if (change === "reorder") {
const first = view.rows.filter({ hasText: order[0] })
const second = view.rows.filter({ hasText: order[1] })
await first.getByRole("button", { name: "Reorder queued prompt" }).hover()
await page.mouse.down()
const target = await second.boundingBox()
if (!target) throw new Error("The target queue row is not visible")
await page.mouse.move(target.x + target.width / 2, target.y + target.height / 2, { steps: 10 })
await page.mouse.up()
}
if (change === "edit") {
await view.rows.getByText(order[1], { exact: true }).click()
await expect(view.input).toHaveText(order[1])
await expect(view.input).toBeFocused()
await view.input.press("End")
await view.input.pressSequentially(", edited")
await view.input.press("Enter")
}
// The replacements admitted before the failure are withdrawn; the originals stay in place.
await expect.poll(() => mock.log.includes(`cancel:${mock.prompts.at(-1)?.id}`)).toBe(true)
await expect.poll(() => mock.changes.length).toBe(mock.prompts.length)
expect(mock.changes.every((entry) => entry.action === "cancel" && !entry.inboxID.startsWith("inb_seed_"))).toBe(
true,
)
await expect(view.rows.locator('[data-action="session-queue-edit"]')).toHaveText(order)
expect(mock.rows.map((row) => row.payload.text)).toEqual(order)
// A failed edit keeps its draft in the composer so nothing typed is lost.
if (change === "edit") await expect(view.input).toHaveText("second queued prompt, edited")
})
}
test("editing restores the existing draft and replaces only the original queue position", async ({ page }) => {
const mock = createQueueMock(["first queued prompt", "tighten the error copy", "third queued prompt"])
const view = await openQueue(page, mock)
@@ -326,9 +180,7 @@ test("editing restores the existing draft and replaces only the original queue p
await original.click()
await expect(view.input).toHaveText("tighten the error copy")
await expect(view.input).toBeFocused()
// fill() on a composer that holds text can append, so extend the loaded text instead.
await view.input.press("End")
await view.input.pressSequentially(" and add a retry hint")
await view.input.fill("tighten the error copy and add a retry hint")
await expect(view.input).toHaveText("tighten the error copy and add a retry hint")
await view.input.press("Enter")
@@ -338,123 +190,16 @@ test("editing restores the existing draft and replaces only the original queue p
"third queued prompt",
])
await expect(view.input).toHaveText("my in-progress draft")
// One rewrite from the edited position: the replacement, then the prompts after it.
expect(mock.prompts.map((prompt) => prompt.text)).toEqual([
"tighten the error copy and add a retry hint",
"tighten the error copy and add a retry hint",
"third queued prompt",
])
expect(mock.prompts.every((prompt) => prompt.delivery === "queue" && prompt.resume === false)).toBe(true)
expect(mock.changes).toEqual([
{ inboxID: "inb_seed_2", action: "cancel" },
{ inboxID: "inb_seed_3", action: "cancel" },
])
expect(mock.changes.map((change) => change.action)).toEqual(["cancel", "cancel", "cancel"])
expect(mock.log[0]).toBe("prompt:queue")
})
for (const delivery of ["queue", "steer"] as const) {
test(`editing a prompt the server delivered meanwhile keeps the edit draft (${delivery})`, async ({ page }) => {
const mock = createQueueMock(["first queued prompt", "second queued prompt"])
const view = await openQueue(page, mock, "queue")
await view.rows.getByText("second queued prompt", { exact: true }).click()
await expect(view.input).toHaveText("second queued prompt")
await expect(view.input).toBeFocused()
await view.input.press("End")
await view.input.pressSequentially(", edited")
// The server delivers the original before the edit lands; this client has not heard yet.
mock.rows.splice(1, 1)
await view.input.press(delivery === "queue" ? "Enter" : "ControlOrMeta+Enter")
await expect(page.getByText("Request failed")).toBeVisible()
await expect(view.input).toHaveText("second queued prompt, edited")
expect(mock.prompts).toEqual([])
expect(mock.changes).toEqual([])
})
}
for (const change of ["reorder", "edit"] as const) {
test(`a staged revert refuses a queue ${change}, whose admission would commit it`, async ({ page }) => {
const order = ["first queued prompt", "second queued prompt"]
const mock = createQueueMock(order, [
{ id: "msg_queue_delivered", type: "user", text: "First prompt", time: { created: 1 } },
])
const view = await openQueue(page, mock, undefined, "msg_queue_delivered")
await expect(view.rows).toHaveCount(2)
if (change === "reorder") {
await view.rows.filter({ hasText: order[0] }).getByRole("button", { name: "Reorder queued prompt" }).hover()
await page.mouse.down()
const target = await view.rows.filter({ hasText: order[1] }).boundingBox()
if (!target) throw new Error("The target queue row is not visible")
await page.mouse.move(target.x + target.width / 2, target.y + target.height / 2, { steps: 10 })
await page.mouse.up()
}
if (change === "edit") await view.rows.getByText(order[1], { exact: true }).click()
await expect(page.getByText("Redo the revert before you reorder or edit queued prompts")).toBeVisible()
await expect(view.rows.locator('[data-action="session-queue-edit"]')).toHaveText(order)
await expect(view.input).toHaveText("")
expect(mock.prompts).toEqual([])
expect(mock.changes).toEqual([])
})
}
test("editing drops a file whose mention was deleted and keeps unmentioned context", async ({ page }) => {
const mock = createQueueMock(["inspect @main.ts here"])
mock.rows[0].payload.files = [
{
data: "aGk=",
mime: "text/plain",
source: { type: "uri", uri: "file:///repo/main.ts" },
name: "main.ts",
mention: { start: 8, end: 16, text: "@main.ts" },
},
{ data: "bm90ZXM=", mime: "text/plain", source: { type: "uri", uri: "file:///repo/notes.md" }, name: "notes.md" },
]
const view = await openQueue(page, mock)
await view.rows.getByText("inspect @main.ts here", { exact: true }).click()
await expect(view.input).toHaveText("inspect @main.ts here")
// The edit focuses the composer a frame after loading it; keys sent earlier miss it.
await expect(view.input).toBeFocused()
await view.input.press("ControlOrMeta+a")
await view.input.pressSequentially("inspect here")
await view.input.press("Enter")
await expect.poll(() => mock.prompts.length).toBe(1)
expect(mock.prompts[0].files?.map((file) => file.uri)).toEqual(["file:///repo/notes.md"])
})
test("editing a comment-only prompt keeps its notes once", async ({ page }) => {
const note = "The user made the following comment regarding line 2 of /repo/app.ts: check the guard"
const mock = createQueueMock([note])
mock.rows[0].payload.metadata = {
displayText: "",
comments: [
{
path: "/repo/app.ts",
comment: "check the guard",
selection: { startLine: 2, startChar: 0, endLine: 2, endChar: 0 },
origin: "review",
},
],
}
const view = await openQueue(page, mock)
// With no display text, the editor shows the note itself, so the edit owns it as text.
await view.rows.getByText(note, { exact: true }).click()
await expect(view.input).toHaveText(note)
await view.input.press("End")
await view.input.pressSequentially(", please")
await expect(view.input).toHaveText(`${note}, please`)
await view.input.press("Enter")
await expect.poll(() => mock.prompts.length).toBe(1)
expect(mock.prompts[0].text).toBe(`${note}, please`)
expect(mock.prompts[0].metadata).toMatchObject({ displayText: `${note}, please`, comments: [] })
})
test("Undo cancels only the selected queued prompt and focuses the restored input", async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 })
const text = "Review the detailed error report and check every step of the retry path ".repeat(4)
@@ -496,47 +241,6 @@ test("Undo appends to an existing draft and restores inline attachments", async
expect(mock.changes).toEqual([{ inboxID: "inb_seed_1", action: "cancel" }])
})
test("Undo of a comment-only prompt keeps the draft text and restores the comment once", async ({ page }) => {
const comment = "check the guard"
const mock = createQueueMock([`The user made the following comment regarding line 2 of /repo/app.ts: ${comment}`])
mock.rows[0].payload.metadata = {
displayText: "",
comments: [
{
path: "/repo/app.ts",
comment,
selection: { startLine: 2, startChar: 0, endLine: 2, endChar: 0 },
origin: "review",
},
],
}
mock.rows[0].payload.files = [
{
data: "aGk=",
mime: "text/plain",
source: { type: "uri", uri: "file:///repo/app.ts?start=2&end=2" },
name: "app.ts",
},
]
const view = await openQueue(page, mock)
await view.input.fill("my draft")
await view.rows.getByRole("button", { name: "Undo" }).click()
await expect(view.rows).toHaveCount(0)
await expect(view.input).toHaveText("my draft")
const cards = view.composer.locator('[data-component="composer-attachments"] [data-component="attachment-card"]')
await expect(cards).toHaveCount(1)
await expect(cards).toContainText(comment)
// The resubmission carries the draft text with no stray break and the comment's file exactly once.
await view.input.press("Enter")
await expect.poll(() => mock.prompts.length).toBe(1)
expect(mock.prompts[0].text).toBe(
`my draft\nThe user made the following comment regarding line 2 of /repo/app.ts: ${comment}`,
)
expect(mock.prompts[0].files?.map((file) => file.uri)).toEqual(["file:///repo/app.ts?start=2&end=2"])
})
test("Undo preserves mentioned file and agent references on resubmission", async ({ page }) => {
const mock = createQueueMock(["inspect @main.ts with @build"])
mock.rows[0].payload.files = [
@@ -554,256 +258,23 @@ test("Undo preserves mentioned file and agent references on resubmission", async
await expect(view.input).toHaveText("inspect @main.ts with @build")
await view.input.press("Enter")
await expect.poll(() => mock.prompts.length).toBe(1)
// Like the TUI, a restored mention points at the file it named rather than a snapshot of it.
expect(mock.prompts[0].files).toMatchObject([
{ uri: "file:///repo/main.ts", mention: { text: "@main.ts", start: 8, end: 16 } },
{ uri: "data:text/plain;base64,aGk=", mention: { text: "@main.ts", start: 8, end: 16 } },
])
expect(mock.prompts[0].agents).toMatchObject([{ name: "build", mention: { text: "@build" } }])
})
for (const delivery of ["queue", "steer"] as const) {
test(`${delivery === "queue" ? "Undo" : "/undo of a pending steer"} keeps unmentioned file context`, async ({
page,
}) => {
// Another client (for example ACP) can attach a file without mentioning it.
const mock = createQueueMock(["inspect this file"])
const inboxID = mock.rows[0].id
mock.rows[0].delivery = delivery
mock.rows[0].payload.files = [
{
data: "aGk=",
mime: "text/plain",
source: { type: "uri", uri: "file:///repo/main.ts" },
name: "main.ts",
description: "the failing version",
},
]
const view = await openQueue(page, mock)
if (delivery === "queue") await view.rows.getByRole("button", { name: "Undo" }).click()
if (delivery === "steer") {
await expect(userRow(page, inboxID)).toContainText("inspect this file")
await runSlash(page, view.input, "undo")
}
// Like the TUI, the file returns attached without a mention, as a chip rather than prompt text.
const chip = view.composer.locator('[data-slot="composer-context-file"]')
await expect(view.input).toHaveText("inspect this file")
await expect(chip).toHaveCount(1)
await expect(chip).toContainText("main.ts")
expect(mock.changes).toEqual([{ inboxID, action: "cancel" }])
// Like the TUI's blank Enter, a chip alone sends nothing: without text the draft reads as blank.
await expect(view.input).toBeFocused()
await view.input.press("ControlOrMeta+a")
await view.input.press("Backspace")
await expect(view.input).toHaveText("")
await expect(view.composer.locator('[data-action="composer-submit"]')).toHaveAttribute("aria-label", "Stop")
await expect(chip).toHaveCount(1)
await view.input.pressSequentially("inspect this file")
await view.input.press("Enter")
await expect.poll(() => mock.prompts.length).toBe(1)
expect(mock.prompts[0].files).toMatchObject([
{ uri: "file:///repo/main.ts", name: "main.ts", description: "the failing version" },
])
expect(mock.prompts[0].files?.[0]?.mention).toBeUndefined()
await expect(chip).toHaveCount(0)
})
}
test("reverting to another prompt replaces the file chips of an earlier restore", async ({ page }) => {
const mock = createQueueMock(
[],
[
{ id: "msg_queue_first", type: "user", text: "First prompt", time: { created: 1 } },
{
id: "msg_queue_second",
type: "user",
text: "Second prompt",
files: [
{ data: "aGk=", mime: "text/plain", source: { type: "uri", uri: "file:///repo/main.ts" }, name: "main.ts" },
],
time: { created: 2 },
},
],
)
const view = await openQueue(page, mock)
const chip = view.composer.locator('[data-slot="composer-context-file"]')
for (const [id, text, chips] of [
["msg_queue_second", "Second prompt", 1],
["msg_queue_first", "First prompt", 0],
] as const) {
const row = userRow(page, id)
await row.hover()
await row.getByRole("button", { name: "Revert message" }).click()
await expect(view.input).toHaveText(text)
await expect(chip).toHaveCount(chips)
}
})
test("/undo withdraws a pending steer without interrupting the running session", async ({ page }) => {
const mock = createQueueMock([])
const view = await openQueue(page, mock, "steer")
const stops: string[] = []
page.on("request", (request) => {
const path = new URL(request.url()).pathname
if (path.endsWith("/interrupt") || path.endsWith("/wait") || path.endsWith("/revert/stage")) stops.push(path)
})
const text = "U2: Also check the retry path."
await view.input.fill(text)
await view.input.press("Enter")
await expect.poll(() => mock.rows.map((row) => row.delivery)).toEqual(["steer"])
await expect(view.input).toHaveText("")
const inboxID = mock.rows[0].id
const pending = userRow(page, inboxID)
await expect(pending).toContainText(text)
await runSlash(page, view.input, "undo")
await expect(pending).toHaveCount(0)
await expect(view.input).toHaveText(text)
expect(mock.changes).toEqual([{ inboxID, action: "cancel" }])
expect(stops).toEqual([])
})
test("reverting a delivered prompt leaves queued prompts alone, as in the TUI", async ({ page }) => {
const mock = createQueueMock(
["U2: queued follow-up"],
[{ id: "msg_queue_delivered", type: "user", text: "First prompt", time: { created: 1 } }],
)
const view = await openQueue(page, mock)
const delivered = userRow(page, "msg_queue_delivered")
const staged = page.waitForResponse(
(response) => new URL(response.url()).pathname === `/api/session/${sessionID}/revert/stage`,
)
await delivered.hover()
await delivered.getByRole("button", { name: "Revert message" }).click()
expect((await staged).ok()).toBe(true)
await expect(view.input).toHaveText("First prompt")
expect(mock.changes).toEqual([])
await expect(view.rows).toHaveCount(1)
})
test("a shell command cannot wait in the queue, as in the TUI", async ({ page }) => {
const mock = createQueueMock([])
const view = await openQueue(page, mock, "queue")
const shells: string[] = []
page.on("request", (request) => {
if (new URL(request.url()).pathname.endsWith(`/${sessionID}/shell`)) shells.push(request.url())
})
await view.input.pressSequentially("!")
await view.input.pressSequentially("git status")
await view.input.press("Enter")
await expect(page.getByText("This prompt cannot be queued")).toBeVisible()
await expect(view.input).toHaveText("git status")
expect(shells).toEqual([])
expect(mock.prompts).toEqual([])
})
test("/compact runs with the composer model and shows a queued compaction, as in the TUI", async ({ page }) => {
const mock = createQueueMock(
[],
[{ id: "msg_queue_delivered", type: "user", text: "First prompt", time: { created: 1 } }],
)
const view = await openQueue(page, mock)
const models: unknown[] = []
page.on("request", (request) => {
if (new URL(request.url()).pathname !== `/api/session/${sessionID}/model`) return
mock.log.push("model")
models.push(request.postDataJSON())
})
await expect(userRow(page, "msg_queue_delivered")).toContainText("First prompt")
await runSlash(page, view.input, "compact")
await expect(page.locator('[data-timeline-row="CompactionQueued"]')).toHaveText("Session compaction queued")
// The row is optimistic; the model switch still precedes the admission.
await expect.poll(() => mock.log).toEqual(["model", "compact"])
expect(models).toMatchObject([{ model: { id: "queue-model", providerID: "opencode" } }])
})
for (const action of ["Move to queue", "Delete"] as const) {
test(`${action} on a pending steer replaces Revert, as in the TUI`, async ({ page }) => {
const mock = createQueueMock(["U2: Also check the retry path."])
const inboxID = mock.rows[0].id
mock.rows[0].delivery = "steer"
const view = await openQueue(page, mock)
const pending = userRow(page, inboxID)
await expect(pending).toContainText("U2: Also check the retry path.")
await pending.hover()
await expect(pending.getByRole("button", { name: "Revert message" })).toHaveCount(0)
await pending.getByRole("button", { name: action }).click()
await expect(pending).toHaveCount(0)
expect(mock.changes).toEqual([{ inboxID, action: action === "Delete" ? "cancel" : "queue" }])
// Neither action returns the prompt to the composer; a moved steer waits in the queue.
await expect(view.input).toHaveText("")
await expect(view.rows).toHaveCount(action === "Delete" ? 0 : 1)
if (action === "Move to queue") await expect(view.rows).toContainText("U2: Also check the retry path.")
})
}
test("/undo returns a pending steer's review comment to the composer", async ({ page }) => {
const display = "tighten this"
const comment = "check the guard"
const mock = createQueueMock([
`${display}\nThe user made the following comment regarding line 2 of /repo/app.ts: ${comment}`,
])
const row = mock.rows[0]
row.delivery = "steer"
row.payload.metadata = {
displayText: display,
comments: [
{
path: "/repo/app.ts",
comment,
selection: { startLine: 2, startChar: 0, endLine: 2, endChar: 0 },
origin: "review",
},
],
}
// The comment's context file, which a resubmission regenerates from the restored comment.
row.payload.files = [
{
data: "aGk=",
mime: "text/plain",
source: { type: "uri", uri: "file:///repo/app.ts?start=2&end=2" },
name: "app.ts",
},
test("Undo does not discard hidden file context", async ({ page }) => {
const mock = createQueueMock(["inspect this file"])
mock.rows[0].payload.files = [
{ data: "aGk=", mime: "text/plain", source: { type: "uri", uri: "file:///repo/main.ts" }, name: "main.ts" },
]
const view = await openQueue(page, mock)
const pending = userRow(page, row.id)
await expect(pending).toContainText(display)
await runSlash(page, view.input, "undo")
await expect(pending).toHaveCount(0)
await expect(view.input).toHaveText(display)
// The comment card is the only card: its context file is regenerated from it, not restored twice.
const cards = view.composer.locator('[data-component="composer-attachments"] [data-component="attachment-card"]')
await expect(cards).toHaveCount(1)
await expect(cards).toContainText(comment)
expect(mock.changes).toEqual([{ inboxID: row.id, action: "cancel" }])
await view.rows.getByRole("button", { name: "Undo" }).click()
await expect(page.getByText("Edit this prompt in the queue to preserve its file context")).toBeVisible()
await expect(view.rows).toHaveCount(1)
await expect(view.input).toHaveText("")
expect(mock.changes).toEqual([])
})
for (const delivery of ["steer", "queue"] as const) {
@@ -814,7 +285,6 @@ for (const delivery of ["steer", "queue"] as const) {
const userID = "msg_queue_initial_user"
const assistantID = "msg_queue_continued_assistant"
const followUp = "U2: Also check the retry path."
const mock = createQueueMock(
[],
[
@@ -830,7 +300,6 @@ for (const delivery of ["steer", "queue"] as const) {
},
],
)
const view = await openQueue(page, mock, delivery)
const transcript = page.locator("[data-timeline-virtual-content]")
const thinking = transcript.locator('[data-timeline-row="Thinking"]')
@@ -844,7 +313,6 @@ for (const delivery of ["steer", "queue"] as const) {
const inboxID = mock.rows[0].id
const pending = transcript.locator(`[data-timeline-row="UserMessage"][data-message-id="${inboxID}"]`)
if (delivery === "queue") {
const queued = view.rows.filter({ hasText: followUp })
await expect(queued).toBeVisible()
@@ -854,7 +322,6 @@ for (const delivery of ["steer", "queue"] as const) {
await queued.getByRole("button", { name: "Steer", exact: true }).click()
await expect.poll(() => mock.changes).toEqual([{ inboxID, action: "steer" }])
}
await expect(view.rows).toHaveCount(0)
await expect(pending).toContainText(followUp)
await expect(thinking).toHaveCount(0)
@@ -867,7 +334,6 @@ for (const delivery of ["steer", "queue"] as const) {
model,
started: Date.now(),
})
for (const tool of [
{ id: "tool_queue_read", name: "read", input: { path: "src/queue.ts" } },
{ id: "tool_queue_grep", name: "grep", input: { pattern: "retry", path: "src" } },
@@ -882,7 +348,6 @@ for (const delivery of ["steer", "queue"] as const) {
executed: true,
})
}
mock.emit("session.step.ended", {
sessionID,
assistantMessageID: assistantID,
@@ -912,7 +377,6 @@ for (const delivery of ["steer", "queue"] as const) {
await expect
.poll(async () => {
const boxes = await Promise.all([tools.boundingBox(), pending.boundingBox()])
return boxes.every((box) => box !== null) && boxes[0]!.y + boxes[0]!.height <= boxes[1]!.y
})
.toBe(true)
@@ -931,11 +395,9 @@ for (const delivery of ["steer", "queue"] as const) {
mock.emit("session.step.started", { ...later, agent: "build", model, started: Date.now() })
mock.emit("session.text.started", { ...later, ordinal: 0 })
mock.emit("session.text.ended", { ...later, ordinal: 0, text: "A3: Now checking the retry path for U2." })
const response = transcript
.locator('[data-timeline-row="AssistantPart"]')
.filter({ hasText: "A3: Now checking the retry path for U2." })
await expect(response).toHaveAttribute("data-message-id", inboxID)
await expect(thinking).toHaveCount(0)
await expect(tools.or(pending).or(response)).toHaveText([
@@ -134,27 +134,6 @@ test("a session in a worktree subfolder names its worktree and lists cached work
list.release()
})
test("a subagent shows its details but cannot move to another worktree", async ({ page }) => {
const root = "C:/OpenCode/SmokeWorktrees"
await mockStressTimeline(page, {
sessions: fixture.sessions.map((item) =>
item.id === fixture.targetID
? { ...item, parentID: fixture.sourceID, directory: `${root}/feature` }
: { ...item },
),
worktrees: [{ directory: fixture.directory }, { directory: `${root}/feature`, strategy: "git" }],
})
await page.goto(sessionHref(fixture.targetID))
const summary = page.getByRole("dialog", { name: "Session details", exact: true })
await page.getByRole("button", { name: "Session details", exact: true }).click()
await expect(summary.getByText("feature", { exact: true })).toBeVisible()
await expect(summary.getByRole("button", { name: "feature", exact: true })).toHaveCount(0)
await expect(summary.getByRole("button", { name: "Extensions", exact: true })).toBeVisible()
})
test("the details follow the routed session when it moves to another worktree", async ({ page }) => {
const root = "C:/OpenCode/SmokeWorktrees"
+11 -113
View File
@@ -1,5 +1,5 @@
import { expect, test, type Page } from "@playwright/test"
import type { OpenCodeEvent, SessionMessageInfo } from "@opencode/client/promise"
import type { SessionMessageInfo } from "@opencode/client/promise"
import {
NO_PROVIDER,
REMOTE_SERVER,
@@ -240,111 +240,7 @@ test("a remote tab stays busy while a child session runs", async ({ page }) => {
await expect(tabA.locator('[data-component="session-progress-indicator-v2"]')).toHaveCount(0)
})
test("inactive tabs stay busy while work waits in their inbox, and pulse on a new prompt, as in the TUI", async ({
page,
}) => {
const workspace = await openSession(page, {
name: "TabInbox",
sessions: [a, b, c],
inbox: [
{
id: "inb_tab_b",
sessionID: b.id,
time: { created: 1 },
type: "user",
payload: { text: "Queued follow-up" },
delivery: "queue",
},
// Parked synthetic context, such as a user shell's output, waits without work.
{
id: "inb_tab_c",
sessionID: c.id,
time: { created: 1 },
type: "synthetic",
payload: { text: "Shell output", description: "Shell finished" },
delivery: "steer",
},
],
})
const tab = (id: string) => page.locator(`[data-titlebar-tab-slot]:has(a[href="${sessionHref(id)}"])`)
const progress = '[data-component="session-progress-indicator-v2"]'
await expect(tab(b.id).locator(progress)).toBeVisible()
await expect(tab(c.id).locator("[data-titlebar-tab-title]")).toHaveText(c.title)
await expect(tab(c.id).locator(progress)).toHaveCount(0)
await expect(tab(c.id).locator('[data-slot="tab-prompt-pulse"]')).toHaveCount(0)
// The pulse lasts one animation, so record that it appeared rather than racing its removal.
await tab(c.id).evaluate((element) => {
const observer = new MutationObserver(() => {
if (!element.querySelector('[data-slot="tab-prompt-pulse"]')) return
element.setAttribute("data-test-pulsed", "")
observer.disconnect()
})
observer.observe(element, { childList: true, subtree: true })
})
await workspace.push([
{
id: "evt_tab_c_prompt",
created: 2,
type: "session.inbox.enqueued",
durable: { aggregateID: c.id, seq: 1, version: 1 },
data: {
sessionID: c.id,
inboxID: "inb_tab_c_prompt",
item: { type: "user", payload: { text: "Another client's prompt" }, delivery: "queue" },
},
} satisfies Extract<OpenCodeEvent, { type: "session.inbox.enqueued" }>,
])
await expect(tab(c.id)).toHaveAttribute("data-test-pulsed", "")
await expect(tab(c.id).locator('[data-slot="tab-prompt-pulse"]')).toHaveCount(0)
await expect(tab(c.id).locator(progress)).toBeVisible()
await expect(tab(a.id).locator('[data-slot="tab-prompt-pulse"]')).toHaveCount(0)
})
test("selecting a tab with waiting work waits for its transcript instead of showing only the inbox", async ({
page,
}) => {
await openSession(page, {
name: "TabInboxTranscript",
sessions: [a, b],
pageMessages: (id) => ({
items:
id === b.id ? [{ id: "msg_tab_b_history", type: "user", text: "Earlier prompt", time: { created: 1 } }] : [],
}),
inbox: [
{
id: "inb_tab_b_steer",
sessionID: b.id,
time: { created: 2 },
type: "user",
payload: { text: "Pending steer" },
delivery: "steer",
},
],
})
const tabB = page.locator(`[data-titlebar-tab-slot]:has(a[href="${sessionHref(b.id)}"])`)
// The inactive tab read its inbox, which materializes the pending steer as a transcript row.
await expect(tabB.locator('[data-component="session-progress-indicator-v2"]')).toBeVisible()
const transcript = await holdRoute(page, (url) => url.pathname === `/api/session/${b.id}/message`)
await page.locator(`[data-titlebar-tab-link][href="${sessionHref(b.id)}"]`).click()
await transcript.arrived
await expect(page.locator("[data-timeline-virtual-content]")).toHaveCount(0)
transcript.release()
await expect(page.locator('[data-timeline-row="UserMessage"][data-message-id="msg_tab_b_history"]')).toContainText(
"Earlier prompt",
)
await expect(page.locator('[data-timeline-row="UserMessage"][data-message-id="inb_tab_b_steer"]')).toContainText(
"Pending steer",
)
})
test("inactive tabs load attention and inbox, but read the transcript only on selection", async ({ page }) => {
test("inactive tabs load attention, but read transcript and inbox only on selection", async ({ page }) => {
const reads: string[] = []
const mutations: string[] = []
const errors: string[] = []
@@ -386,7 +282,7 @@ test("inactive tabs load attention and inbox, but read the transcript only on se
const attention = Promise.all(
[fixture.targetID, fixture.childID].flatMap((id) =>
["permission", "form", "inbox"].map((kind) =>
["permission", "form"].map((kind) =>
page.waitForResponse((response) => new URL(response.url()).pathname === `/api/session/${id}/${kind}`),
),
),
@@ -411,14 +307,16 @@ test("inactive tabs load attention and inbox, but read the transcript only on se
state.text,
)
// Every tab reads its inbox once, since waiting work keeps a tab busy; selection reuses that read.
for (const id of [fixture.sourceID, fixture.targetID, fixture.childID])
expect(reads.filter((path) => path === `/api/session/${id}/inbox`)).toHaveLength(1)
for (const id of [fixture.sourceID, fixture.targetID])
for (const id of [fixture.sourceID, fixture.targetID]) {
expect(reads.filter((path) => path === `/api/session/${id}/message`)).toHaveLength(1)
expect(reads.filter((path) => path === `/api/session/${id}/inbox`)).toHaveLength(1)
}
expect(reads.filter((path) => path === `/api/session/${fixture.childID}/message`)).toEqual([])
expect(
reads.filter(
(path) => path === `/api/session/${fixture.childID}/message` || path === `/api/session/${fixture.childID}/inbox`,
),
).toEqual([])
expect(mutations).toEqual([])
expect(errors).toEqual([])
})
@@ -28,9 +28,7 @@ import {
} from "../utils/timeline"
const detailed = timelinePresets[2].value
const user = { id: "msg_user", type: "user", text: "Run it", time: { created: 1 } } satisfies SessionMessageInfo
const completed = {
id: "msg_assistant",
type: "assistant",
@@ -91,34 +89,28 @@ test.describe("static projection", () => {
],
{ summary: { diffs: Array.from({ length: 11 }, (_, index) => summaryDiff(index)) } },
)
const aborted = assistantMessage([{ id: "prt_before_abort", type: "text", text: "Before interruption" }], {
id: "msg_1001_assistant_aborted",
error: { type: "MessageAbortedError", message: "Stopped" },
})
const failed = assistantMessage([{ id: "prt_after_abort", type: "text", text: "After interruption" }], {
id: "msg_1002_assistant_failed",
error: { type: "APIError", message: "Visible provider failure" },
created: 1700000003000,
})
const nextUser = userMessage([userText("Second turn", { id: "prt_second_user" })], {
id: "msg_2000_second_user",
created: 1700000005000,
})
const nextAssistant = assistantMessage([{ id: "prt_second_text", type: "text", text: "Second response" }], {
id: "msg_2001_second_assistant",
parentID: "msg_2000_second_user",
created: 1700000006000,
})
const timeline = await setupTimeline(page, {
settings: { timelineDetail: detailed },
messages: [firstUser, aborted, failed, nextUser, nextAssistant],
})
await timeline.send(status("idle"))
const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
await scroller.evaluate((element) => (element.scrollTop = 0))
@@ -148,7 +140,6 @@ test.describe("static projection", () => {
url: "data:text/plain;base64,bm90ZXM=",
},
])
message.metadata = {
displayText: "what's goin on here",
comments: [
@@ -207,7 +198,6 @@ test.describe("static projection", () => {
toolPart("prt_skill_break", "read", "completed", { path: "src/a.ts" }),
toolPart("prt_skill_last", "skill", "completed", { id: "opencode" }),
]
await setupTimeline(page, {
settings: { timelineDetail: detailed },
messages: [userMessage(), assistantMessage(parts)],
@@ -227,7 +217,6 @@ test.describe("static projection", () => {
toolPart("prt_collapsed_patch", "patch", "completed", { patchText: "Update src/value.ts" }),
toolPart("prt_collapsed_read", "read", "completed", { path: "src/value.ts" }),
]
await setupTimeline(page, {
messages: [userMessage(), assistantMessage(parts)],
settings: { shellToolPartsExpanded: true },
@@ -246,7 +235,6 @@ test.describe("static projection", () => {
test("combines adjacent patch calls and repeated files into one group", async ({ page }) => {
const [first, second] = ["prt_patch_first", "prt_patch_second"]
const timeline = await setupTimeline(page, {
settings: { timelineDetail: { ...detailed, edit: { placement: "separate", details: "collapsed" } } },
messages: [
@@ -270,7 +258,6 @@ test("combines adjacent patch calls and repeated files into one group", async ({
await expect(initialFile.getByRole("button")).toHaveAttribute("aria-expanded", "true")
await initial.evaluate((element) => {
const row = element.closest<HTMLElement>("[data-timeline-key]")
if (row) row.dataset.patchRow = "stable"
})
@@ -305,7 +292,6 @@ test("combines adjacent patch calls and repeated files into one group", async ({
test("keeps a failed patch in Used without losing the surviving file choice", async ({ page }) => {
const failed = "prt_grouped_patch_failed"
const surviving = "prt_grouped_patch_surviving"
const timeline = await setupTimeline(page, {
settings: { timelineDetail: detailed },
messages: [
@@ -335,7 +321,6 @@ test("keeps a failed patch in Used without losing the surviving file choice", as
await expect(file).toHaveAttribute("aria-expanded", "true")
await group.evaluate((element) => {
const row = element.closest<HTMLElement>("[data-timeline-key]")
if (row) row.dataset.groupIdentity = "preserved"
})
@@ -346,11 +331,9 @@ test("keeps a failed patch in Used without losing the surviving file choice", as
)
const failedRow = page.locator("[data-timeline-key]", { has: page.locator(`[data-timeline-part-id="${failed}"]`) })
const survivingRow = page.locator("[data-timeline-key]", {
has: page.locator(`[data-timeline-part-id="${surviving}"]`),
})
await expect(group).toHaveAttribute("data-timeline-part-ids", `${failed},${surviving}`)
await expect(used).toHaveAttribute("aria-expanded", "true")
await expect(failedRow).toHaveAttribute("data-timeline-key", /^assistant-part:context:/)
@@ -368,21 +351,15 @@ test("does not remount an edit diff when a sibling part arrives", async ({ page
const attachShadow = Element.prototype.attachShadow
Element.prototype.attachShadow = function (init) {
count += 1
return attachShadow.call(this, init)
}
// SAFETY: this init script owns `__shadowRoots`; the probe reads below run only after it installed it.
const probe = window as Window & { __shadowRoots?: { reset(): void; count(): number } }
probe.__shadowRoots = {
;(window as Window & { __shadowRoots?: { reset(): void; count(): number } }).__shadowRoots = {
reset: () => {
count = 0
},
count: () => count,
}
})
const timeline = await setupTimeline(page, {
settings: { editToolPartsExpanded: true, shellToolPartsExpanded: true, showReasoningSummaries: true },
messages: [
@@ -424,10 +401,8 @@ test("does not remount an edit diff when a sibling part arrives", async ({ page
),
],
})
const tool = page.locator(`[data-timeline-part-id="${editID}"]`)
await expect(tool.locator('[data-component="file"][data-mode="diff"]')).toBeVisible()
const markers = () =>
tool.evaluate((element) => {
const nodes = [
@@ -435,15 +410,12 @@ test("does not remount an edit diff when a sibling part arrives", async ({ page
element.querySelector('[data-component="file"][data-mode="diff"]'),
element.closest("[data-timeline-key]"),
element.closest("[data-timeline-row]"),
]
] as (HTMLElement | null)[]
return {
markers: nodes.map((node) => (node instanceof HTMLElement ? node.dataset.timelineProbe : undefined)),
// SAFETY: the init script installed `__shadowRoots` before the page loaded.
markers: nodes.map((node) => node?.dataset.timelineProbe),
shadowRoots: (window as Window & { __shadowRoots?: { count(): number } }).__shadowRoots!.count(),
}
})
await tool.evaluate((element) => {
;[
element,
@@ -454,11 +426,7 @@ test("does not remount an edit diff when a sibling part arrives", async ({ page
if (!(node instanceof HTMLElement)) throw new Error("missing edit tool, diff, row, or frame")
node.dataset.timelineProbe = "before"
})
// SAFETY: the init script installed `__shadowRoots` before the page loaded.
const probe = window as Window & { __shadowRoots?: { reset(): void } }
probe.__shadowRoots!.reset()
;(window as Window & { __shadowRoots?: { reset(): void } }).__shadowRoots!.reset()
})
await timeline.send(partUpdated(textPart("prt_sibling_text", "Streaming added a later assistant text part.")))
@@ -470,12 +438,10 @@ for (const transition of ["reasoning-end", "idle", "retry"] as const) {
test(`stops active Thinking on ${transition} without a following tool`, async ({ page }) => {
const id = `prt_reasoning_stop_${transition}`
const text = "## Inspecting stability\n\nThe timeline is ready for the next step."
const timeline = await setupTimeline(page, {
messages: [userMessage(), assistantMessage([reasoningPart(id, text)], { completed: false })],
settings: { timelineDetail: { ...detailed, thinking: { placement: "separate", details: "collapsed" } } },
})
const part = page.locator(`[data-timeline-part-id="${renderedPartID(id)}"]`)
const trigger = part.locator('[data-slot="collapsible-trigger"]')
await expect(page.locator('[data-timeline-row="Thinking"]')).toBeVisible()
@@ -499,7 +465,6 @@ for (const shellDefault of ["collapsed", "expanded"] as const) {
const shellID = `prt_separate_shell_${shellDefault}`
const output = (count: number) => Array.from({ length: count }, (_, index) => `line ${index + 1}`).join("\n")
const assistant = assistantMessage([reasoningPart(reasoningID, "## Inspecting stability")], { completed: false })
const timeline = await setupTimeline(page, {
messages: [userMessage(), assistant],
settings: {
@@ -510,11 +475,9 @@ for (const shellDefault of ["collapsed", "expanded"] as const) {
},
},
})
const thought = page.locator(
`[data-timeline-part-id="${renderedPartID(reasoningID)}"] [data-slot="collapsible-trigger"]`,
)
const shellTrigger = page.locator(`[data-timeline-part-id="${shellID}"] [data-slot="collapsible-trigger"]`)
const group = page.locator('[data-component="collapsed-tool-group"]')
// One row opens the thought, the other opens and then closes it again.
@@ -522,7 +485,6 @@ for (const shellDefault of ["collapsed", "expanded"] as const) {
await expect(page.locator('[data-timeline-row="Thinking"]')).toBeVisible()
await expect(thought).toHaveAttribute("aria-expanded", "false")
await thought.click()
if (!thoughtOpen) await thought.click()
await expect(thought).toHaveAttribute("aria-expanded", String(thoughtOpen))
@@ -556,7 +518,6 @@ test.describe("Working", () => {
viewport: { width: 390, height: 900 },
settings: { timelineDetail: { ...detailed, thinking: { placement: "separate", details: "collapsed" } } },
})
const working = page.locator('[data-component="session-working"]')
const shimmer = working.locator('[data-component="text-shimmer"]')
await expect(working).toHaveCount(1)
@@ -590,7 +551,6 @@ test.describe("Working", () => {
],
settings: { timelineDetail: detailed },
})
const working = page.locator('[data-component="session-working"]')
const group = page.locator('[data-component="collapsed-tool-group"]')
await expect(working).toBeVisible()
@@ -611,20 +571,16 @@ test.describe("Working", () => {
timelineDetail: { ...timelinePresets[0].value, shell: { placement: "separate", details: "collapsed" } },
},
})
const working = page.locator('[data-component="session-working"]')
await expect(working).toBeVisible()
const id = `prt_working_${name}`
const input = {
shell: { command: "printf ready" },
patch: {
patchText: "*** Begin Patch\n*** Add File: src/working.ts\n+export const ready = true\n*** End Patch",
},
subagent: { agent: "general", description: "Inspect working indicator", prompt: "Inspect the timeline." },
}[name]
const input =
name === "shell"
? { command: "printf ready" }
: name === "patch"
? { patchText: "*** Begin Patch\n*** Add File: src/working.ts\n+export const ready = true\n*** End Patch" }
: { agent: "general", description: "Inspect working indicator", prompt: "Inspect the timeline." }
await timeline.send(partUpdated(toolPart(id, name, "streaming", input)))
const tool = page.locator(`[data-timeline-part-id="${id}"]`)
await expect(tool).toBeVisible()
@@ -634,10 +590,9 @@ test.describe("Working", () => {
name === "patch"
? { files: [{ ...patchFile("src/working.ts", "added"), patch: "@@ -0,0 +1 @@\n+export const ready = true" }] }
: {}
await timeline.send(partUpdated(toolPart(id, name, "running", input, { metadata })))
await expect(tool).toContainText(
{ shell: "printf ready", patch: "working.ts", subagent: "Inspect working indicator" }[name],
name === "shell" ? "printf ready" : name === "patch" ? "working.ts" : "Inspect working indicator",
)
await expect(working).toHaveCount(0)
@@ -650,43 +605,6 @@ test.describe("Working", () => {
})
}
test("keeps Working while a new read joins a standalone read group", async ({ page }) => {
const timeline = await setupTimeline(page, {
messages: [
userMessage(),
assistantMessage(
["one", "two", "three"].map((name) =>
toolPart(`prt_read_${name}`, "read", "completed", { path: `src/${name}.ts` }),
),
{ completed: false },
),
],
settings: { timelineDetail: timelinePresets[0].value },
})
const working = page.locator('[data-component="session-working"]')
const group = page.locator('[data-component="read-tool-group"]')
const shimmer = group.locator('[data-component="text-shimmer"]')
await expect(group).toHaveAttribute("data-timeline-part-ids", "prt_read_one,prt_read_two,prt_read_three")
await expect(working).toBeVisible()
await timeline.send(partUpdated(toolPart("prt_read_four", "read", "streaming", { path: "src/four.ts" })))
await expect(group).toHaveAttribute(
"data-timeline-part-ids",
"prt_read_one,prt_read_two,prt_read_three,prt_read_four",
)
await expect(shimmer).toHaveAttribute("data-active", "true")
await expect(working).toBeVisible()
await timeline.send(partUpdated(toolPart("prt_read_four", "read", "running", { path: "src/four.ts" })))
await expect(group.locator('[data-slot="basic-tool-tool-subtitle"]')).toHaveText("one.ts, two.ts, three.ts, four.ts")
await expect(working).toBeVisible()
await timeline.send(partUpdated(toolPart("prt_read_four", "read", "completed", { path: "src/four.ts" })))
await expect(shimmer).toHaveAttribute("data-active", "false")
await expect(working).toBeVisible()
})
for (const grouped of [false, true]) {
test(`uses ${grouped ? "grouped" : "standalone"} background shell presentation`, async ({ page }) => {
await setupTimeline(page, {
@@ -709,18 +627,14 @@ test.describe("Working", () => {
],
})
const working = page.locator('[data-component="session-working"]')
if (!grouped) {
await expect(page.locator('[data-timeline-part-id="prt_background_active"]')).toBeVisible()
await expect(working).toHaveCount(0)
return
}
const trigger = page
.locator('[data-component="collapsed-tool-group"]')
.getByRole("button", { name: "Used 2 Shell", exact: true, includeHidden: true })
await expect(trigger).toHaveAttribute("aria-expanded", "false")
await expect(working).toBeVisible()
await trigger.click()
@@ -752,7 +666,6 @@ test.describe("Working", () => {
const timeline = await setupTimeline(page, {
messages: [userMessage(), assistantMessage([], { completed: false })],
})
const working = page.locator('[data-component="session-working"]')
await expect(working).toBeVisible()
@@ -795,9 +708,7 @@ test.describe("background shortcut", () => {
await expect
.poll(async () => {
const [cardBox, hintBox] = await Promise.all([card.boundingBox(), hint.boundingBox()])
if (!cardBox || !hintBox) return undefined
return { aligned: Math.abs(cardBox.x - hintBox.x) < 2, ordered: cardBox.y < hintBox.y }
})
.toEqual({ aligned: true, ordered: true })
@@ -818,11 +729,11 @@ test.describe("background shortcut", () => {
"prt_grouped_active",
name,
"running",
{
read: { filePath: "src/working.ts" },
shell: { command: "sleep 10" },
subagent: { agent: "general", description: "Inspect the timeline", prompt: "Inspect it." },
}[name],
name === "shell"
? { command: "sleep 10" }
: name === "subagent"
? { agent: "general", description: "Inspect the timeline", prompt: "Inspect it." }
: { filePath: "src/working.ts" },
),
],
{ completed: false },
@@ -834,24 +745,19 @@ test.describe("background shortcut", () => {
const trigger = group.locator(':scope > [data-component="collapsible"] > [data-slot="collapsible-trigger"]')
await expect(trigger).toHaveAttribute("aria-expanded", "false")
await expect(working).toBeInViewport()
if (name !== "read") {
await expect(backgroundHint(page)).toBeInViewport()
await expect(page.locator('[data-component="session-background-hint-row"]')).toHaveCSS("height", "24px")
}
await trigger.click()
await expect(trigger).toHaveAttribute("aria-expanded", "true")
if (name === "subagent") await expect(group.getByText("Inspect the timeline", { exact: true })).toBeVisible()
if (name !== "subagent")
await expect(group.locator('[data-component="text-shimmer"][data-active="true"]')).toBeVisible()
await expect(working).toBeVisible()
await trigger.click()
await expect(trigger).toHaveAttribute("aria-expanded", "false")
await expect(working).toBeVisible()
if (name !== "read") await expectBackgroundRequest(page)
})
}
@@ -859,7 +765,6 @@ test.describe("background shortcut", () => {
test("separates blocking and already-backgrounded work into two rows", async ({ page }) => {
const backgroundID = "ses_background_existing"
const blockingID = "ses_background_blocking"
const timeline = await setupTimeline(page, {
settings: { timelineDetail: detailed },
sessionMessages: [
@@ -876,7 +781,7 @@ test.describe("background shortcut", () => {
name: "subagent",
state: {
status: "completed",
input: { description: "Background task", agent: "explore" },
input: { description: "Background task" },
content: [{ type: "text", text: "working" }],
metadata: { sessionID: backgroundID, status: "running" },
},
@@ -909,7 +814,7 @@ test.describe("background shortcut", () => {
name: "subagent",
state: {
status: "running",
input: { description: "Foreground task", agent: "build" },
input: { description: "Foreground task" },
metadata: { sessionID: blockingID },
},
time: { created: 4 },
@@ -920,8 +825,8 @@ test.describe("background shortcut", () => {
],
sessions: [
session(),
session({ id: backgroundID, parentID: sessionID, title: "Background task", agent: "explore" }),
session({ id: blockingID, parentID: sessionID, title: "Foreground task", agent: "build" }),
session({ id: backgroundID, parentID: sessionID, title: "Background task" }),
session({ id: blockingID, parentID: sessionID, title: "Foreground task" }),
],
sessionStatus: {
[sessionID]: { type: "busy" },
@@ -950,58 +855,27 @@ test.describe("background shortcut", () => {
})
const backgroundCard = page.locator('[data-timeline-part-id="call_backgrounded"]')
await expect(backgroundHint(page)).toBeVisible()
const used = page
.locator('[data-timeline-part-ids="call_backgrounded,call_shell_backgrounded,call_blocking"]')
.locator(':scope > [data-component="collapsible"] > [data-slot="collapsible-trigger"]')
await expect(used).toHaveText(/^Used\s*3\s*Agent, Shell$/)
await expect(used).toHaveAttribute("aria-expanded", "false")
await page.getByRole("button", { name: "3 running", exact: true }).click()
const list = page.getByRole("menu", { name: "3 running", exact: true })
await expect(list.getByRole("menuitem")).toHaveText([
/^Build\s*Foreground task$/,
/^Explore\s*Background task$/,
/^Shell\s*sleep 120$/,
])
await list.getByRole("menuitem", { name: /sleep 120/ }).click()
await expect(list).toHaveCount(0)
await used.click()
await expect(used).toHaveAttribute("aria-expanded", "true")
const shellCall = page.locator('[data-timeline-part-id="call_shell_backgrounded"]')
await expect(shellCall.locator('[data-slot="collapsible-trigger"]')).toHaveAttribute("aria-expanded", "true")
await expect(shellCall).toBeInViewport()
await page.getByRole("button", { name: "3 running", exact: true }).click()
const interrupt = page.waitForRequest(
(request) =>
request.method() === "POST" && new URL(request.url()).pathname === `/api/session/${blockingID}/interrupt`,
)
const foreground = list.getByRole("menuitem", { name: /Foreground task/ })
await foreground.hover()
await foreground.getByRole("button", { name: "Interrupt subagent", exact: true }).click()
await interrupt
await expect(list).toBeVisible()
const kill = page.waitForRequest(
(request) => request.method() === "DELETE" && new URL(request.url()).pathname === "/api/shell/shell_backgrounded",
)
await list.getByRole("menuitem", { name: /sleep 120/ }).hover()
await page.keyboard.press("Delete")
await kill
await page.keyboard.press("Escape")
await expect(list).toHaveCount(0)
await page.getByRole("button", { name: "Session details" }).click()
const summary = page.getByRole("button", { name: "2 background tasks running", exact: true })
await expect(summary).toContainText("2")
await summary.click()
const list = page.locator('[data-component="session-background-list"]')
await expect(list).toContainText("Background task")
await expect(list).toContainText("sleep 120")
await expect(list).not.toContainText("Foreground task")
await expect(backgroundCard).toContainText("Background task (background)")
await expect(backgroundCard.locator('[data-component="session-progress-indicator-v2"]')).toBeVisible()
await expect(
page.locator('[data-timeline-part-id="call_shell_backgrounded"] [data-component="text-shimmer"]'),
).toHaveAttribute("data-active", "true")
// SAFETY: the transport forwards this payload unchanged, and the client marks a session idle from
// `type` and `data.sessionID` alone; the child session's event needs no durable metadata here.
await timeline.transport.send({
id: "evt_background_succeeded",
created: Date.now(),
@@ -1011,116 +885,6 @@ test.describe("background shortcut", () => {
await expect(backgroundCard.locator('[data-component="session-progress-indicator-v2"]')).toHaveCount(0)
await expect(backgroundCard).toContainText("Background task (background)")
})
test("moves between siblings and to the parent's shell from inside a subagent", async ({ page }) => {
const siblings = [
{ id: "ses_sibling_one", agent: "explore", description: "Draft TUI proposal" },
{ id: "ses_sibling_two", agent: "build", description: "Fix context controls" },
]
const timeline = await setupTimeline(page, {
settings: { timelineDetail: detailed },
sessionMessages: [
user,
{
id: "msg_sibling_shell",
type: "assistant",
agent: "build",
model: { id: "model", providerID: "provider" },
content: [
{
type: "tool",
id: "call_sibling_shell",
name: "shell",
state: {
status: "completed",
input: { command: "sleep 120" },
content: [{ type: "text", text: "working" }],
metadata: { shellID: "shell_sibling", status: "running" },
},
time: { created: 2, completed: 3 },
},
],
time: { created: 2, completed: 3 },
},
{
id: "msg_siblings",
type: "assistant",
agent: "build",
model: { id: "model", providerID: "provider" },
content: siblings.map((item) => ({
type: "tool" as const,
id: `call_${item.id}`,
name: "subagent",
state: {
status: "running" as const,
input: { description: item.description, agent: item.agent },
metadata: { sessionID: item.id },
},
time: { created: 2 },
})),
time: { created: 2 },
},
],
sessions: [
session(),
...siblings.map((item) =>
session({ id: item.id, parentID: sessionID, title: item.description, agent: item.agent }),
),
],
sessionStatus: Object.fromEntries(
[sessionID, ...siblings.map((item) => item.id)].map((id) => [id, { type: "busy" }]),
),
})
await timeline.transport.send({
id: "evt_sibling_shell_created",
created: 3,
type: "shell.created",
location: { directory },
data: {
info: {
id: "shell_sibling",
status: "running",
command: "sleep 120",
cwd: directory,
shell: "bash",
file: "/tmp/sibling.out",
metadata: { sessionID },
time: { started: 2 },
},
},
})
const header = page.locator("[data-session-title]")
const trigger = header.getByRole("button", { name: "3 running", exact: true })
const list = page.getByRole("menu", { name: "3 running", exact: true })
await trigger.click()
await list.getByRole("menuitem", { name: /Draft TUI proposal/ }).click()
await expect(page).toHaveURL(/\/session\/ses_sibling_one$/)
await trigger.click()
await expect(list.getByRole("menuitem")).toHaveText([
/^Explore\s*Draft TUI proposal/,
/^Build\s*Fix context controls/,
/^Shell\s*sleep 120/,
])
await expect(list.getByRole("menuitem", { name: /Draft TUI proposal/ })).toHaveAttribute("aria-current", "page")
await expect(list.getByRole("menuitem", { name: /Fix context controls/ })).not.toHaveAttribute("aria-current")
await list.getByRole("menuitem", { name: /Fix context controls/ }).click()
await expect(page).toHaveURL(/\/session\/ses_sibling_two$/)
// The shell call lives in the parent: the row opens it there, expanded.
await trigger.click()
await list.getByRole("menuitem", { name: /sleep 120/ }).click()
await expect(page).toHaveURL(new RegExp(`/session/${sessionID}$`))
const shellCall = page.locator('[data-timeline-part-id="call_sibling_shell"]')
await expect(shellCall.locator('[data-slot="collapsible-trigger"]')).toHaveAttribute("aria-expanded", "true")
await expect(shellCall).toBeInViewport()
})
})
test.describe("compaction", () => {
@@ -1152,7 +916,6 @@ test.describe("compaction", () => {
.poll(async () => {
const summary = await compaction.locator('[data-component="text-part"]').boundingBox()
const status = await running.boundingBox()
return !!summary && !!status && status.y >= summary.y + summary.height
})
.toBe(true)
@@ -1174,7 +937,6 @@ test.describe("compaction", () => {
.poll(async () => {
const summary = await compaction.locator('[data-component="text-part"]').boundingBox()
const done = await compaction.getByText("Session compacted", { exact: true }).boundingBox()
return !!summary && !!done && done.y >= summary.y + summary.height
})
.toBe(true)
@@ -1209,21 +971,16 @@ test.describe("compaction", () => {
shown: undefined,
},
} as const
const labels = Object.values(outcomes).map((outcome) => outcome.label)
// Failed then cancelled share one history, so each boundary must keep its own outcome.
for (const names of [["failed", "cancelled"], ["interrupted"]] as const) {
test(`ends running compactions as ${names.join(", then ")}`, async ({ page }) => {
const timeline = await setupTimeline(
page,
names[0] === "interrupted"
? { sessionMessages: [user, completed], sessionStatus: { [sessionID]: { type: "busy" } } }
: { sessionMessages: [user, completed] },
)
const timeline = await setupTimeline(page, {
sessionMessages: [user, completed],
...(names[0] === "interrupted" ? { sessionStatus: { [sessionID]: { type: "busy" as const } } } : {}),
})
const compactions = page.locator('[data-component="session-compaction-message"]')
for (const [index, name] of names.entries()) {
const outcome = outcomes[name]
await timeline.send(compactionStarted({ sessionID, reason: outcome.reason, recent: "" }))
@@ -1231,45 +988,36 @@ test.describe("compaction", () => {
await expect(compactions).toHaveCount(index + 1)
const compaction = compactions.nth(index)
await expect(compaction).toContainText(outcome.partial)
if (name === "interrupted") {
await expect(compaction.getByRole("status").getByLabel("Compacting", { exact: true })).toBeVisible()
const request = page.waitForRequest(
(request) =>
request.method() === "POST" && new URL(request.url()).pathname === `/api/session/${sessionID}/interrupt`,
)
await page.getByRole("button", { name: "Stop", exact: true }).click()
await request
}
await timeline.send(compactionFailed({ sessionID, reason: outcome.reason, error: outcome.error }))
await expect(compaction.getByText(outcome.label, { exact: true })).toBeVisible()
}
await expect(compactions).toHaveCount(names.length)
for (const [index, name] of names.entries()) {
const outcome = outcomes[name]
const compaction = compactions.nth(index)
await expect(compaction.getByText("Session compaction started", { exact: true })).toBeVisible()
await expect(compaction.getByText(outcome.label, { exact: true })).toBeVisible()
for (const other of labels.filter((label) => label !== outcome.label))
await expect(compaction.getByText(other, { exact: true })).toHaveCount(0)
await expect(compaction.getByText("Session compacted", { exact: true })).toHaveCount(0)
await expect(compaction.getByRole("status")).toHaveCount(0)
await expect(compaction).not.toContainText(outcome.partial)
if (outcome.shown) await expect(compaction.getByText(outcome.shown, { exact: true })).toBeVisible()
if (!outcome.shown) await expect(compaction).not.toContainText(outcome.error.message)
}
})
}
})
test("reducer-hardening: converges when idle arrives before final part and message completion", async ({ page }) => {
const textID = "prt_event_order_text"
const assistant = assistantMessage([textPart(textID, "Partial")], { completed: false })
@@ -1300,13 +1048,11 @@ test("changes timeline presets and saves custom thinking details", async ({ page
const slider = settings.getByRole("slider", { name: "Timeline detail", exact: true })
await expect(slider).toBeEnabled()
await slider.press("Home")
for (const [index, name] of ["Messages only", "Quiet", "Compact", "Detailed", "Everything"].entries()) {
if (index) await slider.press("ArrowRight")
await expect(slider).toHaveValue(String(index))
await expect(slider).toHaveAttribute("aria-valuetext", name)
}
await slider.press("End")
await settings.getByRole("button", { name: "Advanced", exact: true }).click()
const grouped = settings.getByRole("switch", { name: "Thinking grouped", exact: true })
@@ -1386,7 +1132,6 @@ test.describe("shell completion", () => {
],
time: { created: 2, completed: 3 },
}
const state = { finished: false, requests: 0 }
// Larger than one server page (65,536 bytes), so the final output is read in two pages. The card shows its most
// recent 64 KiB.
@@ -1396,11 +1141,9 @@ test.describe("shell completion", () => {
const reads: number[] = []
page.on("request", (request) => {
const url = new URL(request.url())
if (url.pathname === `/api/shell/${background.id}/output`)
reads.push(Number(url.searchParams.get("cursor") ?? 0))
})
const timeline = await setupTimeline(page, {
viewport: { width: grouped ? 390 : 1400, height: 900 },
settings: { shellToolPartsExpanded: !grouped },
@@ -1413,19 +1156,15 @@ test.describe("shell completion", () => {
shellOutput: ({ id }) => {
if (id !== background.id) return "Checking project\n"
state.requests++
return state.finished ? finished : "Checking project\n"
},
})
await page.clock.install()
await page.reload()
await timeline.transport.waitForConnection()
const groupTrigger = page
.locator('[data-component="collapsed-tool-group"]')
.locator(':scope > [data-component="collapsible"] > [data-slot="collapsible-trigger"]')
if (grouped) {
await expect(page.locator('[data-component="collapsed-tool-group"]')).toHaveAttribute(
"data-timeline-part-ids",
@@ -1434,13 +1173,11 @@ test.describe("shell completion", () => {
await expect(groupTrigger).toHaveAttribute("aria-expanded", "false")
await groupTrigger.click()
}
const card = page.locator(`[data-timeline-part-id="call_${background.id}"]`)
const shimmer = card.locator('[data-component="text-shimmer"]')
const other = page.locator('[data-timeline-part-id="call_sh_other"] [data-component="text-shimmer"]')
await expect(shimmer).toHaveAttribute("data-active", "true")
await expect(other).toHaveAttribute("data-active", "true")
if (grouped) await card.locator('[data-slot="collapsible-trigger"]').click()
await expect(card.locator('[data-slot="bash-result"]')).toHaveText("Checking project")
@@ -1465,11 +1202,9 @@ test.describe("shell completion", () => {
reads.length = 0
await page.reload()
if (grouped) await groupTrigger.click()
await expect(shimmer).toHaveAttribute("data-active", "false")
await expect(other).toHaveAttribute("data-active", "true")
// An expanded card reads the exited shell's final output from the start, again in two pages.
if (grouped) return
await expect(card.locator('[data-slot="bash-result"]')).toHaveText(tail)
@@ -1505,7 +1240,6 @@ test.describe("shell completion", () => {
],
shellOutput: ({ id }) => (id === background.id ? "Checking project\n" : undefined),
})
const card = page.locator('[data-timeline-part-id="call_foreground"]')
const shimmer = card.locator('[data-component="text-shimmer"]')
await expect(shimmer).toHaveAttribute("data-active", "true")
@@ -1556,7 +1290,6 @@ async function expectBackgroundRequest(page: Page) {
(request) =>
request.method() === "POST" && new URL(request.url()).pathname === `/api/session/${sessionID}/background`,
)
await page.keyboard.press("Control+b")
await request
}
@@ -1565,11 +1298,12 @@ function patchFile(file: string, status: "added" | "modified" | "deleted") {
return {
file,
status,
patch: {
added: "@@ -0,0 +1 @@\n+export const after = true",
deleted: "@@ -1 +0,0 @@\n-export const before = true",
modified: "@@ -1 +1 @@\n-export const before = true\n+export const after = true",
}[status],
patch:
status === "added"
? "@@ -0,0 +1 @@\n+export const after = true"
: status === "deleted"
? "@@ -1 +0,0 @@\n-export const before = true"
: "@@ -1 +1 @@\n-export const before = true\n+export const after = true",
additions: status === "deleted" ? 0 : 1,
deletions: status === "added" ? 0 : 1,
}
-13
View File
@@ -219,12 +219,6 @@ const Group = HttpApiGroup.make("mock")
error: MockShellNotFound.pipe(HttpApiSchema.status(404)),
}),
)
.add(
HttpApiEndpoint.delete("shellRemove", "/api/shell/:id", {
params: { id: Schema.String },
success: NoContent,
}),
)
.add(
HttpApiEndpoint.get("ptyList", "/api/pty", {
success: Json,
@@ -333,13 +327,6 @@ const Group = HttpApiGroup.make("mock")
success: Json,
}),
)
.add(
HttpApiEndpoint.post("sessionCompact", "/api/session/:sessionID/compact", {
params: SessionParams,
payload: JsonPayload,
success: Json,
}),
)
.add(
HttpApiEndpoint.post("sessionCommand", "/api/session/:sessionID/command", {
params: SessionParams,
+1 -26
View File
@@ -125,7 +125,6 @@ export interface MockServerConfig {
sessionStatus?: Resolvable<Record<string, { type: string }>>
inbox?: unknown[] | (() => unknown[])
onPrompt?: (input: { sessionID: string; body: Schema.JsonObject }) => void
onCompact?: (input: { sessionID: string; body: Schema.JsonObject }) => void
generate?: (input: { sessionID: string; prompt: string }) => { text: string } | Promise<{ text: string }>
onInboxChange?: (input: { sessionID: string; inboxID: string; action: "cancel" | "steer" | "queue" }) => void
// Serves `/api/pty*` and mock PTY WebSockets. Created IDs are the first unused `${prefix}<n>` (prefix must start with "pty").
@@ -1033,8 +1032,6 @@ function mockHandlers(
},
})
}),
// Like the server, which also answers 204 for a shell that already ended.
shellRemove: () => noContent,
ptyList: (ctx) =>
ptyEnabled.pipe(
Effect.map(() => {
@@ -1158,12 +1155,7 @@ function mockHandlers(
sessionFormReply: () => noContent,
sessionFormCancel: () => noContent,
sessionBackground: () => noContent,
sessionInbox: (ctx) =>
Effect.sync(() => ({
data: resolve(config.inbox ?? []).filter(
(item) => Predicate.isObject(item) && item.sessionID === ctx.params.sessionID,
),
})),
sessionInbox: () => Effect.sync(() => ({ data: resolve(config.inbox ?? []) })),
sessionPrompt: (ctx) =>
Effect.sync(() => {
const body = Option.getOrElse(decodeJsonObject(ctx.payload), () => ({}))
@@ -1182,23 +1174,6 @@ function mockHandlers(
},
}
}),
// Like the server, a compaction is admitted as a steered inbox item under the proposed ID.
sessionCompact: (ctx) =>
Effect.sync(() => {
const body = Option.getOrElse(decodeJsonObject(ctx.payload), (): Schema.JsonObject => ({}))
config.onCompact?.({ sessionID: ctx.params.sessionID, body })
return {
data: {
id: Predicate.isString(body.id) ? body.id : `inb_mock_${Date.now()}`,
sessionID: ctx.params.sessionID,
time: { created: Date.now() },
type: "compaction",
payload: {},
delivery: "steer",
},
}
}),
sessionGenerate: (ctx) =>
Effect.promise(async () => ({
data: (await config.generate?.({ sessionID: ctx.params.sessionID, prompt: ctx.payload.prompt })) ?? {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/app",
"version": "2.0.23",
"version": "2.0.22",
"description": "",
"type": "module",
"exports": {
+1 -43
View File
@@ -29,7 +29,6 @@ import { ProgressCircle } from "@opencode/ui/progress-circle"
import type { Upload } from "../attachments/uploads"
import { CommentCard } from "@opencode/session-ui/comment-card"
import { typeLabel } from "@opencode/session-ui/message-file"
import { getFilename } from "@opencode/util/path"
import { Skill } from "@opencode/schema/skill"
import type {
ComposerAgentPart,
@@ -197,7 +196,6 @@ export function ComposerEditor(props: ComposerEditorProps) {
attachments={props.controller.attachments()}
uploads={props.controller.uploads()}
comments={props.controller.comments()}
files={props.controller.files()}
activeCommentID={state.activeContextID}
removeLabel={i18n.t("ui.promptInput.removeAttachment")}
onAttachmentClick={props.controller.openAttachment}
@@ -205,7 +203,6 @@ export function ComposerEditor(props: ComposerEditorProps) {
onUploadCancel={(upload) => props.controller.cancelUpload(upload.id)}
onCommentClick={(comment) => props.controller.toggleContext(comment.key)}
onCommentRemove={(comment) => props.controller.removeContext(comment.key)}
onFileRemove={(file) => props.controller.removeContext(file.key)}
/>
</Show>
@@ -571,8 +568,6 @@ export function ComposerAttachments(props: {
attachments: ComposerAttachment[]
uploads?: Upload[]
comments?: ComposerComment[]
// Files attached without a mention, such as one restored from a prompt another client sent.
files?: Extract<ComposerComment, { type: "file" }>[]
activeCommentID?: string
removeLabel: string
onAttachmentClick?: (attachment: ComposerAttachment) => void
@@ -580,20 +575,12 @@ export function ComposerAttachments(props: {
onUploadCancel?: (upload: Upload) => void
onCommentClick?: (comment: ComposerComment) => void
onCommentRemove?: (comment: ComposerComment) => void
onFileRemove?: (file: Extract<ComposerComment, { type: "file" }>) => void
}) {
const i18n = useI18n()
const percent = (upload: Upload) => (upload.size === 0 ? 100 : Math.floor((upload.loaded / upload.size) * 100))
return (
<Show
when={
props.attachments.length > 0 ||
(props.uploads?.length ?? 0) > 0 ||
(props.comments?.length ?? 0) > 0 ||
(props.files?.length ?? 0) > 0
}
>
<Show when={props.attachments.length > 0 || (props.uploads?.length ?? 0) > 0 || (props.comments?.length ?? 0) > 0}>
<div data-component="composer-attachments" data-slot="composer-attachments" class="relative">
<div
data-slot="composer-attachments-scroll"
@@ -630,35 +617,6 @@ export function ComposerAttachments(props: {
</div>
)}
</For>
<For each={props.files ?? []}>
{(file) => (
<div class="relative group shrink-0" data-slot="composer-context-file">
<Tooltip value={file.path} placement="top" contentClass="break-all">
<AttachmentCard title={file.name ?? getFilename(file.path)} surface="base">
<FileIcon node={{ path: file.path, type: "file" }} />
<span>
{typeLabel(file.name ?? file.path, "text/plain", i18n.t("ui.common.file"))}
<Show when={file.selection}>
{(selection) =>
selection().startLine === selection().endLine
? `:${selection().startLine}`
: `:${selection().startLine}-${selection().endLine}`
}
</Show>
</span>
</AttachmentCard>
</Tooltip>
<button
type="button"
onClick={() => props.onFileRemove?.(file)}
class="absolute -top-1 -end-1 size-4 rounded-full bg-v2-icon-icon-muted outline-solid outline-1 outline-v2-icon-icon-contrast flex items-center justify-center hover-reveal group-hover:opacity-100"
aria-label={props.removeLabel}
>
<Icon name="outline-xmark" class="text-v2-icon-icon-contrast" />
</button>
</div>
)}
</For>
<For each={props.attachments}>
{(attachment) => (
<div class="relative group shrink-0">
+5 -102
View File
@@ -1,4 +1,3 @@
import { Predicate } from "effect"
import { createEffect, type Accessor } from "solid-js"
import { createStore, reconcile } from "solid-js/store"
import { useFilteredList } from "@opencode/ui/hooks"
@@ -75,21 +74,15 @@ export function createComposerEditor(input: {
let fileInput: HTMLInputElement | undefined
const draft = createComposerEditorActions(input.store)
const [state, setState] = input.state ?? createComposerEditorState(draft.state.mode)
function addPart(part: ComposerPersistedState["prompt"][number]) {
if (isAttachment(part)) return false
if (part.type === "file" || part.type === "agent") {
draft.addMention(part)
return true
}
draft.addText(part.content)
return true
}
const attachments = input.attachments
? createComposerAttachments({
...input.attachments,
@@ -104,30 +97,24 @@ export function createComposerEditor(input: {
setDraggingType: (type) => dispatch({ type: type ? "drag.enter" : "drag.leave" }),
})
: undefined
const attach = () => {
if (!attachments) {
input.view.add?.onAttach()
return
}
// The add menu leaves focus on its trigger, so return it to the editor once files are picked.
attachments.pick(
() => fileInput?.click(),
() => restoreFocus(),
)
}
const contextList = useFilteredList<ComposerSuggestion>({
items: async (query) => {
const fixed = input.context().filter((item) => item.kind !== "file")
const recent = input.context().filter((item) => item.kind === "file" && item.recent)
if (!query.trim()) return [...fixed, ...recent]
const seen = new Set(recent.map((item) => item.id))
const files = (await input.searchContextFiles(query)).filter((item) => !seen.has(item.id))
return [...fixed, ...recent, ...files]
},
key: (item) => item.id,
@@ -135,66 +122,47 @@ export function createComposerEditor(input: {
skipFilter: (item) => item.kind === "file" && !item.recent,
groupBy: (item) => {
if (item.kind === "reference") return "reference"
if (item.kind === "skill") return "skill"
if (item.kind === "agent") return "agent"
if (item.kind === "resource") return "resource"
if (item.recent) return "recent"
return "file"
},
sortGroupsBy: (a, b) => {
const order = ["reference", "skill", "agent", "resource", "recent", "file"]
return order.indexOf(a.category) - order.indexOf(b.category)
},
})
const commandList = useFilteredList<ComposerSuggestion>({
items: () => input.commands(),
key: (item) => item.id,
filterKeys: ["trigger", "title"],
})
const list = () => (state.popover.type === "context" ? contextList : commandList)
const suggestions = () => list().flat()
const execute = (command: ComposerInteractionCommand) => {
if (command.type === "draft.setText") {
draft.setText(command.value)
return
}
if (command.type === "draft.addText") {
draft.addText(command.value)
return
}
if (command.type === "mention.add") {
if (command.item.mention) draft.addMention(command.item.mention, command.range)
return
}
if (command.type === "popover.filter") {
;(command.popover === "command" ? commandList : contextList).onInput(command.query)
return
}
if (command.type === "suggestion.select") {
const item = suggestions().find((entry) => entry.id === command.id)
if (item) dispatch({ type: "popover.select", item })
return
}
if (command.type === "focus.editor") requestAnimationFrame(() => editor?.focus())
}
@@ -202,32 +170,26 @@ export function createComposerEditor(input: {
const mode = state.mode
const result = transitionComposer(state, event, draft.state)
const action = event.type === "popover.select" ? input.onSuggestionSelect?.(event.item) : undefined
if (event.type === "popover.select") {
if (!action || state.popover.type !== "command-menu") result.commands.forEach(execute)
if (action && event.item.kind === "command" && state.popover.type !== "command-menu") {
draft.setPrompt(draft.state.prompt.filter(isAttachment), 0)
draft.setPrompt(
draft.state.prompt.filter(isAttachment),
0,
)
}
}
setState(reconcile(result.state))
if (mode !== result.state.mode) draft.setMode(result.state.mode)
if (event.type !== "popover.select") result.commands.forEach(execute)
if (mode !== result.state.mode) {
if (result.state.mode === "shell") input.view.shell?.onOpen()
if (result.state.mode === "normal") input.view.shell?.onClose()
}
if (event.type === "popover.select") {
if (!action) return result.handled
action()
}
return result.handled
}
@@ -241,10 +203,8 @@ export function createComposerEditor(input: {
) {
event.preventDefault()
attach()
return true
}
const handled = dispatch({
type: "key.down",
key: event.key,
@@ -253,37 +213,28 @@ export function createComposerEditor(input: {
ids: suggestions().map((item) => item.id),
empty: draft.state.prompt.every((part) => !("content" in part) || part.content.length === 0),
})
if (handled) event.preventDefault()
if (handled && event.key !== "Enter" && event.key !== "Tab" && state.popover.type !== "closed") {
const activeID = state.popover.activeID ?? ""
requestAnimationFrame(() =>
document.querySelector(`[data-suggestion-id="${CSS.escape(activeID)}"]`)?.scrollIntoView({ block: "nearest" }),
)
}
if (handled) return true
if (event.key === "Escape" && input.view.submit.queue?.editing()) {
event.preventDefault()
input.view.submit.queue.cancelEdit()
return true
}
const stop =
input.view.submit.working?.() &&
((event.ctrlKey && !event.metaKey && !event.altKey && !event.shiftKey && event.key.toLowerCase() === "g") ||
event.key === "Escape")
if (stop) {
event.preventDefault()
input.view.submit.onStop()
return true
}
if (
!event.altKey &&
!event.ctrlKey &&
@@ -292,17 +243,14 @@ export function createComposerEditor(input: {
navigateHistory(event.key === "ArrowUp" ? "up" : "down")
) {
event.preventDefault()
return true
}
return event.defaultPrevented
}
createEffect(() => {
if (state.popover.type === "closed") return
const ids = suggestions().map((item) => item.id)
if (state.popover.activeID ? ids.includes(state.popover.activeID) : ids.length === 0) return
dispatch({ type: "popover.results", ids })
})
@@ -320,48 +268,36 @@ export function createComposerEditor(input: {
draft.setPrompt(clonePrompt(entry.prompt), cursor)
restoreFocus(cursor)
}
const navigateHistory = (direction: "up" | "down") => {
if (!input.history || !editor) return false
const selection = window.getSelection()
if (!selection?.isCollapsed || !editor.contains(selection.anchorNode)) return false
const text = draft.state.prompt.map((part) => ("content" in part ? part.content : "")).join("")
if (!canNavigateHistory(direction, text, editorCursor(editor), state.historyIndex >= 0)) return false
const entries = input.history.entries(state.mode)
if (direction === "up") {
if (entries.length === 0 || state.historyIndex >= entries.length - 1) return false
if (state.historyIndex === -1) {
setState("savedHistory", {
prompt: clonePrompt(draft.state.prompt),
metadata: input.history.capture?.(),
})
}
const index = state.historyIndex + 1
setState("historyIndex", index)
applyHistory(entries[index]!, "start")
return true
}
if (state.historyIndex < 0) return false
if (state.historyIndex > 0) {
const index = state.historyIndex - 1
setState("historyIndex", index)
applyHistory(entries[index]!, "end")
return true
}
const saved = state.savedHistory ?? { prompt: [{ type: "text", content: "", start: 0, end: 0 }] }
setState({ historyIndex: -1, savedHistory: undefined })
applyHistory(saved, "end")
return true
}
@@ -383,9 +319,6 @@ export function createComposerEditor(input: {
comments() {
return draft.state.context.items.filter((item) => !!item.comment?.trim())
},
files() {
return draft.state.context.items.flatMap((item) => (item.type === "file" && !item.comment?.trim() ? [item] : []))
},
attachments(): ComposerAttachment[] {
return draft.state.prompt.filter(isAttachment)
},
@@ -401,10 +334,8 @@ export function createComposerEditor(input: {
},
removeContext(id: string) {
const item = draft.state.context.items.find((entry) => entry.key === id)
if (item) input.onContextRemove?.(item)
draft.removeContext(id)
if (state.activeContextID === id) dispatch({ type: "context.active", id })
},
openAttachment(attachment: ComposerAttachment) {
@@ -415,20 +346,14 @@ export function createComposerEditor(input: {
},
canSubmit() {
if (input.view.submit.available?.() === false) return false
if (input.view.draftOnly) return false
if (attachments?.pending().length) return false
const persisted = draft.state
if (state.mode === "shell") {
return persisted.prompt.some((part) => "content" in part && !!part.content.trim())
}
if (persisted.prompt.some(isAttachment)) return true
if (persisted.context.items.some((item) => !!item.comment?.trim())) return true
return persisted.prompt.some((part) => "content" in part && !!part.content.trim())
},
setEditor(element: HTMLElement) {
@@ -438,7 +363,6 @@ export function createComposerEditor(input: {
restoreFocus,
onInput(value: string, prompt?: ComposerPersistedState["prompt"], cursor?: number) {
if (prompt) draft.setPrompt(prompt, cursor)
if (input.view.draftOnly) return
dispatch({ type: "input.changed", value, persist: !prompt })
},
@@ -456,9 +380,7 @@ export function createComposerEditor(input: {
},
submit(options?: { alternate?: boolean }) {
if (input.view.submit.available?.() === false) return
if (input.view.draftOnly) return
if (attachments?.pending().length) return
input.view.submit.onSubmit(options)
dispatch({ type: "popover.close" })
@@ -476,32 +398,26 @@ export function createComposerEditor(input: {
onPaste(event: ClipboardEvent) {
const clipboard = event.clipboardData
const text = clipboard?.getData("text/plain")
if (attachments && shouldHandlePasteAsAttachment(clipboard, !!input.attachments?.readClipboardImage)) {
void attachments.handlePaste(event)
return
}
if (!text) return
event.preventDefault()
// insertText emits input events per line, repeatedly parsing and saving the draft.
// Escaped HTML inserts multiline text once and preserves native selection and undo.
const normalized = text.replace(/\r\n?/g, "\n")
const multiline = normalized.includes("\n")
const value = multiline
? normalized.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;")
: normalized
if (
Predicate.isFunction(document.execCommand) &&
typeof document.execCommand === "function" &&
document.execCommand(multiline ? "insertHTML" : "insertText", false, value)
)
return
const target = event.currentTarget
const selection = window.getSelection()
if (!(target instanceof HTMLElement) || !selection?.rangeCount || !target.contains(selection.anchorNode)) return
const range = selection.getRangeAt(0)
range.deleteContents()
@@ -526,11 +442,9 @@ export function createComposerEditor(input: {
onDrop(event: DragEvent) {
event.preventDefault()
dispatch({ type: "drag.leave" })
if (attachments) {
event.stopPropagation()
void attachments.handleDrop(event)
return
}
},
@@ -553,30 +467,23 @@ export type ComposerEditorModel = ReturnType<typeof createComposerEditor>
export function shouldHandlePasteAsAttachment(clipboard: DataTransfer | null, readClipboardImage: boolean) {
if (Array.from(clipboard?.items ?? []).some((item) => item.kind === "file")) return true
if (Array.from(clipboard?.types ?? []).some((type) => type.startsWith("text/"))) return false
return readClipboardImage
}
function canNavigateHistory(direction: "up" | "down", text: string, cursor: number, inHistory: boolean) {
const position = Math.max(0, Math.min(cursor, text.length))
if (inHistory) return position === 0 || position === text.length
if (direction === "up") return position === 0 && text.length === 0
return position === text.length
}
function editorCursor(editor: HTMLElement) {
const selection = window.getSelection()
if (!selection?.rangeCount || !editor.contains(selection.anchorNode)) return editor.textContent?.length ?? 0
const range = selection.getRangeAt(0).cloneRange()
range.selectNodeContents(editor)
range.setEnd(selection.anchorNode!, selection.anchorOffset)
return range.toString().length
}
@@ -585,10 +492,8 @@ function setEditorCursor(editor: HTMLElement | undefined, cursor: number) {
const walker = document.createTreeWalker(editor, NodeFilter.SHOW_TEXT)
let remaining = cursor
let node = walker.nextNode()
while (node) {
const length = node.textContent?.length ?? 0
if (remaining <= length) {
const range = document.createRange()
range.setStart(node, remaining)
@@ -596,10 +501,8 @@ function setEditorCursor(editor: HTMLElement | undefined, cursor: number) {
const selection = window.getSelection()
selection?.removeAllRanges()
selection?.addRange(range)
return
}
remaining -= length
node = walker.nextNode()
}
-1
View File
@@ -280,7 +280,6 @@ export function createComposerModel(adapter: ComposerAdapter, options?: { queue?
title: language.t("prompt.toast.modelAgentRequired.title"),
description: language.t("prompt.toast.modelAgentRequired.description"),
}),
unqueueable: () => showToast({ title: language.t("prompt.toast.unqueueable.title") }),
failed: (kind, error) =>
showToast({
title: language.t(sendFailedTitle[kind]),
+4 -98
View File
@@ -1,8 +1,6 @@
import { describe, expect, test } from "bun:test"
import type { SessionMessageUser } from "@opencode/client/promise"
import { extractPromptContext, extractPromptFromMessage } from "./prompt"
import { buildPromptRequest } from "./request"
import { contextItemKey } from "./schema"
import { extractPromptComments, extractPromptFromMessage } from "./prompt"
describe("extractPromptFromMessage", () => {
test("restores uploaded attachments in order, optimistic data URLs, and review comments", () => {
@@ -43,10 +41,9 @@ describe("extractPromptFromMessage", () => {
blob: { id: url, url },
})),
])
expect(extractPromptContext(message)).toMatchObject({
comments: [{ type: "file", path: "src/app.ts", comment: "check this", commentOrigin: "review" }],
files: [],
})
expect(extractPromptComments(message)).toMatchObject([
{ path: "src/app.ts", comment: "check this", origin: "review" },
])
})
test("keeps the directory of a file mention without an at-sign", () => {
@@ -84,97 +81,6 @@ describe("extractPromptFromMessage", () => {
expect(extractPromptFromMessage(message)[0]).toMatchObject({ type: "text", content: "model text" })
})
test("restores every input another client sent without duplicating review comment files", () => {
const message = {
id: "msg_1",
type: "user",
text: "model text",
metadata: {
displayText: "日本 @main.ts",
comments: [
{
path: "/repo/app.ts",
comment: "check this",
selection: { startLine: 2, startChar: 0, endLine: 2, endChar: 0 },
},
],
attachments: [{ name: "report.zip", mime: "application/zip", path: "/repo/report.zip" }],
},
files: [
// Display-width offsets, as the TUI records them after wide characters.
{
data: "",
mime: "text/plain",
source: { type: "uri", uri: "file:///repo/main.ts" },
name: "main.ts",
mention: { text: "@main.ts", start: 5, end: 13 },
},
{
data: "aGk=",
mime: "text/plain",
source: { type: "uri", uri: "file:///repo/app.ts?start=2&end=2" },
name: "app.ts",
},
{
data: "bm90ZXM=",
mime: "text/markdown",
source: { type: "uri", uri: "file:///repo/notes.md" },
name: "notes.md",
description: "the failing version",
},
{
data: "c3JjLw==",
mime: "application/x-directory",
source: { type: "uri", uri: "file:///repo/src" },
name: "src",
},
// The workspace root has no relative path.
{ data: "", mime: "application/x-directory", source: { type: "uri", uri: "file:///repo" }, name: "repo" },
// An empty file is stored with empty data, not missing data.
{ data: "", mime: "text/plain", source: { type: "inline" }, name: "empty.txt" },
],
agents: [{ name: "plan" }],
skills: [{ id: "review", name: "Review" }],
time: { created: 1 },
} satisfies SessionMessageUser
expect(extractPromptFromMessage(message, { directory: "/repo" })).toMatchObject([
{ type: "text", content: "日本 " },
{ type: "file", content: "@main.ts", url: "file:///repo/main.ts" },
{ type: "text", content: " " },
{ type: "agent", content: "@plan", name: "plan" },
{ type: "text", content: " " },
{ type: "skill", content: "@review", id: "review" },
{ type: "image", filename: "empty.txt", mime: "text/plain", blob: { url: "data:text/plain;base64," } },
{ type: "path", filename: "report.zip", path: "/repo/report.zip" },
])
// Unmentioned file references return as context chips, not mention text, as the TUI keeps them.
const context = extractPromptContext(message, { directory: "/repo" })
expect(context.files).toEqual([
{ type: "file", path: "notes.md", name: "notes.md", description: "the failing version" },
// A directory keeps its URI rather than turning into a snapshot of its listing.
{ type: "file", path: "src", name: "src" },
{ type: "file", path: "/repo", name: "repo" },
])
// Resubmitting the restored context sends each file once, under the URI, name, and description it had.
expect(
buildPromptRequest({
prompt: [],
context: [...context.comments, ...context.files].map((item) => ({ ...item, key: contextItemKey(item) })),
images: [],
text: "",
sessionDirectory: "/repo",
}).files.map((file) => [file.uri, file.name, file.description]),
).toEqual([
["file:///repo/app.ts?start=2&end=2", "app.ts", undefined],
["file:///repo/notes.md", "notes.md", "the failing version"],
["file:///repo/src", "src", undefined],
["file:///repo", "repo", undefined],
])
})
test("restores skill mentions as structured Composer parts", () => {
const message = {
id: "msg_1",
+80 -162
View File
@@ -1,10 +1,7 @@
import type { FileAttachmentPart, ImageAttachmentPart, PathAttachmentPart, Prompt } from "@/composer/state"
import type { AgentPart, FileAttachmentPart, ImageAttachmentPart, Prompt, SkillPart } from "@/composer/state"
import { createLegacyBlobReference } from "@/runtime/persistence/drafts"
import type { SessionMessageUser } from "@opencode/client/promise"
import { commentContextItem, readPromptPresentation } from "./comment-note"
import { buildPromptRequest } from "./request"
import { contextItemKey, type FileContextItem } from "./schema"
import { createPathHelpers, decodeFilePath, stripFileProtocol, stripQueryAndHash } from "@/workspaces/files/path"
import { readPromptPresentation } from "./comment-note"
import { Skill } from "@opencode/schema/skill"
type Inline =
@@ -20,10 +17,8 @@ type Inline =
startChar: number
endChar: number
}
url?: string
mime?: string
filename?: string
description?: string
}
| {
type: "agent"
@@ -43,14 +38,11 @@ type Inline =
function selectionFromFileUrl(url: string): Extract<Inline, { type: "file" }>["selection"] {
const queryIndex = url.indexOf("?")
if (queryIndex === -1) return undefined
const params = new URLSearchParams(url.slice(queryIndex + 1))
const startLine = Number(params.get("start"))
const endLine = Number(params.get("end"))
if (!Number.isFinite(startLine) || !Number.isFinite(endLine)) return undefined
return {
startLine,
endLine,
@@ -59,38 +51,24 @@ function selectionFromFileUrl(url: string): Extract<Inline, { type: "file" }>["s
}
}
// A user message or a pending inbox item: the materialized transcript row shares the inbox payload's shape.
export type PromptSource = Pick<SessionMessageUser, "id" | "text" | "files" | "agents" | "skills" | "metadata">
// Restores the composer content that produced a prompt, losing nothing it carried. File references
// without a mention return as context through extractPromptContext; unmentioned agents and skills
// return as trailing mentions, and inline files as attachments.
export function extractPromptFromMessage(
message: PromptSource,
message: SessionMessageUser,
opts?: { directory?: string; attachmentName?: string },
): Prompt {
const presentation = readPromptPresentation(message.metadata)
const text = presentation?.displayText ?? message.text
const text = readPromptPresentation(message.metadata)?.displayText ?? message.text
const directory = opts?.directory
const attachmentName = opts?.attachmentName ?? "attachment"
const toRelative = (path: string) => {
if (!directory) return path
const prefix = directory.endsWith("/") ? directory : directory + "/"
if (path.startsWith(prefix)) return path.slice(prefix.length)
return path
}
const inline: Inline[] = []
const trailing: Inline[] = []
const attachments: (ImageAttachmentPart | PathAttachmentPart)[] = []
const images: ImageAttachmentPart[] = []
for (const file of message.files ?? []) {
const mention = file.mention
const uri = file.source.type === "uri" ? file.source.uri : `data:${file.mime};base64,${file.data}`
if (mention) {
inline.push({
type: "file",
@@ -99,130 +77,63 @@ export function extractPromptFromMessage(
value: mention.text,
path: toRelative(mention.text.startsWith("@") ? mention.text.slice(1) : mention.text),
selection: selectionFromFileUrl(uri),
url: uri,
mime: file.mime,
filename: file.name,
description: file.description,
})
continue
}
// A file reference returns as a context chip through extractPromptContext.
if (file.source.type === "uri" && file.source.uri.startsWith("file:")) continue
// Stored files carry their bytes, and an empty file has empty data; only a local handoff row
// leaves data empty because its bytes live at a blob URL.
const stored = file.source.type === "inline" || !!file.data
const dataUrl =
file.source.type === "uri" && file.source.uri.startsWith("data:")
? file.source.uri
: stored
: file.data
? `data:${file.mime};base64,${file.data}`
: undefined
if (!dataUrl) continue
attachments.push({
images.push({
type: "image",
id: `${message.id}:file:${attachments.length}`,
id: `${message.id}:file:${images.length}`,
filename: file.name ?? attachmentName,
mime: file.mime,
blob: createLegacyBlobReference(dataUrl),
})
}
for (const agent of message.agents ?? []) {
const mention = agent.mention
const part = { type: "agent" as const, name: agent.name }
if (!mention) trailing.push({ ...part, start: -1, end: -1, value: `@${agent.name}` })
if (mention) inline.push({ ...part, start: mention.start, end: mention.end, value: mention.text })
if (!mention) continue
inline.push({
type: "agent",
start: mention.start,
end: mention.end,
value: mention.text,
name: agent.name,
})
}
for (const attached of message.skills ?? []) {
const mention = attached.mention
const part = { type: "skill" as const, id: Skill.ID.make(attached.id), name: Skill.Name.make(attached.name) }
if (!mention) trailing.push({ ...part, start: -1, end: -1, value: `@${attached.id}` })
if (mention) inline.push({ ...part, start: mention.start, end: mention.end, value: mention.text })
if (!mention) continue
inline.push({
type: "skill",
start: mention.start,
end: mention.end,
value: mention.text,
id: Skill.ID.make(attached.id),
name: Skill.Name.make(attached.name),
})
}
attachments.push(
...(presentation?.attachments ?? []).map(
(file, index): PathAttachmentPart => ({
type: "path",
id: `${message.id}:path:${index}`,
filename: file.name,
mime: file.mime,
path: file.path,
}),
),
)
return buildPrompt(text, inline, trailing, attachments)
return buildPrompt(text, inline, images)
}
/**
* The composer context a sent prompt restores: its comments, and the files it attached without a mention. Like the
* TUI, an unmentioned file keeps its URI rather than becoming text; comment files are regenerated from the comments.
*/
export function extractPromptContext(message: PromptSource, opts?: { directory?: string }) {
const comments = (readPromptPresentation(message.metadata)?.comments ?? []).map(commentContextItem)
const directory = opts?.directory
const regenerated = new Set(
buildPromptRequest({
prompt: [],
context: comments.map((item) => ({ ...item, key: contextItemKey(item) })),
images: [],
text: "",
sessionDirectory: directory ?? "",
}).files.map((file) => file.uri),
)
const files = (message.files ?? []).flatMap((file): FileContextItem[] => {
if (file.mention || file.source.type !== "uri" || !file.source.uri.startsWith("file:")) return []
if (regenerated.has(file.source.uri)) return []
const absolute = decodeFilePath(stripQueryAndHash(stripFileProtocol(file.source.uri))).replace(
/^\/([A-Za-z]:)/,
"$1",
)
return [
{
type: "file",
// The workspace root itself has no relative path, so it keeps its absolute one.
path: (directory && createPathHelpers(() => directory).normalize(file.source.uri)) || absolute,
selection: selectionFromFileUrl(file.source.uri),
name: file.name,
description: file.description,
},
]
})
return { comments, files }
export function extractPromptComments(message: SessionMessageUser) {
return readPromptPresentation(message.metadata)?.comments ?? []
}
function buildPrompt(
text: string,
inline: Inline[],
trailing: Inline[],
attachments: (ImageAttachmentPart | PathAttachmentPart)[],
): Prompt {
function buildPrompt(text: string, inline: Inline[], images: ImageAttachmentPart[]): Prompt {
inline.sort((a, b) => {
if (a.start !== b.start) return a.start - b.start
return a.end - b.end
})
const result: Prompt = []
let position = 0
let cursor = 0
let tail = ""
const pushText = (content: string) => {
if (!content) return
@@ -233,70 +144,77 @@ function buildPrompt(
end: position + content.length,
})
position += content.length
tail = content
}
const pushPart = (item: Inline) => {
const pushFile = (item: Extract<Inline, { type: "file" }>) => {
const content = item.value
const span = { content, start: position, end: position + content.length }
position += content.length
tail = content
if (item.type === "agent") {
result.push({ type: "agent", name: item.name, ...span })
return
}
if (item.type === "skill") {
result.push({ type: "skill", id: item.id, name: item.name, ...span })
return
}
result.push({
const attachment: FileAttachmentPart = {
type: "file",
path: item.path,
content,
start: position,
end: position + content.length,
selection: item.selection,
url: item.url,
mime: item.mime,
filename: item.filename,
description: item.description,
...span,
} satisfies FileAttachmentPart)
}
result.push(attachment)
position += content.length
}
// A mention whose recorded offsets no longer index the text (another client may count
// display width) is found by its text; one missing from the text returns at the end.
const unplaced = inline.flatMap((item) => {
if (!item.value) return []
const pushAgent = (item: Extract<Inline, { type: "agent" }>) => {
const content = item.value
const mention: AgentPart = {
type: "agent",
name: item.name,
content,
start: position,
end: position + content.length,
}
result.push(mention)
position += content.length
}
const mismatch =
item.start < cursor ||
item.end < item.start ||
item.end > text.length ||
text.slice(item.start, item.end) !== item.value
const pushSkill = (item: Extract<Inline, { type: "skill" }>) => {
const content = item.value
const skill: SkillPart = {
type: "skill",
id: item.id,
name: item.name,
content,
start: position,
end: position + content.length,
}
result.push(skill)
position += content.length
}
const start = mismatch ? text.indexOf(item.value, cursor) : item.start
for (const item of inline) {
if (item.start < 0 || item.end < item.start) continue
const expected = item.value
if (!expected) continue
const mismatch = item.end > text.length || item.start < cursor || text.slice(item.start, item.end) !== expected
const start = mismatch ? text.indexOf(expected, cursor) : item.start
if (start === -1) continue
const end = mismatch ? start + expected.length : item.end
if (start === -1) return [item]
pushText(text.slice(cursor, start))
pushPart(item)
cursor = start + item.value.length
return []
})
if (item.type === "file") pushFile(item)
if (item.type === "agent") pushAgent(item)
if (item.type === "skill") pushSkill(item)
cursor = end
}
pushText(text.slice(cursor))
for (const item of [...unplaced, ...trailing]) {
if (tail && !/\s$/.test(tail)) pushText(" ")
pushPart(item)
}
if (result.length === 0) {
result.push({ type: "text", content: "", start: 0, end: 0 })
}
return [...result, ...attachments]
if (images.length === 0) return result
return [...result, ...images]
}
+12 -51
View File
@@ -5,8 +5,6 @@ import type {
ContextItem,
FileAttachmentPart,
ImageAttachmentPart,
NoteComment,
NoteContextItem,
PathAttachmentPart,
Prompt,
SkillPart,
@@ -23,13 +21,7 @@ import {
type PromptRequest = {
text: string
displayText: string
files: {
uri: string
mime: string
name?: string
description?: string
mention?: { start: number; end: number; text: string }
}[]
files: { uri: string; mime: string; name?: string; mention?: { start: number; end: number; text: string } }[]
agents: { name: string; mention?: { start: number; end: number; text: string } }[]
skills: { id: string; name: string; mention?: { start: number; end: number; text: string } }[]
comments: PromptComment[]
@@ -46,11 +38,8 @@ type BuildPromptRequestInput = {
const absolute = (directory: string, path: string) => {
if (path.startsWith("/")) return path
if (/^[A-Za-z]:[\\/]/.test(path) || /^[A-Za-z]:$/.test(path)) return path
if (path.startsWith("\\\\") || path.startsWith("//")) return path
return `${directory.replace(/[\\/]+$/, "")}/${path}`
}
@@ -62,54 +51,28 @@ const mention = /(^|[\s([{"'])@(\S+)/g
const parseCommentMentions = (comment: string) => {
return Array.from(comment.matchAll(mention)).flatMap((match) => {
const path = (match[2] ?? "").replace(/[.,!?;:)}\]"']+$/, "")
if (!path) return []
return [path]
})
}
const isFileAttachment = (part: Prompt[number]): part is FileAttachmentPart => part.type === "file"
const isAgentAttachment = (part: Prompt[number]): part is AgentPart => part.type === "agent"
const isSkillAttachment = (part: Prompt[number]): part is SkillPart => part.type === "skill"
const isPathAttachment = (part: Prompt[number]): part is PathAttachmentPart => part.type === "path"
/** The sent form of a note: its optional link and live subject travel only when present. */
export function noteComment(item: NoteContextItem, comment: string) {
const note: NoteComment = {
type: "note",
origin: item.origin,
label: item.label,
icon: item.icon,
subject: item.subject,
comment,
}
if (item.href) note.href = item.href
if (item.live) note.live = { ...item.live }
return note
}
export function buildPromptRequest(input: BuildPromptRequestInput): PromptRequest {
const skills = input.prompt.filter(isSkillAttachment).map((attachment) => ({
id: attachment.id,
name: attachment.name,
mention: { start: attachment.start, end: attachment.end, text: attachment.content },
}))
const files = input.prompt.filter(isFileAttachment).map((attachment) => {
const path = absolute(input.sessionDirectory, attachment.path)
return {
uri: attachment.url ?? `file://${encodeFilePath(path)}${fileQuery(attachment.selection)}`,
mime: attachment.mime ?? "text/plain",
name: attachment.filename ?? getFilename(attachment.path),
description: attachment.description,
mention: { start: attachment.start, end: attachment.end, text: attachment.content },
}
})
@@ -121,36 +84,36 @@ export function buildPromptRequest(input: BuildPromptRequestInput): PromptReques
const used = new Set(files.map((file) => file.uri))
const comments: PromptComment[] = []
const mentioned = (comment: string) =>
parseCommentMentions(comment).flatMap((path) => {
const uri = `file://${encodeFilePath(absolute(input.sessionDirectory, path))}`
if (used.has(uri)) return []
used.add(uri)
return [{ uri, mime: "text/plain", name: getFilename(path) }]
})
const context = input.context.flatMap((item) => {
if (item.type === "note") {
const comment = item.comment.trim()
if (!comment) return []
comments.push(noteComment(item, comment))
comments.push({
type: "note",
origin: item.origin,
label: item.label,
icon: item.icon,
subject: item.subject,
...(item.href ? { href: item.href } : {}),
...(item.live ? { live: { ...item.live } } : {}),
comment,
})
return mentioned(comment)
}
const path = absolute(input.sessionDirectory, item.path)
const uri = `file://${encodeFilePath(path)}${fileQuery(item.selection)}`
const comment = item.comment?.trim()
if (!comment && used.has(uri)) return []
used.add(uri)
const file = { uri, mime: "text/plain", name: item.name ?? getFilename(item.path), description: item.description }
const file = { uri, mime: "text/plain", name: getFilename(item.path) }
if (!comment) return [file]
comments.push({
@@ -160,7 +123,6 @@ export function buildPromptRequest(input: BuildPromptRequestInput): PromptReques
preview: item.preview,
origin: item.commentOrigin,
})
return [file, ...mentioned(comment)]
})
@@ -169,7 +131,6 @@ export function buildPromptRequest(input: BuildPromptRequestInput): PromptReques
mime: attachment.mime,
name: attachment.sourcePath ?? attachment.filename,
}))
// Like comments, path references reach the model as text and the message UI through metadata.
const attachments = input.prompt
.filter(isPathAttachment)
-41
View File
@@ -12,9 +12,7 @@ const PartBase = {
}
const SourceText = Schema.Struct({ value: Schema.String, start: Schema.Number, end: Schema.Number })
const Position = Schema.Struct({ line: Schema.Number, character: Schema.Number })
const FilePartSource = Schema.Union([
Schema.Struct({ type: Schema.Literal("file"), text: SourceText, path: Schema.String }),
Schema.Struct({
@@ -29,7 +27,6 @@ const FilePartSource = Schema.Union([
])
export const TextPart = Persistence.struct({ type: Schema.Literal("text"), ...PartBase })
export type TextPart = typeof TextPart.Type
export const FileAttachmentPart = Persistence.struct({
@@ -39,15 +36,12 @@ export const FileAttachmentPart = Persistence.struct({
selection: Persistence.optional(FileSelection),
mime: Persistence.optional(Schema.String),
filename: Persistence.optional(Schema.String),
description: Persistence.optional(Schema.String),
url: Persistence.optional(Schema.String),
source: Persistence.optional(FilePartSource),
})
export type FileAttachmentPart = typeof FileAttachmentPart.Type
export const AgentPart = Persistence.struct({ type: Schema.Literal("agent"), ...PartBase, name: Schema.String })
export type AgentPart = typeof AgentPart.Type
export const SkillPart = Persistence.struct({
@@ -56,7 +50,6 @@ export const SkillPart = Persistence.struct({
id: Skill.ID,
name: Skill.Name,
})
export type SkillPart = typeof SkillPart.Type
const ImageFields = {
@@ -66,7 +59,6 @@ const ImageFields = {
sourcePath: Persistence.optional(Schema.String),
mime: Schema.String,
}
const Image = Persistence.struct({
...ImageFields,
// An empty URL is an image whose bytes are still in the draft store; see `resolveBlobUrl`.
@@ -86,7 +78,6 @@ export const ImageAttachmentPart = Schema.Struct({
decode: SchemaGetter.transform((value) => {
const id = value.blob?.id ?? value.dataUrl ?? ""
const url = value.blob?.url
return {
type: value.type,
id: value.id,
@@ -102,7 +93,6 @@ export const ImageAttachmentPart = Schema.Struct({
encode: SchemaGetter.transform((value) => value),
}),
)
export type ImageAttachmentPart = typeof ImageAttachmentPart.Type
// A file the model receives as a path on the server: its bytes never enter the draft store.
@@ -113,7 +103,6 @@ export const PathAttachmentPart = Persistence.struct({
mime: Schema.String,
path: Schema.String,
})
export type PathAttachmentPart = typeof PathAttachmentPart.Type
export const ContentPart = Schema.Union([
@@ -124,11 +113,8 @@ export const ContentPart = Schema.Union([
ImageAttachmentPart,
PathAttachmentPart,
])
export type ContentPart = typeof ContentPart.Type
export const Prompt = Persistence.array(ContentPart)
export type Prompt = typeof Prompt.Type
export const PromptModel = Persistence.struct({
@@ -136,7 +122,6 @@ export const PromptModel = Persistence.struct({
modelID: Schema.String,
variant: Persistence.optional(Schema.NullOr(Schema.String)),
})
export type PromptModel = typeof PromptModel.Type
export const FileContextItem = Persistence.struct({
@@ -147,11 +132,7 @@ export const FileContextItem = Persistence.struct({
commentID: Persistence.optional(Schema.String),
commentOrigin: Persistence.optional(Schema.Literals(["review", "file"])),
preview: Persistence.optional(Schema.String),
// A file restored from a sent prompt keeps the name and description it was sent with.
name: Persistence.optional(Schema.String),
description: Persistence.optional(Schema.String),
})
export type FileContextItem = typeof FileContextItem.Type
const NoteFields = {
@@ -164,16 +145,11 @@ const NoteFields = {
live: Persistence.optional(Persistence.struct({ subject: Schema.String, href: Persistence.optional(Schema.String) })),
comment: Schema.String,
}
/** An extension's comment on something other than workspace lines, as sent in message metadata. */
export const NoteComment = Persistence.struct(NoteFields)
export type NoteComment = typeof NoteComment.Type
export const NoteContextItem = Persistence.struct({ ...NoteFields, commentID: Schema.String })
export type NoteContextItem = typeof NoteContextItem.Type
export type ContextItem = FileContextItem | NoteContextItem
/** A note's live part names state inside the app process that attached it; anything that may outlive it drops it. */
@@ -198,7 +174,6 @@ const LegacyBrowserComment = Persistence.struct({
}),
comment: Schema.String,
})
export const LegacyBrowserNote = LegacyBrowserComment.pipe(
Schema.decodeTo(Schema.toType(NoteComment), {
decode: SchemaGetter.transform(legacyBrowserNote),
@@ -209,7 +184,6 @@ export const LegacyBrowserNote = LegacyBrowserComment.pipe(
// The subject those builds sent for an element whose ref no longer applies.
function legacyBrowserNote(item: typeof LegacyBrowserComment.Type): NoteComment {
const element = item.element
const details = [
element.role ? `role ${element.role}` : undefined,
element.name ? `accessible name ${JSON.stringify(element.name)}` : undefined,
@@ -218,7 +192,6 @@ function legacyBrowserNote(item: typeof LegacyBrowserComment.Type): NoteComment
? `selector ${JSON.stringify(element.selector)}${element.selector.includes(" >>> ") ? ' (">>>" enters a shadow root)' : ""}`
: undefined,
].filter((detail) => detail !== undefined)
return {
type: "note",
origin: "browser",
@@ -233,13 +206,10 @@ function legacyBrowserNote(item: typeof LegacyBrowserComment.Type): NoteComment
export function contextItemKey(item: ContextItem) {
if (item.type === "note") return `note:${item.origin}:c=${item.commentID}`
const key = `${item.type}:${item.path}:${item.selection?.startLine}:${item.selection?.endLine}`
if (item.commentID) return `${key}:c=${item.commentID}`
const comment = item.comment?.trim()
if (!comment) return key
const digest = checksum(comment) ?? comment
return `${key}:c=${digest.slice(0, 8)}`
}
@@ -249,7 +219,6 @@ const FileContextEntry = Schema.Struct({ ...FileContextItem.fields, key: Persist
encode: SchemaGetter.transform((item) => item),
}),
)
const NoteContextEntry = Schema.Struct({
...NoteContextItem.fields,
key: Persistence.optional(Schema.String),
@@ -260,7 +229,6 @@ const NoteContextEntry = Schema.Struct({
encode: SchemaGetter.transform((item) => item),
}),
)
const LegacyBrowserContextEntry = Schema.Struct({
...LegacyBrowserComment.fields,
commentID: Schema.String,
@@ -269,13 +237,11 @@ const LegacyBrowserContextEntry = Schema.Struct({
Schema.decodeTo(Persistence.struct({ ...NoteContextItem.fields, key: Schema.String }).pipe(Schema.toType), {
decode: SchemaGetter.transform((item) => {
const note = { ...legacyBrowserNote(item), commentID: item.commentID }
return { ...note, key: contextItemKey(note) }
}),
encode: SchemaGetter.forbidden(() => "Legacy browser comments are read-only"),
}),
)
const ContextEntry = Schema.Union([FileContextEntry, NoteContextEntry, LegacyBrowserContextEntry])
export const DEFAULT_PROMPT: Prompt = [{ type: "text", content: "", start: 0, end: 0 }]
@@ -310,7 +276,6 @@ export const ComposerStore = Persistence.struct({
),
context: Persistence.struct({ items: Persistence.array(ContextEntry) }),
})
export type ComposerStore = typeof ComposerStore.Type
export const LineComment = Persistence.struct({
@@ -320,13 +285,11 @@ export const LineComment = Persistence.struct({
comment: Schema.String,
time: Schema.Number,
})
export type LineComment = typeof LineComment.Type
export const CommentStore = Persistence.struct({
comments: Schema.Record(Schema.String, Schema.mutableKey(Persistence.array(LineComment))),
})
export type CommentStore = typeof CommentStore.Type
export const PromptHistoryComment = Persistence.struct({
@@ -338,7 +301,6 @@ export const PromptHistoryComment = Persistence.struct({
origin: Persistence.optional(Schema.Literals(["review", "file"])),
preview: Persistence.optional(Schema.String),
})
export type PromptHistoryComment = typeof PromptHistoryComment.Type
// History entries require a prompt array; only its individual parts recover.
@@ -348,16 +310,13 @@ const HistoryPrompt = Schema.Array(Persistence.fallback(Schema.UndefinedOr(Conte
encode: SchemaGetter.transform((parts) => parts),
}),
)
const HistoryEntry = Schema.Struct({ prompt: HistoryPrompt, comments: Persistence.array(PromptHistoryComment) })
export const PromptHistoryEntry = Schema.Union([HistoryEntry, HistoryPrompt]).pipe(
Schema.decodeTo(Schema.toType(HistoryEntry), {
decode: SchemaGetter.transform((entry) => ("prompt" in entry ? entry : { prompt: entry, comments: [] })),
encode: SchemaGetter.transform((entry) => entry),
}),
)
export type PromptHistoryEntry = typeof PromptHistoryEntry.Type
export const PromptHistoryState = Persistence.struct({ entries: Persistence.array(PromptHistoryEntry) })
+11 -32
View File
@@ -14,7 +14,6 @@ import {
} from "./schema"
export { DEFAULT_PROMPT } from "./schema"
export type {
AgentPart,
ComposerStore,
@@ -46,12 +45,13 @@ export function isCommentItem(item: ContextItem | (ContextItem & { key: string }
function createComposerActions(setStore: SetStoreFunction<ComposerStore>) {
return {
set(prompt: Prompt, cursorPosition?: number) {
batch(() => {
setStore({ prompt: clonePrompt(prompt), retry: undefined })
// An omitted cursor keeps the current one.
if (cursorPosition !== undefined) setStore("cursor", cursorPosition)
})
batch(() =>
setStore({
prompt: clonePrompt(prompt),
...(cursorPosition !== undefined ? { cursor: cursorPosition } : {}),
retry: undefined,
}),
)
},
reset() {
batch(() => setStore({ prompt: clonePrompt(DEFAULT_PROMPT), cursor: 0, retry: undefined }))
@@ -64,14 +64,14 @@ function composerTarget(serverScope: ServerScope, scope: PromptScope) {
? Persist.prompt(Persist.draft(scope.draftID, "prompt"))
: Persist.prompt({
...Persist.serverScoped(serverScope, scope.dir, scope.id, "prompt"),
previousKey:
serverScope === ServerScope.local ? `${scope.dir}/prompt${scope.id ? "/" + scope.id : ""}.v2` : undefined,
...(serverScope === ServerScope.local
? { previousKey: `${scope.dir}/prompt${scope.id ? "/" + scope.id : ""}.v2` }
: {}),
})
}
function initialComposerStore(initial?: InitialPrompt): ComposerStore {
const text = initial?.prompt
return {
prompt:
text === undefined ? clonePrompt(DEFAULT_PROMPT) : [{ type: "text", content: text, start: 0, end: text.length }],
@@ -85,13 +85,11 @@ function initialComposerStore(initial?: InitialPrompt): ComposerStore {
function createComposerStateValue(store: ComposerStore, setStore: SetStoreFunction<ComposerStore>) {
const actions = createComposerActions(setStore)
const clearRetry = () => {
if (untrack(() => store.retry) !== undefined) setStore("retry", undefined)
}
const value = {
store: [() => store, setStore] satisfies [Accessor<ComposerStore>, SetStoreFunction<ComposerStore>],
store: [() => store, setStore] as [Accessor<ComposerStore>, SetStoreFunction<ComposerStore>],
current: () => store.prompt,
cursor: () => store.cursor,
model: {
@@ -116,7 +114,6 @@ function createComposerStateValue(store: ComposerStore, setStore: SetStoreFuncti
items: () => store.context.items,
add(item: ContextItem) {
const key = contextItemKey(item)
if (store.context.items.find((x) => x.key === key)) return
setStore("context", "items", (items) => [...items, { key, ...item }])
clearRetry()
@@ -135,27 +132,13 @@ function createComposerStateValue(store: ComposerStore, setStore: SetStoreFuncti
setStore("context", "items", (items) =>
items.map((item) => {
if (item.commentID !== commentID) return item
const value =
item.type === "file" ? { ...item, ...next } : { ...item, comment: next.comment ?? item.comment }
return { ...value, key: contextItemKey(value) }
}),
)
clearRetry()
},
/** Replaces every item, as restoring a sent prompt replaces the whole draft. */
replace(items: ContextItem[]) {
setStore(
"context",
"items",
[...new Map(items.map((item) => [contextItemKey(item), item] as const)).entries()].map(([key, item]) => ({
...item,
key,
})),
)
clearRetry()
},
replaceComments(items: ContextItem[]) {
setStore("context", "items", (current) => [
...current.filter((item) => !isCommentItem(item)),
@@ -168,7 +151,6 @@ function createComposerStateValue(store: ComposerStore, setStore: SetStoreFuncti
reset: () => actions.reset(),
capture: () => value,
}
return value
}
@@ -178,7 +160,6 @@ function createPersistedComposer(
platform?: Platform,
) {
const [store, setStore, _, ready] = persisted(target, ComposerStore, initialComposerStore(initial), platform)
return { ready, ...createComposerStateValue(store, setStore) }
}
@@ -198,7 +179,6 @@ export function createDraftComposerState(draftID: string, initial?: InitialPromp
export type ComposerState = ReturnType<typeof createComposerState>
export function createComposerReady(session: Accessor<ComposerState>) {
// SAFETY: defineProperty returns the same function, now carrying the promise getter defined here.
return Object.defineProperty(() => session().ready(), "promise", {
get: () => session().ready.promise,
}) as (() => boolean) & { readonly promise: Promise<unknown> | undefined }
@@ -207,7 +187,6 @@ export function createComposerReady(session: Accessor<ComposerState>) {
export function createMemoryComposerState(initial?: InitialPrompt) {
const [store, setStore] = createStore<ComposerStore>(initialComposerStore(initial))
const ready = Object.assign(() => true, { promise: Promise.resolve(true) })
return {
ready,
...createComposerStateValue(store, setStore),
+8 -52
View File
@@ -7,7 +7,6 @@ import { createMemoryComposerState, type Prompt } from "./state"
import { createComposerSubmit } from "./submit"
import type { ComposerStateTarget } from "./submission-state"
// SAFETY: submission reads only the selected model's id, name, and provider id.
const selectedModel = {
id: "model-1",
name: "Model 1",
@@ -66,11 +65,7 @@ function fresh(state: ComposerStateTarget, start: NewSessionComposerAdapter["sta
function submitInput(
adapter: ActiveComposerAdapter | NewSessionComposerAdapter,
notify: Parameters<typeof createComposerSubmit>[0]["notify"] = {
missingSelection() {},
unqueueable() {},
failed() {},
},
notify = { missingSelection() {}, failed(_kind: "shell" | "command" | "prompt", _error: unknown) {} },
mode: "normal" | "shell" = "normal",
commands: () => readonly { name: string }[] | undefined = () => [],
history: string[] = [],
@@ -102,7 +97,7 @@ function session(input: {
statuses?: ("idle" | "running")[]
current?: ComposerSession["current"]
admitted?: (messageID: string) => boolean
shell?: ComposerSession["api"]["shell"]
shell?: () => Promise<unknown>
command?: ComposerSession["api"]["command"]
switchAgent?: ComposerSession["api"]["switchAgent"]
switchModel?: ComposerSession["api"]["switchModel"]
@@ -145,9 +140,8 @@ function session(input: {
}
describe("Composer submission", () => {
test("applies the selection and runs a client argument command without admitting it to the session", async () => {
test("runs a client argument command without admitting it to the session", async () => {
const state = createMemoryComposerState().capture()
const image = {
type: "image" as const,
id: "attachment",
@@ -155,7 +149,6 @@ describe("Composer submission", () => {
mime: "image/png",
blob: { id: "attachment", url: "data:image/png;base64,YQ==" },
}
state.set([{ type: "text", content: "/btw why this approach?", start: 0, end: 23 }, image])
state.context.add({ type: "file", path: "src/retry.ts" })
const calls: string[] = []
@@ -163,14 +156,12 @@ describe("Composer submission", () => {
await submitInput(active(state, session({ calls })), undefined, "normal", undefined, history, (text) => {
expect(text).toBe("/btw why this approach?")
return () => {
calls.push("btw")
}
}).submit(new Event("submit"))
// Client commands such as /btw generate with the session's model, so the composer's selection commits first.
expect(calls).toEqual(["switch-agent", "switch-model", "btw"])
expect(calls).toEqual(["btw"])
expect(history).toEqual([])
expect(state.current()).toEqual([{ type: "text", content: "", start: 0, end: 0 }, image])
expect(state.context.items()).toHaveLength(1)
@@ -185,7 +176,6 @@ describe("Composer submission", () => {
},
])("applies the selection before sending one captured value: $calls", async (row) => {
const state = createMemoryComposerState({ prompt: "ship it" }).capture()
state.context.add({ type: "file", path: "notes.md", description: "the failing version" })
const calls: string[] = []
const admitted = Promise.withResolvers<Parameters<ComposerSession["data"]["session"]["prompt"]>[0]>()
const target = session({ calls, current: () => row.current, prompt: async (value) => admitted.resolve(value) })
@@ -202,11 +192,7 @@ describe("Composer submission", () => {
agent: "build",
model: { providerID: "provider-1", modelID: "model-1", variant: "balanced" },
})
// A file chip goes with this prompt only, like the TUI's mentionless files.
expect(request.files).toMatchObject([{ name: "notes.md", description: "the failing version" }])
expect(request.files?.[0]?.mention).toBeUndefined()
expect(state.current()).toEqual([{ type: "text", content: "", start: 0, end: 0 }])
expect(state.context.items()).toEqual([])
})
test("applies the captured agent and model before a custom command without passing over its overrides", async () => {
@@ -217,7 +203,6 @@ describe("Composer submission", () => {
const started = Promise.withResolvers<void>()
const committed = Promise.withResolvers<void>()
const completed = Promise.withResolvers<void>()
const target = session({
calls,
switchAgent: async (request) => {
@@ -239,7 +224,6 @@ describe("Composer submission", () => {
completed.resolve()
},
})
selected.model.selection = {
...selection,
trackSessionCommit: (_id, value) => {
@@ -249,7 +233,6 @@ describe("Composer submission", () => {
variant: "balanced",
})
calls.push("track")
return () => calls.push("cancel")
},
}
@@ -275,7 +258,6 @@ describe("Composer submission", () => {
const selected = controls()
const failed = Promise.withResolvers<unknown>()
const error = new Error("model unavailable")
const target = session({
calls,
switchModel: async () => {
@@ -285,12 +267,10 @@ describe("Composer submission", () => {
calls.push("command")
},
})
selected.model.selection = {
...selection,
trackSessionCommit: () => {
calls.push("track")
return () => {
calls.push("cancel")
}
@@ -298,7 +278,7 @@ describe("Composer submission", () => {
}
await submitInput(
active(state, target, () => selected),
{ missingSelection() {}, unqueueable() {}, failed: (_kind, error) => failed.resolve(error) },
{ missingSelection() {}, failed: (_kind, error) => failed.resolve(error) },
"normal",
() => [{ name: "review" }],
).submit(new Event("submit"))
@@ -309,7 +289,6 @@ describe("Composer submission", () => {
test("restores and retries an unacknowledged admission", async () => {
const state = createMemoryComposerState().capture()
const prompt: Prompt = [
{ type: "text", content: "retry ", start: 0, end: 6 },
{ type: "file", path: "src/app.ts", content: "@src/app.ts", start: 6, end: 17 },
@@ -321,14 +300,11 @@ describe("Composer submission", () => {
blob: { id: "attachment", url: "data:image/png;base64,YQ==" },
},
]
state.set(prompt)
state.context.add({ type: "file", path: "notes.md", description: "the failing version" })
const attempts: string[] = []
const statuses: ("idle" | "running")[] = []
const first = Promise.withResolvers<void>()
const second = Promise.withResolvers<void>()
const target = session({
calls: [],
statuses,
@@ -337,13 +313,10 @@ describe("Composer submission", () => {
throw new Error("network unavailable")
},
})
const notify = {
missingSelection() {},
unqueueable() {},
failed: () => (attempts.length === 2 ? first.resolve() : second.resolve()),
}
const history: string[] = []
const submission = submitInput(active(state, target), notify, "normal", () => [], history)
@@ -356,9 +329,6 @@ describe("Composer submission", () => {
expect(new Set(attempts).size).toBe(1)
expect(statuses).toEqual(["running", "idle", "running", "idle"])
expect(state.current()).toEqual(prompt)
expect(state.context.items()).toMatchObject([
{ type: "file", path: "notes.md", description: "the failing version" },
])
// The caret returns after the mention text; attachments take no caret positions.
expect(state.cursor()).toBe(17)
// The restored prompt is the draft again, so history does not also keep it (and its attachments).
@@ -381,18 +351,14 @@ describe("Composer submission", () => {
const promoted = createMemoryComposerState().capture()
const failed = Promise.withResolvers<void>()
const target = session({ calls: [], shell: async () => Promise.reject(new Error("send failed")) })
const adapter = fresh(draft, async (_selection, submission) => {
submission.retarget(promoted)
return { session: target, cleanupReady: Promise.resolve() }
})
await submitInput(
adapter,
{ missingSelection() {}, unqueueable() {}, failed: () => failed.resolve() },
"shell",
).submit(new Event("submit"))
await submitInput(adapter, { missingSelection() {}, failed: () => failed.resolve() }, "shell").submit(
new Event("submit"),
)
await failed.promise
expect(promoted.current()).toMatchObject([{ type: "text", content: "first prompt" }])
@@ -404,12 +370,10 @@ describe("Composer submission", () => {
const state = createMemoryComposerState({ prompt: "admitted prompt" }).capture()
const checked = Promise.withResolvers<void>()
const attempts: string[] = []
const target = session({
calls: [],
admitted: () => {
checked.resolve()
return true
},
prompt: async (value) => {
@@ -428,7 +392,6 @@ describe("Composer submission", () => {
test("hands off image-only first prompts and admits them before cleanup is ready", async () => {
const draft = createMemoryComposerState().capture()
const prompt: Prompt = [
{ type: "text", content: "", start: 0, end: 0 },
{
@@ -439,12 +402,10 @@ describe("Composer submission", () => {
blob: { id: "attachment", url: "data:image/png;base64,YQ==" },
},
]
draft.set(prompt)
const handedOff = Promise.withResolvers<SessionMessageUser>()
const admitted = Promise.withResolvers<void>()
const cleanup = Promise.withResolvers<void>()
const target = session({
calls: [],
handoff: { set: handedOff.resolve, clear() {} },
@@ -454,7 +415,6 @@ describe("Composer submission", () => {
const submitted = submitInput(
fresh(draft, async () => ({ session: target, cleanupReady: cleanup.promise })),
).submit(new Event("submit"))
await admitted.promise
expect(draft.current()).toEqual(prompt)
cleanup.resolve()
@@ -511,7 +471,6 @@ describe("Composer submission", () => {
const catalog = [{ name: "review" }]
const sent = Promise.withResolvers<"prompt" | "command">()
const requests: Parameters<ComposerSession["api"]["command"]>[0][] = []
const target = session({
calls: [],
prompt: async () => sent.resolve("prompt"),
@@ -520,14 +479,11 @@ describe("Composer submission", () => {
sent.resolve("command")
},
})
target.directory = "C:/new-worktree"
target.data.location.command.list = () => undefined
const adapter = fresh(state, async () => {
// The destination catalog has not loaded, and the source composer is leaving.
catalog.splice(0)
return { session: target, cleanupReady: Promise.resolve() }
})
+44 -122
View File
@@ -7,7 +7,7 @@ import type { ImageAttachmentPart, Prompt } from "./state"
import { clonePrompt, promptLength } from "./prompt-parts"
import type { ComposerAdapter, ComposerDelivery, ComposerSelection, ComposerSession } from "./adapter"
import { createComposerSubmission } from "./submission-state"
import { buildPromptRequest, noteComment } from "./request"
import { buildPromptRequest } from "./request"
import { setCursorPosition } from "./editor/dom"
import { blobDataUrl, resolveBlobUrl } from "@/runtime/persistence/drafts"
import { isAttachment } from "./prompt-parts"
@@ -42,8 +42,6 @@ type ComposerSubmitInput = {
clientCommand?: (text: string) => (() => void | Promise<void>) | undefined
notify: {
missingSelection: () => void
unqueueable: () => void
// oxlint-disable-next-line anti-slop/no-unknown-parameters -- a rejected send is opaque; the notifier formats it
failed: (kind: "shell" | "command" | "prompt", error: unknown) => void
}
comments: {
@@ -60,22 +58,10 @@ export function createComposerSubmit(input: ComposerSubmitInput) {
const prompt = clonePrompt(input.adapter.state.current())
const text = submissionText(prompt)
const clientCommand = input.mode() === "normal" ? input.clientCommand?.(text) : undefined
if (clientCommand) {
if (submitting.has(input.adapter.state)) return
submitting.add(input.adapter.state)
try {
// Client commands such as /btw answer with the session's model, so apply the composer's selection first,
// following the same steer rule as server commands.
const selection = currentSelection(input)
if (input.adapter.kind === "active-session" && selection && (input.delivery?.(false) ?? "steer") === "steer")
await applySelection(
input.adapter.session(),
selection,
input.adapter.controls().model.selection.trackSessionCommit,
)
clearClientCommand(input, prompt)
await clientCommand()
} catch (error) {
@@ -83,10 +69,8 @@ export function createComposerSubmit(input: ComposerSubmitInput) {
} finally {
submitting.delete(input.adapter.state)
}
return
}
const submission = createComposerSubmission({
target: input.adapter.state,
prompt,
@@ -98,24 +82,12 @@ export function createComposerSubmit(input: ComposerSubmitInput) {
: { ...item, selection: item.selection ? { ...item.selection } : undefined },
),
})
const read = readSubmission(input, submission.prompt, submission.context, text, options?.alternate ?? false)
if (!read) {
if (input.adapter.working() && input.adapter.kind === "active-session") void input.adapter.interrupt()
return
}
// Like the TUI, a shell command runs now or not at all; it cannot wait in the queue.
if (read.mode === "shell" && read.delivery === "queue") {
input.notify.unqueueable()
return
}
if (submitting.has(input.adapter.state)) return
// Images restored from a draft or history carry ids only; the optimistic message shows their URLs.
const value = {
...read,
@@ -126,7 +98,6 @@ export function createComposerSubmit(input: ComposerSubmitInput) {
})),
),
}
submitting.add(input.adapter.state)
const comments = input.comments.capture()
// Capture command intent before starting a session in a worktree whose catalog has not loaded.
@@ -137,7 +108,6 @@ export function createComposerSubmit(input: ComposerSubmitInput) {
input.adapter.kind === "active-session"
? { session: input.adapter.session(), cleanupReady: Promise.resolve() }
: await input.adapter.start(value.selection, submission, handoffMessage(value))
if (!started) return
const session = started.session
@@ -148,10 +118,8 @@ export function createComposerSubmit(input: ComposerSubmitInput) {
if (value.mode === "normal" && !command) {
session.handoff?.set(handoffMessage(value))
const optimisticBusy = !input.adapter.working()
if (optimisticBusy && input.adapter.kind === "new-session")
session.data.session.setStatus(session.id, "running")
const sending = sendPrompt(session, value, input.adapter.controls().model.selection.trackSessionCommit, () => {
if (optimisticBusy && input.adapter.kind === "active-session")
session.data.session.setStatus(session.id, "running")
@@ -159,12 +127,12 @@ export function createComposerSubmit(input: ComposerSubmitInput) {
() => ({ ok: true as const }),
(error) => ({ ok: false as const, error }),
)
await started.cleanupReady
await started.complete?.()
input.adapter.submitted()
// Like the TUI, sent context goes with the prompt rather than riding along on the next one.
submission.context.forEach((item) => submission.target().context.remove(item.key))
submission.context
.filter((item) => !!item.comment?.trim())
.forEach((item) => submission.target().context.remove(item.key))
input.comments.clear()
clearSubmission(input, submission)
void sending.then((result) => {
@@ -173,7 +141,6 @@ export function createComposerSubmit(input: ComposerSubmitInput) {
if (optimisticBusy) session.data.session.setStatus(session.id, "idle")
})
})
return
}
@@ -184,7 +151,6 @@ export function createComposerSubmit(input: ComposerSubmitInput) {
if (value.mode === "shell") {
clearSubmission(input, submission)
void sendShell(session, value).catch((error) => failSubmission(input, session, "shell", error, restore))
return
}
@@ -193,7 +159,6 @@ export function createComposerSubmit(input: ComposerSubmitInput) {
void sendCommand(session, value, command, input.adapter.controls().model.selection.trackSessionCommit).catch(
(error) => failSubmission(input, session, "command", error, restore, value.id),
)
return
}
} finally {
@@ -214,14 +179,6 @@ function clearClientCommand(input: ComposerSubmitInput, prompt: Prompt) {
input.closePopover()
}
function selectionModel(selection: ComposerSelection) {
const model: ComposerSelection["model"] & { variant?: string } = { ...selection.model }
if (selection.variant) model.variant = selection.variant
return model
}
function submissionText(prompt: Prompt) {
return prompt.map((part) => ("content" in part ? part.content : "")).join("")
}
@@ -244,23 +201,35 @@ function handoffMessage(value: ComposerSubmission): SessionMessageUser {
),
comments: value.context.flatMap((item): PromptComment[] => {
const comment = item.comment?.trim()
if (!comment) return []
if (item.type === "note") return [noteComment(item, comment)]
if (item.type === "note")
return [
{
type: "note",
origin: item.origin,
label: item.label,
icon: item.icon,
subject: item.subject,
...(item.href ? { href: item.href } : {}),
...(item.live ? { live: { ...item.live } } : {}),
comment,
},
]
return [
{
path: item.path,
comment,
selection: item.selection && { ...item.selection },
preview: item.preview,
origin: item.commentOrigin,
...(item.selection ? { selection: { ...item.selection } } : {}),
...(item.preview !== undefined ? { preview: item.preview } : {}),
...(item.commentOrigin ? { origin: item.commentOrigin } : {}),
},
]
}),
agent: value.selection.agent,
model: selectionModel(value.selection),
model: {
...value.selection.model,
...(value.selection.variant ? { variant: value.selection.variant } : {}),
},
},
time: { created: Date.now() },
}
@@ -274,30 +243,26 @@ function readSubmission(
alternate: boolean,
): ComposerSubmission | undefined {
const mode = input.mode()
if (mode === "shell" && !text.trim()) return
const images = prompt.filter((part): part is ImageAttachmentPart => part.type === "image")
// Like the TUI's blank Enter, file chips alone send nothing; they wait for text.
const comments = context.filter((item) => !!item.comment?.trim()).length
if (!text.trim() && !prompt.some(isAttachment) && comments === 0) return
const selection = currentSelection(input)
if (!selection) {
const controls = input.adapter.controls()
const model = controls.model.selection.current()
const agent = controls.agents.current
if (!model || !agent) {
input.notify.missingSelection()
return
}
const variant = controls.model.selection.variant.current()
const retry = input.adapter.state.retry.current()
const retryID =
retry &&
retry.agent === selection.agent &&
retry.providerID === selection.model.providerID &&
retry.modelID === selection.model.modelID &&
(retry.variant ?? "default") === (selection.variant ?? "default")
retry.agent === agent &&
retry.providerID === model.provider.id &&
retry.modelID === model.id &&
(retry.variant ?? "default") === (variant ?? "default")
? retry.id
: undefined
@@ -308,25 +273,15 @@ function readSubmission(
context,
text,
images,
selection,
selection: {
agent,
model: { modelID: model.id, providerID: model.provider.id },
variant,
},
delivery: input.delivery?.(alternate) ?? "steer",
}
}
function currentSelection(input: ComposerSubmitInput): ComposerSelection | undefined {
const controls = input.adapter.controls()
const model = controls.model.selection.current()
const agent = controls.agents.current
if (!model || !agent) return
return {
agent,
model: { modelID: model.id, providerID: model.provider.id },
variant: controls.model.selection.variant.current(),
}
}
function clearSubmission(input: ComposerSubmitInput, submission: ReturnType<typeof createComposerSubmission>) {
submission.clear()
submission.target().mode.set("normal")
@@ -341,7 +296,6 @@ function restoreSubmission(
comments: PromptHistoryComment[],
) {
const restored = submission.restore()
if (!restored) return false
// The prompt is back in the composer; its history entry would only keep attachments referenced.
input.removeFromHistory(value.prompt, value.mode, comments)
@@ -374,17 +328,6 @@ function restoreSubmission(
},
),
)
restored.context.forEach((item) => {
if (item.type === "file" && !item.comment?.trim())
restored.target.context.add({
type: "file",
path: item.path,
selection: item.selection,
name: item.name,
description: item.description,
})
})
// A recovered follow-up changes the payload, so it must use a new admission ID.
if (value.mode === "normal" && restored.prompt === submission.prompt) {
restored.target.retry.set({
@@ -395,7 +338,6 @@ function restoreSubmission(
variant: value.selection.variant,
})
}
if (!submission.current(input.adapter.state)) return true
input.comments.restore(comments)
@@ -403,13 +345,11 @@ function restoreSubmission(
input.closePopover()
requestAnimationFrame(() => {
const editor = input.editor()
if (!editor) return
editor.focus()
setCursorPosition(editor, promptLength(value.prompt))
input.queueScroll()
})
return true
}
@@ -419,9 +359,7 @@ async function sendShell(session: ComposerSession, value: ComposerSubmission) {
function findCommand(commands: ReturnType<ComposerSubmitInput["commands"]>, text: string) {
const parsed = parseSlashCommand(text)
if (!parsed || !commands?.some((item) => item.name === parsed.name)) return
return { command: parsed.name, arguments: parsed.input }
}
@@ -432,19 +370,13 @@ async function sendCommand(
track?: ModelSelection["trackSessionCommit"],
) {
const request = await buildSubmissionRequest(session, value)
// Like queued prompts, queued commands must not apply the composer's selection to active work.
if (value.delivery === "steer") await applySelection(session, value.selection, track)
await session.api.command({
sessionID: session.id,
name: command.command,
text: command.arguments,
files: request.files.map((file) => ({
uri: file.uri,
name: file.name,
description: file.description,
mention: file.mention,
})),
files: request.files.map((file) => ({ uri: file.uri, name: file.name, mention: file.mention })),
agents: request.agents,
skills: request.skills,
delivery: value.delivery,
@@ -457,14 +389,11 @@ async function applySelection(
track?: ModelSelection["trackSessionCommit"],
) {
const cancel = track?.(session.id, selection)
try {
const current = session.current()
if (current?.agent !== selection.agent) {
await session.api.switchAgent({ sessionID: session.id, agent: selection.agent })
}
// The server deduplicates unchanged selections; cached SSE state may still be behind an earlier switch.
await session.api.switchModel({
sessionID: session.id,
@@ -483,7 +412,6 @@ async function sendPrompt(
onAdmit: () => void,
) {
const request = await buildSubmissionRequest(session, value)
// Switching agent or model reconfigures the session immediately, and with it
// the remainder of a running turn. A steer targets that turn, so its
// selection applies now; a queued follow-up must not reconfigure the turn it
@@ -498,12 +426,7 @@ async function sendPrompt(
sessionID: session.id,
delivery: value.delivery,
text: request.text,
files: request.files.map((file) => ({
uri: file.uri,
name: file.name,
description: file.description,
mention: file.mention,
})),
files: request.files.map((file) => ({ uri: file.uri, name: file.name, mention: file.mention })),
agents: request.agents,
skills: request.skills,
metadata: {
@@ -511,10 +434,12 @@ async function sendPrompt(
comments: request.comments,
attachments: request.attachments,
agent: value.selection.agent,
model: selectionModel(value.selection),
model: {
...value.selection.model,
...(value.selection.variant ? { variant: value.selection.variant } : {}),
},
},
}
const sending = session.data.session.prompt(admission).catch(() => session.data.session.prompt(admission))
onAdmit()
await sending
@@ -527,7 +452,6 @@ async function buildSubmissionRequest(session: ComposerSession, value: ComposerS
dataUrl: await blobDataUrl(attachment.blob, attachment.mime),
})),
)
return buildPromptRequest({
prompt: value.prompt,
context: value.context,
@@ -541,14 +465,12 @@ function failSubmission(
input: ComposerSubmitInput,
session: ComposerSession,
kind: "shell" | "command" | "prompt",
// oxlint-disable-next-line anti-slop/no-unknown-parameters -- a rejected send is opaque; the notifier formats it
error: unknown,
restore: () => boolean,
messageID?: string,
rollback?: () => void,
) {
if (messageID && session.admitted(messageID)) return
if (messageID) session.handoff?.clear(messageID)
rollback?.()
restore()
+20 -12
View File
@@ -77,23 +77,31 @@ if (root instanceof HTMLElement && root.dataset.opencodeMounted === undefined) {
const standalone = isStandalone()
root.dataset.standalone = String(standalone)
if (standalone) restorePwaRoute()
const server: ServerConnection.Http | undefined = web.currentServerUrl
? {
type: "http",
authToken: !!auth,
http: {
url: web.currentServerUrl,
...auth,
},
}
: undefined
const currentServer = web.currentServerUrl ? ServerConnection.Key.make(web.currentServerUrl) : undefined
const server: ServerConnection.Http | undefined =
web.currentServerUrl && auth
? {
type: "http",
authToken: true,
http: {
url: web.currentServerUrl,
...auth,
},
}
: undefined
render(
() => (
<PlatformProvider value={web.platform}>
<AppBaseProviders locale={locale}>
<AppInterface
defaultServer={web.defaultServerUrl ? ServerConnection.Key.make(web.defaultServerUrl) : undefined}
canonicalLocalServer={server ? ServerConnection.key(server) : undefined}
defaultServer={
web.defaultServerUrl
? ServerConnection.Key.make(web.defaultServerUrl)
: server
? currentServer
: undefined
}
canonicalLocalServer={currentServer}
servers={server ? [server] : []}
>
<KeyboardInsets />
+5 -21
View File
@@ -3,7 +3,7 @@ import { useSettingsSurface } from "@/settings/surface"
import { useTabs, type DraftTab } from "@/shell/tabs/tabs"
import { useSettingsServers } from "@/settings/servers/inventory"
import { useSearchParams } from "@solidjs/router"
import { createEffect, createMemo, createResource, on, untrack } from "solid-js"
import { createEffect, createMemo, createResource, untrack } from "solid-js"
import { createComposerModel } from "@/composer/model"
import { useComposerCommands } from "@/composer/commands"
import { createNewSessionComposerAdapter } from "./composer-adapter"
@@ -18,23 +18,17 @@ export default function NewSessionPage(props: { draftId: string }) {
const tabs = useTabs()
const servers = useSettingsServers()
const settingsSurface = useSettingsSurface()
const draftTab = createMemo(() =>
tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === search.draftId),
)
const openWorkspaces = () => {
const draft = draftTab()
if (servers().length > 1 && draft) {
settingsSurface.openServer(draft.server, "workspaces")
return
}
settingsSurface.open("workspaces")
}
const workspace = createNewSessionWorkspaceController({
selectedWorktree: () => draftTab()?.worktree,
selectedBranch: () => draftTab()?.branch,
@@ -46,9 +40,7 @@ export default function NewSessionPage(props: { draftId: string }) {
},
onViewAll: openWorkspaces,
})
const mcp = createDraftMcpControls({ draftID: props.draftId, worktree: workspace.selection.value })
const composer = createNewSessionComposerAdapter({
draftID: props.draftId,
worktree: workspace.selection.value,
@@ -56,15 +48,12 @@ export default function NewSessionPage(props: { draftId: string }) {
submitted: workspace.selection.remember,
mcp,
})
const model = createComposerModel(composer.adapter)
useComposerCommands({ model: composer.model })
const project = createPromptProjectController({
controls: composer.project,
onDone: model.restoreFocus,
})
useNewSessionCommands({
restoreFocus: model.restoreFocus,
project: {
@@ -76,25 +65,20 @@ export default function NewSessionPage(props: { draftId: string }) {
cycle: workspace.selection.cycle,
},
})
// Focus once the draft loads. restoreFocus reads the stored cursor, so tracking it here would reapply
// a stale cursor a frame after every key-up and yank the caret from a key pressed in that frame.
createEffect(
on(composer.ready, (ready) => {
if (ready) model.restoreFocus()
}),
)
createEffect(() => {
if (!composer.ready()) return
model.restoreFocus()
})
createEffect(() => {
if (!composer.ready()) return
untrack(() => {
const text = search.prompt
if (!text) return
composer.adapter.state.set([{ type: "text", content: text, start: 0, end: text.length }], text.length)
setSearch({ ...search, prompt: undefined })
})
})
const ready = Promise.resolve()
const [suspendUntilPromptReady] = createResource(
() => composer.ready.promise ?? ready,
(promise) => promise.then(() => true),
@@ -14,6 +14,7 @@ import { Mark } from "@opencode/ui/logo"
import { Keybind } from "@opencode/ui/keybind"
import { Tooltip } from "@opencode/ui/tooltip"
import { Menu } from "@opencode/ui/menu"
import { SessionReviewV2SidebarToggle } from "@opencode/session-ui/v2/session-review-v2"
import {
MenuItem,
type MountedSession,
@@ -159,6 +160,13 @@ export function SideRegion(props: {
onCleanup(createTabStripScroll({ el, lead: props.region.lead }))
}}
>
<div class="session-review-v2-sidebar-toggle-slot h-full shrink-0 sticky start-0 z-10 flex items-center justify-center bg-v2-background-bg-base">
<SessionReviewV2SidebarToggle
opened={props.sidebar.opened()}
disabled={props.region.selected()?.tab.sidebar === "locked"}
onToggle={props.sidebar.toggle}
/>
</div>
<For each={props.region.keys()}>
{(key) => (
<Show when={props.region.entry(key)}>
+3 -15
View File
@@ -2,13 +2,6 @@ import { DESKTOP_NATIVE_ENGLISH } from "./desktop-native"
export const dict = {
...DESKTOP_NATIVE_ENGLISH,
"session.location.unavailable": "Session location unavailable",
"session.location.description": "Choose another directory to continue this session.",
"session.location.choose": "Choose directory",
"session.location.worktree": "Choose worktree",
"session.location.worktreesFailed": "Failed to load worktrees",
"session.location.moving": "Moving session…",
"session.location.moveFailed": "Failed to move session",
"server.action.authenticate": "Authenticate",
"server.status.connecting": "Connecting over SSH…",
"server.status.authentication": "Authentication required",
@@ -302,7 +295,6 @@ export const dict = {
"prompt.toast.uploadFailed.title": "Upload failed",
"prompt.toast.modelAgentRequired.title": "Select an agent and model",
"prompt.toast.modelAgentRequired.description": "Choose an agent and model before sending a prompt.",
"prompt.toast.unqueueable.title": "This prompt cannot be queued",
"prompt.toast.worktreeCreateFailed.title": "Failed to create worktree",
"prompt.toast.sessionCreateFailed.title": "Failed to create session",
"prompt.toast.shellSendFailed.title": "Failed to send shell command",
@@ -348,6 +340,8 @@ export const dict = {
"server.connect.scan.description": "Point your camera at the QR code shown by opencode pair.",
"server.connect.scan.invalid": "This is not an OpenCode pairing code. Scan the code shown by opencode pair.",
"server.connect.link.expired": "This pairing link expired or was already used. Run opencode pair to get a new one.",
"server.connect.link.failed":
"Could not use this pairing code. Make sure this device can reach the server, then scan a new code.",
"server.connect.camera": "Pairing camera",
"server.connect.camera.starting": "Opening camera…",
"server.connect.mixedContent":
@@ -511,17 +505,11 @@ export const dict = {
"session.queue.remove": "Remove",
"session.queue.undo": "Undo",
"session.queue.undoShell": "Leave shell mode before undoing a queued prompt",
"session.queue.undoUnavailable": "Edit this prompt in the queue to preserve its file context",
"session.queue.reorder": "Reorder queued prompt",
"session.queue.reverted": "Redo the revert before you reorder or edit queued prompts",
"session.queue.attachments.one": "{{count}} attachment",
"session.queue.attachments.other": "{{count}} attachments",
"session.timeline.working": "Working",
"session.running.working.one": "{{count}} working…",
"session.running.working.other": "{{count}} working…",
"session.running.running.one": "{{count}} running",
"session.running.running.other": "{{count}} running",
"session.running.stop.subagent": "Interrupt subagent",
"session.running.stop.shell": "Kill shell command",
"session.files.selectToOpen": "Select a file to open",
+1 -1
View File
@@ -48,7 +48,7 @@ export function createWebPlatform(version: string) {
return {
platform,
currentServerUrl,
defaultServerUrl: storedServerUrl ?? currentServerUrl,
defaultServerUrl: storedServerUrl,
}
}
@@ -1,5 +1,7 @@
import { describe, expect, test } from "bun:test"
import { pairingLink } from "./pairing"
import { pairingLink, redeemPairingLink } from "./pairing"
const mockFetch = (run: () => Promise<Response>) => run as unknown as typeof globalThis.fetch
describe("pairing link", () => {
test("reads the server address and code from opencode pair links", () => {
@@ -17,3 +19,42 @@ describe("pairing link", () => {
expect(pairingLink("192.168.1.2:49374")).toBeUndefined()
})
})
describe("pairing redemption", () => {
test("returns the session token from a successful redemption", async () => {
const result = await redeemPairingLink(
{ url: "https://server.example", code: "fresh" },
mockFetch(() => Promise.resolve(Response.json({ token: "session-token" }))),
)
expect(result).toEqual({
ok: true,
pairing: { url: "https://server.example", password: "session-token" },
})
})
test("reports only an unauthorized response as expired", async () => {
const result = await redeemPairingLink(
{ url: "https://server.example", code: "used" },
mockFetch(() =>
Promise.resolve(
Response.json(
{ _tag: "UnauthorizedError", message: "Pairing link expired or already used" },
{ status: 401 },
),
),
),
)
expect(result).toEqual({ ok: false, reason: "expired" })
})
test("reports transport failures as connection errors", async () => {
const result = await redeemPairingLink(
{ url: "https://server.example", code: "fresh" },
mockFetch(() => Promise.reject(new TypeError("Failed to fetch"))),
)
expect(result).toEqual({ ok: false, reason: "connection" })
})
})
+11 -5
View File
@@ -1,4 +1,4 @@
import { OpenCode } from "@opencode/client/promise"
import { isUnauthorizedError, OpenCode } from "@opencode/client/promise"
import { normalizeServerUrl } from "@/runtime/server/registry"
export function serverAddress(value: string) {
@@ -22,12 +22,18 @@ export function pairingLink(value: string) {
}
export type Pairing = { readonly url: string; readonly password: string }
export type PairingRedemption =
| { readonly ok: true; readonly pairing: Pairing }
| { readonly ok: false; readonly reason: "expired" | "connection" }
export function redeemPairingLink(link: { url: string; code: string }) {
return OpenCode.make({ baseUrl: link.url })
export function redeemPairingLink(link: { url: string; code: string }, fetch?: typeof globalThis.fetch) {
return OpenCode.make({ baseUrl: link.url, fetch })
.server.connect({ code: link.code })
.then(
(session): Pairing => ({ url: link.url, password: session.token }),
() => undefined,
(session): PairingRedemption => ({ ok: true, pairing: { url: link.url, password: session.token } }),
(error): PairingRedemption => ({
ok: false,
reason: isUnauthorizedError(error) ? "expired" : "connection",
}),
)
}
@@ -18,6 +18,12 @@
color: var(--v2-state-fg-danger);
}
.server-connect-scan-alert {
display: flex;
flex-direction: column;
gap: 20px;
}
.server-connect-video {
position: relative;
aspect-ratio: 1;
+50 -21
View File
@@ -4,19 +4,29 @@ import { createStore } from "solid-js/store"
import { Button } from "@opencode/ui/button"
import { useLanguage } from "@/runtime/i18n/language"
import { pairingLink, redeemPairingLink, type Pairing } from "./pairing"
import { isMixedContent } from "./browser"
import "./scanner.css"
export function PairingScanner(props: { onScan: (value: Pairing) => void; onCancel: () => void }) {
const language = useLanguage()
const [state, setState] = createStore({ error: "", ready: false })
const [state, setState] = createStore({ error: "", ready: false, paused: false })
const video = document.createElement("video")
video.setAttribute("aria-label", language.t("server.connect.camera"))
video.setAttribute("playsinline", "")
video.muted = true
let scanner: QrScanner | undefined
const start = () => {
setState({ error: "", ready: false, paused: false })
void scanner?.start().then(
() => setState("ready", true),
() => setState({ error: language.t("server.connect.camera.error"), paused: true }),
)
}
onMount(() => {
// QrScanner hides detached videos, so initialize only after this preview is mounted.
const scanner = new QrScanner(
const instance = new QrScanner(
video,
(result) => {
const link = pairingLink(result.data)
@@ -24,36 +34,55 @@ export function PairingScanner(props: { onScan: (value: Pairing) => void; onCanc
setState("error", language.t("server.connect.scan.invalid"))
return
}
scanner.stop()
scanner?.stop()
void redeemPairingLink(link).then((redeemed) => {
if (redeemed) return props.onScan(redeemed)
setState("error", language.t("server.connect.link.expired"))
if (redeemed.ok) return props.onScan(redeemed.pairing)
setState({
error: language.t(
redeemed.reason === "expired"
? "server.connect.link.expired"
: isMixedContent(location.href, link.url)
? "server.connect.mixedContent"
: "server.connect.link.failed",
),
paused: true,
})
})
},
{ preferredCamera: "environment", maxScansPerSecond: 10, returnDetailedScanResult: true },
)
scanner = instance
// Terminal QR codes can be light-on-dark depending on the terminal theme.
scanner.setInversionMode("both")
onCleanup(() => scanner.destroy())
void scanner.start().then(
() => setState("ready", true),
() => setState("error", language.t("server.connect.camera.error")),
)
instance.setInversionMode("both")
onCleanup(() => instance.destroy())
start()
})
return (
<section class="server-connect-scanner" aria-label={language.t("server.connect.scan")}>
<p>{language.t("server.connect.scan.description")}</p>
<div class="server-connect-video">
{video}
<Show when={!state.ready && !state.error}>
<span role="status">{language.t("server.connect.camera.starting")}</span>
<Show
when={!state.paused}
fallback={
<div class="server-connect-scan-alert" role="alert">
<p class="server-connect-error">{state.error}</p>
<Button variant="contrast" size="large" onClick={start}>
{language.t("common.retry")}
</Button>
</div>
}
>
<p>{language.t("server.connect.scan.description")}</p>
<div class="server-connect-video">
{video}
<Show when={!state.ready && !state.error}>
<span role="status">{language.t("server.connect.camera.starting")}</span>
</Show>
</div>
<Show when={state.error}>
<p class="server-connect-error" role="alert">
{state.error}
</p>
</Show>
</div>
<Show when={state.error}>
<p class="server-connect-error" role="alert">
{state.error}
</p>
</Show>
<Button variant="neutral" size="large" onClick={props.onCancel}>
{language.t("common.cancel")}
@@ -77,21 +77,4 @@
.server-connect-error {
color: var(--v2-state-fg-danger);
}
footer {
text-align: center;
margin-block-start: 16px;
}
code {
display: inline-block;
margin-block-start: 12px;
border: 1px solid var(--v2-border-border-base);
border-radius: 6px;
padding: 8px 16px;
font-size: 13px;
line-height: var(--line-height-base);
background: var(--v2-background-bg-layer-01);
user-select: all;
}
}
+4 -16
View File
@@ -8,7 +8,7 @@ import { useLanguage } from "@/runtime/i18n/language"
import { usePlatform } from "@/runtime/platform/platform"
import { useCheckServerHealth } from "@/runtime/server/health"
import { useServers } from "@/runtime/server/registry"
import { pairingLink, redeemPairingLink, serverAddress } from "./pairing"
import { serverAddress } from "./pairing"
import { isMixedContent } from "./browser"
import { createCameraAvailability } from "./camera"
import "./screen.css"
@@ -30,16 +30,6 @@ export function ConnectServerScreen(props: { url?: string } = {}) {
)
const request = useMutation(() => ({
mutationFn: async () => {
const link = pairingLink(state.url)
if (link) {
const redeemed = await redeemPairingLink(link)
if (!redeemed) {
setState("error", language.t("server.connect.link.expired"))
return
}
// Keep the token in the form so a failed connection check can retry without the spent code.
setState({ url: link.url, password: redeemed.password })
}
const url = serverAddress(state.url)
if (!url) {
setState("error", language.t("server.connect.address.invalid"))
@@ -64,7 +54,9 @@ export function ConnectServerScreen(props: { url?: string } = {}) {
</div>
<header>
<h1 id="server-connect-title">{language.t("server.connect.title")}</h1>
<p>{language.t("server.connect.description")}</p>
<Show when={!state.scanning}>
<p>{language.t("server.connect.description")}</p>
</Show>
</header>
<Show
when={!state.scanning}
@@ -153,10 +145,6 @@ export function ConnectServerScreen(props: { url?: string } = {}) {
</p>
</Show>
</Show>
<footer>
<p>{language.t("server.connect.pair.description")}</p>
<code dir="ltr">opencode pair</code>
</footer>
</Show>
</div>
</main>
@@ -9,7 +9,8 @@ import { showToast } from "@/shell/notifications/toast"
import { useLanguage } from "@/runtime/i18n/language"
import { useServerSDK } from "@/runtime/server/client"
import { base64Encode } from "@opencode/util/encode"
import { extractPromptContext, extractPromptFromMessage } from "@/composer/prompt"
import { commentContextItem } from "@/composer/comment-note"
import { extractPromptComments, extractPromptFromMessage } from "@/composer/prompt"
import { useWorkspaceLocation } from "@/workspaces/location"
import { useServer } from "@/runtime/server/current"
import { sessionHref } from "@/shell/routes/session"
@@ -37,7 +38,6 @@ export const DialogFork: Component = () => {
const messages = createMemo((): ForkableMessage[] => {
const sessionID = params.id
if (!sessionID) return []
const msgs = data.session.message.list(sessionID)
@@ -60,18 +60,13 @@ export const DialogFork: Component = () => {
if (!item) return
const sessionID = params.id
if (!sessionID) return
const message = data.session.message.get(sessionID, item.id)
if (message?.type !== "user") return
const restored = extractPromptFromMessage(message, {
directory: location().directory,
attachmentName: language.t("common.attachment"),
})
const context = extractPromptContext(message, { directory: location().directory })
const dir = base64Encode(location().directory)
serverSDK.api.session
@@ -81,11 +76,11 @@ export const DialogFork: Component = () => {
dialog.close()
const target = prompt.capture({ dir, id: forked.id })
target.set(restored)
target.context.replace([...context.comments, ...context.files])
target.context.replaceComments(extractPromptComments(message).map(commentContextItem))
navigate(sessionHref(server.key, forked.id))
})
.catch((cause: unknown) => {
const message = cause instanceof Error ? cause.message : String(cause)
.catch((err: unknown) => {
const message = err instanceof Error ? err.message : String(err)
showToast({ title: language.t("common.requestFailed"), description: message })
})
}
@@ -6,8 +6,6 @@ import { useLanguage } from "@/runtime/i18n/language"
import { useLayout } from "@/shell/state/layout"
import { useComposerState } from "@/composer/persistence"
import { useServerSDK } from "@/runtime/server/client"
import { useData } from "@/runtime/server/current"
import { formatServerError } from "@/runtime/server/errors"
import { useSettings } from "@/settings/model"
import { showToast } from "@/shell/notifications/toast"
import { fetchSessionExport, saveSessionExport, sessionExportFilename } from "@/session/commands/export"
@@ -36,8 +34,6 @@ export type SessionCommandContext = {
navigateMessageByOffset: (offset: number) => void
revert: Pick<SessionRevert, "undo" | "redo">
focusInput: () => void
// The composer's model, which a compaction runs with.
model: () => { id: string; providerID: string; variant?: string } | undefined
}
const withCategory = (category: string) => {
@@ -54,7 +50,6 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
const language = useLanguage()
const prompt = useComposerState()
const serverSDK = useServerSDK()
const data = useData()
const settings = useSettings()
const platform = usePlatform()
const layout = useLayout()
@@ -248,9 +243,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
if (!sessionID) return
await data.session.compact({ sessionID, model: actions.model() }).catch((cause: unknown) => {
showToast({ title: formatServerError(cause, language.t, language.t("common.requestFailed")) })
})
await serverSDK.api.session.compact({ sessionID })
}
const fork = () => {
@@ -1,142 +0,0 @@
import { Button } from "@opencode/ui/button"
import { useDialog } from "@opencode/ui/context/dialog"
import { DockShell, DockTray } from "@opencode/ui/dock-surface"
import { Icon } from "@opencode/ui/icon"
import { Menu } from "@opencode/ui/menu"
import { getFilename, sameDirectory } from "@opencode/util/path"
import { useMutation } from "@tanstack/solid-query"
import { createEffect, createResource, For, Show } from "solid-js"
import { createStore } from "solid-js/store"
import { useLanguage } from "@/runtime/i18n/language"
import { useData, useServer } from "@/runtime/server/current"
import { showToast } from "@/shell/notifications/toast"
import { createWorktree } from "@/workspaces/create"
import { useDirectoryPicker } from "@/workspaces/selection/picker"
export function SessionLocationMissing(props: { sessionID: string; projectID: string; directory: string }) {
const language = useLanguage()
const server = useServer()
const data = useData()
const dialog = useDialog()
const pickDirectory = useDirectoryPicker()
const [state, setState] = createStore({ restoreFocus: false, worktreesOpen: false })
const project = () => data.project.get(props.projectID)
const [worktrees] = createResource(
() => (state.worktreesOpen ? props.projectID : undefined),
async (projectID) => {
await server.ctx.sync.worktrees.refresh(projectID)
const items = await server.ctx.sync.worktrees.list(projectID)
if (!items) showToast({ variant: "error", title: language.t("session.location.worktreesFailed") })
return items ?? []
},
// Seed latest so even the first fetch does not enter Suspense.
{ initialValue: [] },
)
const otherWorktrees = () =>
worktrees.latest.filter((item) => item.strategy && !sameDirectory(item.directory, props.directory))
let button: HTMLButtonElement | undefined
const move = useMutation(() => ({
mutationFn: async (input: { directory?: string }) => {
// The missing worktree cannot resolve its own Location, so create from the project's saved checkout.
const current = project()
const destination =
input.directory ??
(current &&
(await createWorktree({
api: server.ctx.sdk.api,
data,
directory: current.canonical,
project: { id: current.id, canonical: current.canonical, directory: current.canonical },
})))
if (!destination) return
await server.ctx.sdk.api.session.move({ sessionID: props.sessionID, directory: destination })
},
onError: (error) => {
setState("restoreFocus", true)
showToast({
variant: "error",
title: language.t("session.location.moveFailed"),
description: error instanceof Error ? error.message : language.t("common.requestFailed"),
})
},
}))
createEffect(() => {
if (!state.restoreFocus || move.isPending || dialog.active) return
setState("restoreFocus", false)
button?.focus()
})
function choose() {
if (move.isPending) return
pickDirectory({
server: server.conn,
title: language.t("session.location.choose"),
onSelect: (result) => {
const selected = Array.isArray(result) ? result[0] : result
if (selected) move.mutate({ directory: selected })
if (!selected) setState("restoreFocus", true)
},
})
}
return (
<div data-component="session-location-missing">
<DockShell class="flex flex-col gap-2 p-3">
<div role="status" class="flex items-start gap-2 text-13-regular leading-[var(--line-height-base)]">
<Icon name="warning" class="shrink-0 text-icon-warning-base" />
<div class="min-w-0 flex flex-col gap-1">
<div class="font-medium text-text-strong">{language.t("session.location.unavailable")}</div>
<div class="break-all font-mono text-12-regular text-text-weak">{props.directory}</div>
<div class="text-text-base">{language.t("session.location.description")}</div>
</div>
</div>
</DockShell>
<DockTray attach="top" class="flex flex-wrap justify-end gap-2 p-2 pt-[22px]">
<Show when={project()?.vcs === "git"}>
<Menu placement="top-end" onOpenChange={(open) => setState("worktreesOpen", open)}>
<Menu.Trigger as={Button} variant="neutral" disabled={move.isPending}>
{language.t("session.location.worktree")}
</Menu.Trigger>
<Menu.Portal>
<Menu.Content class="max-h-80 max-w-[calc(100vw-32px)] overflow-y-auto">
<Menu.Item onSelect={() => move.mutate({})} disabled={move.isPending}>
<Icon name="workspace-new" />
{language.t("workspace.new")}
</Menu.Item>
<Show when={worktrees.loading && otherWorktrees().length === 0}>
<Menu.Item disabled>{language.t("common.loading")}</Menu.Item>
</Show>
<For each={otherWorktrees()}>
{(worktree) => (
<Menu.Item
title={worktree.directory}
onSelect={() => move.mutate({ directory: worktree.directory })}
disabled={move.isPending}
>
<Icon name="workspace-isolated" />
<span class="truncate">{getFilename(worktree.directory)}</span>
</Menu.Item>
)}
</For>
</Menu.Content>
</Menu.Portal>
</Menu>
</Show>
<Button ref={button} variant="contrast" onClick={choose} disabled={move.isPending}>
{language.t(move.isPending ? "session.location.moving" : "session.location.choose")}
</Button>
</DockTray>
</div>
)
}
+139 -193
View File
@@ -1,17 +1,16 @@
import { createEffect, createMemo, onCleanup, type Accessor } from "solid-js"
import { createStore } from "solid-js/store"
import { useMutation } from "@tanstack/solid-query"
import { Option, Schema } from "effect"
import type { SessionInboxInfo } from "@opencode/client/promise"
import { SessionMessage } from "@opencode/schema/session-message"
import { Skill } from "@opencode/schema/skill"
import type { ComposerDelivery } from "@/composer/adapter"
import type { ComposerStateTarget } from "@/composer/submission-state"
import type { ContextItem, ImageAttachmentPart, PathAttachmentPart, Prompt } from "@/composer/state"
import type { ImageAttachmentPart, PathAttachmentPart, Prompt } from "@/composer/state"
import { appendPrompt, clonePrompt, isAttachment, promptLength } from "@/composer/prompt-parts"
import { buildPromptRequest } from "@/composer/request"
import { blobDataUrl, createLegacyBlobReference } from "@/runtime/persistence/drafts"
import { readPromptPresentation } from "@/composer/comment-note"
import { extractPromptContext, extractPromptFromMessage } from "@/composer/prompt"
import { useData } from "@/runtime/server/current"
import { useServerSDK } from "@/runtime/server/client"
import { useWorkspaceLocation } from "@/workspaces/location"
@@ -40,12 +39,11 @@ export function createSessionQueue(input: {
const language = useLanguage()
const [state, setState] = createStore<{ editing?: { id: string; stash: EditStash } }>({})
const notify = () => showToast({ title: language.t("common.requestFailed") })
const mutation = useMutation(() => ({
mutationFn: async (
change:
| { type: "reorder"; inboxIDs: string[] }
| { type: "undo"; item: QueuedPrompt; prompt: Prompt; context: ContextItem[] }
| { type: "undo"; item: QueuedPrompt; prompt: Prompt }
| {
type: "edit"
inboxIDs: string[]
@@ -58,25 +56,16 @@ export function createSessionQueue(input: {
},
) => {
if (change.type === "reorder") return rewrite(change.inboxIDs)
if (change.type === "undo") {
await server.api.session.inbox.cancel({ sessionID: input.sessionID, inboxID: change.item.id })
const draft = input.draft.current()
// A prompt of only comments or attachments adds no text, so it needs no paragraph break.
const prompt = !promptLength(draft)
? [...change.prompt, ...draft.filter(isAttachment)]
: promptLength(change.prompt)
? appendPrompt(draft, change.prompt)
: [...clonePrompt(draft), ...change.prompt.filter(isAttachment)]
const prompt = promptLength(draft)
? appendPrompt(draft, change.prompt)
: [...change.prompt, ...draft.filter(isAttachment)]
input.draft.set(prompt, promptLength(prompt))
change.context.forEach((item) => input.draft.context.add(item))
input.restoreFocus(promptLength(prompt))
return
}
const replacement = await editedPromptInput(
input.sessionID,
location().directory,
@@ -84,26 +73,17 @@ export function createSessionQueue(input: {
change.prompt,
change.text,
)
// Admit before cancelling so a failed replacement never discards the original. A queued edit
// rebuilds its position in one rewrite, which leaves the queue unchanged if any admission fails.
const admission = { ...replacement, id: change.replacement, delivery: change.delivery }
if (change.delivery === "queue") {
await rewrite(change.inboxIDs, { original: change.original, admission: { ...admission, resume: false } })
cancelEdit()
return
}
// Like a queued edit, an original the server delivered meanwhile keeps the edit draft instead of sending twice.
const pending = await server.api.session.inbox.list({ sessionID: input.sessionID })
if (!pending.some((item) => item.id === change.original && item.type === "user" && item.delivery === "queue"))
throw new Error("Queued prompt was delivered before the edit")
await data.session.prompt(admission)
// Admit before cancelling so a failed replacement never discards the original.
const admitted = await data.session.prompt({
...replacement,
id: change.replacement,
delivery: change.delivery,
...(change.delivery === "queue" ? { resume: false } : {}),
})
await server.api.session.inbox.cancel({ sessionID: input.sessionID, inboxID: change.original })
cancelEdit()
if (change.delivery === "queue")
await rewrite(change.inboxIDs.map((id) => (id === change.original ? admitted.id : id)))
},
onError: notify,
onSettled: () => data.session.pending.sync(input.sessionID).catch(() => undefined),
@@ -114,10 +94,8 @@ export function createSessionQueue(input: {
.list(input.sessionID)
.filter((item): item is QueuedPrompt => item.type === "user" && item.delivery === "queue"),
)
const rows = createMemo(() => {
const replacement = mutation.isPending ? mutation.variables : undefined
return queuedPromptRows(
queued(),
replacement?.type === "edit" && replacement.delivery === "queue" ? replacement : undefined,
@@ -126,137 +104,78 @@ export function createSessionQueue(input: {
createEffect(() => {
const editing = state.editing
if (!editing || mutation.isPending || queued().some((item) => item.id === editing.id)) return
setState("editing", undefined)
})
onCleanup(() => cancelEdit())
// `replace` substitutes an edited prompt for the original at its position in the same rewrite.
const rewrite = async (
inboxIDs: string[],
replace?: { original: string; admission: Parameters<typeof data.session.prompt>[0] },
) => {
const rewrite = async (inboxIDs: string[]) => {
const pending = await server.api.session.inbox.list({ sessionID: input.sessionID })
if (pending.some((item) => item.delivery === "queue" && item.type !== "user"))
throw new Error("Queued control items block reordering")
const current = pending.filter((item): item is QueuedPrompt => item.type === "user" && item.delivery === "queue")
const ordered = inboxIDs.flatMap((id) => current.filter((item) => item.id === id))
// An edited prompt delivered meanwhile is no longer queued; fail so the edit draft stays.
if (ordered.length !== current.length || (replace && !current.some((item) => item.id === replace.original)))
throw new Error("Queued prompts changed before reordering")
const changed = ordered.findIndex((item, index) => item.id !== current[index]?.id || item.id === replace?.original)
if (ordered.length !== current.length) throw new Error("Queued prompts changed before reordering")
const changed = ordered.findIndex((item, index) => item.id !== current[index]?.id)
if (changed < 0) return
// Existing inbox APIs cannot reorder rows, so replace only the changed suffix. A replacement
// can fail (its file may be gone), so withdraw the ones already sent to keep the queue intact.
// Each ID is recorded before sending: the server can admit a prompt whose response then fails.
const replacements: string[] = []
// Existing inbox APIs cannot reorder rows, so replace only the changed suffix.
for (const item of ordered.slice(changed)) {
const admission =
item.id === replace?.original
? replace.admission
: {
id: SessionMessage.ID.create(),
sessionID: input.sessionID,
text: item.payload.text,
files: item.payload.files?.map((file) => ({
uri: storedFileUri(file),
name: file.name,
description: file.description,
mention: file.mention,
})),
agents: item.payload.agents,
skills: item.payload.skills,
metadata: item.payload.metadata,
delivery: "queue" as const,
resume: false,
}
const id = admission.id ?? SessionMessage.ID.create()
replacements.push(id)
await data.session.prompt({ ...admission, id }).catch(async (error) => {
await Promise.all(
replacements.map((inboxID) =>
server.api.session.inbox.cancel({ sessionID: input.sessionID, inboxID }).catch(() => undefined),
),
)
throw error
await data.session.prompt({
sessionID: input.sessionID,
text: item.payload.text,
files: item.payload.files?.map((file) => ({
uri: `data:${file.mime};base64,${file.data}`,
name: file.name,
description: file.description,
mention: file.mention,
})),
agents: item.payload.agents,
skills: item.payload.skills,
metadata: item.payload.metadata,
delivery: "queue",
resume: false,
})
}
for (const item of current.slice(changed)) {
await server.api.session.inbox.cancel({ sessionID: input.sessionID, inboxID: item.id })
}
}
const steer = (id: string) => {
if (state.editing?.id === id) cancelEdit()
return server.api.session.inbox
.update({ sessionID: input.sessionID, inboxID: id, delivery: "steer" })
.catch(() => notify())
}
const remove = (id: string) => {
if (state.editing?.id === id) cancelEdit()
return server.api.session.inbox.cancel({ sessionID: input.sessionID, inboxID: id }).catch(() => notify())
}
const undo = (id: string) => {
if (mutation.isPending || state.editing) return
const item = queued().find((entry) => entry.id === id)
if (!item) return
if (input.draft.mode.current() !== "normal") {
showToast({ title: language.t("session.queue.undoShell") })
return
}
const source = { id: item.id, ...item.payload }
const context = extractPromptContext(source, { directory: location().directory })
mutation.mutate({
type: "undo",
item,
prompt: extractPromptFromMessage(source, {
directory: location().directory,
attachmentName: language.t("common.attachment"),
}),
context: [...context.comments, ...context.files],
})
const prompt = queuedPromptUndoDraft(item)
if (!prompt) {
showToast({ title: language.t("session.queue.undoUnavailable") })
return
}
mutation.mutate({ type: "undo", item, prompt })
}
// Re-admitting a queued prompt commits a staged revert, which drops every prompt queued after its boundary.
const refuseReverted = () => {
if (!data.session.get(input.sessionID)?.revert) return false
showToast({ title: language.t("session.queue.reverted") })
return true
}
const reorder = (inboxIDs: string[]) => {
if (mutation.isPending || refuseReverted()) return Promise.resolve()
if (mutation.isPending) return Promise.resolve()
return mutation.mutateAsync({ type: "reorder", inboxIDs }).catch(() => undefined)
}
const edit = (id: string) => {
if (mutation.isPending || refuseReverted()) return false
if (mutation.isPending) return false
if (state.editing?.id === id) return true
const item = queued().find((entry) => entry.id === id)
if (!item) return false
if (state.editing) cancelEdit()
const draft = input.draft.current()
setState("editing", {
@@ -275,42 +194,33 @@ export function createSessionQueue(input: {
text.length,
)
input.restoreFocus(text.length)
return true
}
const cancelEdit = () => {
const editing = state.editing
if (!editing) return
setState("editing", undefined)
// Mode first, then prompt, then retry: mode and prompt writes both clear
// the retry marker.
input.draft.mode.set(editing.stash.mode)
input.draft.set(editing.stash.prompt, editing.stash.cursor)
if (editing.stash.retry) input.draft.retry.set(editing.stash.retry)
input.restoreFocus(editing.stash.cursor)
}
const confirmEdit = (delivery: ComposerDelivery) => {
const editing = state.editing
if (!editing || mutation.isPending || refuseReverted()) return
if (!editing || mutation.isPending) return
const prompt = clonePrompt(input.draft.current())
const text = prompt.map((part) => ("content" in part ? part.content : "")).join("")
const attachments = prompt.filter(isAttachment)
if (!text.trim() && !attachments.length) return cancelEdit()
const item = queued().find((entry) => entry.id === editing.id)
const original = item ? queuedPromptAttachments(item) : []
const pristine =
item &&
text.trim() === queuedPromptText(item) &&
attachments.length === original.length &&
attachments.every((attachment, index) => attachment.id === original[index].id)
if (pristine && delivery === "queue") return cancelEdit()
mutation.mutate({
type: "edit",
@@ -323,12 +233,9 @@ export function createSessionQueue(input: {
delivery,
})
}
const editFirst = () => {
const first = queued()[0]
if (!first) return false
return edit(first.id)
}
@@ -337,9 +244,7 @@ export function createSessionQueue(input: {
delivery: () => (input.working() ? input.behavior() : "steer"),
alternate: () => {
if (state.editing) return "steer"
if (!input.working()) return undefined
return input.behavior() === "queue" ? "steer" : "queue"
},
editing: () => state.editing?.id,
@@ -368,29 +273,19 @@ export type SessionQueueView = Pick<
export function queuedPromptRows(items: QueuedPrompt[], replacement?: { original: string; replacement: string }) {
const replaced = replacement && items.some((item) => item.id === replacement.replacement)
return items.flatMap((item) =>
replaced && item.id === replacement.original
? []
: [
{
id: item.id,
text: queuedPromptText(item),
attachments:
(item.payload.files?.length ?? 0) +
(readPromptPresentation(item.payload.metadata)?.attachments.length ?? 0),
},
],
)
return items
.filter((item) => !replaced || item.id !== replacement.original)
.map((item) => ({
id: item.id,
text: queuedPromptText(item),
attachments:
(item.payload.files?.length ?? 0) + (readPromptPresentation(item.payload.metadata)?.attachments.length ?? 0),
}))
}
const decodeDisplayText = Schema.decodeUnknownOption(Schema.Struct({ displayText: Schema.NonEmptyString }))
export function queuedPromptText(item: QueuedPrompt) {
return Option.match(decodeDisplayText(item.payload.metadata), {
onNone: () => item.payload.text,
onSome: (metadata) => metadata.displayText,
})
const display = item.payload.metadata?.["displayText"]
return typeof display === "string" && display.length > 0 ? display : item.payload.text
}
// Inline attachments are the files the composer added itself, so they return
@@ -422,10 +317,86 @@ export function queuedPromptAttachments(item: QueuedPrompt): (ImageAttachmentPar
]
}
// A stored file re-admits by the URI it came from, as the TUI does, so a file:// reference keeps its
// provenance (review comment files stay recognizable) instead of turning into an inline snapshot.
function storedFileUri(file: NonNullable<QueuedPrompt["payload"]["files"]>[number]) {
return file.source.type === "uri" ? file.source.uri : `data:${file.mime};base64,${file.data}`
// Use the full model-visible text so comment notes and path references remain
// in the draft. Convert mentioned files, agents, and skills back into editor
// parts; a detached draft cannot represent non-mentioned file context.
function queuedPromptUndoDraft(item: QueuedPrompt): Prompt | undefined {
if (
item.payload.files?.some((file) => !isComposerAttachment(file) && !file.mention) ||
item.payload.agents?.some((agent) => !agent.mention) ||
item.payload.skills?.some((skill) => !skill.mention)
)
return
const text = item.payload.text
const references = [
...(item.payload.files ?? []).flatMap((file) =>
file.mention
? [
{
type: "file" as const,
content: file.mention.text,
start: file.mention.start,
end: file.mention.end,
path: file.name ?? file.mention.text.replace(/^@/, ""),
filename: file.name,
mime: file.mime,
url: `data:${file.mime};base64,${file.data}`,
},
]
: [],
),
...(item.payload.agents ?? []).flatMap((agent) =>
agent.mention
? [
{
type: "agent" as const,
content: agent.mention.text,
start: agent.mention.start,
end: agent.mention.end,
name: agent.name,
},
]
: [],
),
...(item.payload.skills ?? []).flatMap((skill) =>
skill.mention
? [
{
type: "skill" as const,
content: skill.mention.text,
start: skill.mention.start,
end: skill.mention.end,
id: Skill.ID.make(skill.id),
name: Skill.Name.make(skill.name),
},
]
: [],
),
].sort((left, right) => left.start - right.start)
if (
references.some(
(part, index) =>
part.start < (references[index - 1]?.end ?? 0) || text.slice(part.start, part.end) !== part.content,
)
)
return
const parts: Prompt = references.flatMap((part, index) => {
const start = references[index - 1]?.end ?? 0
return [
...(part.start > start
? [{ type: "text" as const, content: text.slice(start, part.start), start, end: part.start }]
: []),
part,
]
})
const start = references.at(-1)?.end ?? 0
return [
...parts,
...(text.length > start || !parts.length
? [{ type: "text" as const, content: text.slice(start), start, end: text.length }]
: []),
...queuedPromptAttachments(item).filter((part) => part.type === "image"),
]
}
function isComposerAttachment(file: NonNullable<QueuedPrompt["payload"]["files"]>[number]) {
@@ -451,76 +422,51 @@ async function editedPromptInput(
.filter((part): part is ImageAttachmentPart => part.type === "image")
.map(async (part) => ({ ...part, dataUrl: await blobDataUrl(part.blob, part.mime) })),
)
const request = buildPromptRequest({ prompt, context: [], images, text, sessionDirectory: directory })
const payload = item?.payload
const display = item ? queuedPromptText(item) : ""
const notes = payload && display && payload.text.startsWith(display) ? payload.text.slice(display.length) : ""
const mention = (value: { start: number; end: number; text: string } | undefined) => {
if (!value) return undefined
const start = text.indexOf(value.text)
if (start < 0) return undefined
return { text: value.text, start, end: start + value.text.length }
}
// Structured mentions degrade to plain text in the editor, so an original
// agent or skill reference survives the edit as long as its mention text
// still appears; newly typed structured mentions come from the request.
const agents = [
...(payload?.agents?.filter(
(agent) =>
(!agent.mention || text.includes(agent.mention.text)) &&
agent.mention &&
text.includes(agent.mention.text) &&
!request.agents.some((entry) => entry.name === agent.name),
) ?? []),
...request.agents,
]
const skills = [
...(payload?.skills?.filter(
(skill) =>
(!skill.mention || text.includes(skill.mention.text)) && !request.skills.some((entry) => entry.id === skill.id),
skill.mention && text.includes(skill.mention.text) && !request.skills.some((entry) => entry.id === skill.id),
) ?? []),
...request.skills,
]
return {
sessionID,
text: request.text + notes,
files: [
// A stored file whose mention the edit deleted is dropped; unmentioned context stays.
...(payload?.files
?.filter(
(file) =>
!isComposerAttachment(file) &&
(!file.mention || text.includes(file.mention.text)) &&
!request.files.some((entry) => entry.uri === storedFileUri(file)),
)
?.filter((file) => !isComposerAttachment(file))
.map((file) => ({
uri: storedFileUri(file),
uri: `data:${file.mime};base64,${file.data}`,
name: file.name,
description: file.description,
mention: mention(file.mention),
})) ?? []),
...request.files.map((file) => ({
uri: file.uri,
name: file.name,
description: file.description,
mention: file.mention,
})),
...request.files.map((file) => ({ uri: file.uri, name: file.name, mention: file.mention })),
],
agents: agents.map((agent) => ({ name: agent.name, mention: mention(agent.mention) })),
skills: skills.map((skill) => ({ id: skill.id, mention: mention(skill.mention) })),
// Presentation metadata reads only with a comments list, which prompts from other clients lack.
// Comments survive while their notes stay out of the edited text; when the editor showed the
// notes as text (a prompt with no display text), the edit owns them and the comments go.
metadata: {
...payload?.metadata,
comments: notes ? (payload?.metadata?.["comments"] ?? []) : [],
displayText: request.displayText,
attachments: request.attachments,
},
metadata: { ...payload?.metadata, displayText: request.displayText, attachments: request.attachments },
}
}
+12 -111
View File
@@ -1,10 +1,10 @@
import type { SessionUserActions } from "@opencode/session-ui/message"
import { getFilename, sameDirectory } from "@opencode/util/path"
import { getFilename } from "@opencode/util/path"
import { useDialog } from "@opencode/ui/context/dialog"
import { isScrollKeyTarget, scrollKey, scrollKeyOwner } from "@opencode/ui/scroll-view"
import { makeEventListener } from "@solid-primitives/event-listener"
import { useNavigate } from "@solidjs/router"
import { createEffect, createMemo, on, onMount, Show, type Accessor } from "solid-js"
import { createEffect, createMemo, on, onMount, type Accessor } from "solid-js"
import { Composer } from "@/composer/composer"
import { useComposerState } from "@/composer/persistence"
import { createComposerControls } from "@/composer/selection"
@@ -12,9 +12,6 @@ import { setCursorPosition } from "@/composer/editor/dom"
import { promptLength } from "@/composer/prompt-parts"
import { useCommand } from "@/shell/commands/command"
import { useLanguage } from "@/runtime/i18n/language"
import { useData } from "@/runtime/server/current"
import { useServerSDK } from "@/runtime/server/client"
import { showToast } from "@/shell/notifications/toast"
import { useLocal } from "@/providers/models/selection"
import { usePlatform } from "@/runtime/platform/platform"
import { useWorkspaceLocation } from "@/workspaces/location"
@@ -32,7 +29,6 @@ import { SessionQueuePanel } from "./queue-panel"
import { resolveSessionComposerSelection } from "./selection"
import { createSessionRequestModel } from "../requests/model"
import type { Region } from "@/runtime/extension/panels"
import { SessionLocationMissing } from "./location-missing"
export function createActiveSessionRegion(input: {
session: SessionModel
@@ -42,8 +38,6 @@ export function createActiveSessionRegion(input: {
visible: Accessor<boolean>
}) {
const command = useCommand()
const data = useData()
const server = useServerSDK()
const dialog = useDialog()
const language = useLanguage()
const local = useLocal()
@@ -62,7 +56,6 @@ export function createActiveSessionRegion(input: {
const message = input.timeline.lastUserMessage()
const info = input.session.data.info()
const selection = resolveSessionComposerSelection(info, message?.metadata)
if (info && selection.agent && selection.model) {
local.session.restore({ sessionID: info.id, agent: selection.agent, model: selection.model })
}
@@ -71,7 +64,6 @@ export function createActiveSessionRegion(input: {
)
createEffect(() => {
const id = input.session.identity.params.id
if (!id || !prompt.ready() || !local.session.ready()) return
// Prompt model is a submission mirror. Local drafts and durable session state own selection.
syncPromptModel(local, prompt)
@@ -81,7 +73,6 @@ export function createActiveSessionRegion(input: {
() => ({ directory: location().directory, id: input.session.identity.params.id }),
(next, previous) => {
if (!previous || (next.directory === previous.directory && next.id === previous.id)) return
if (previous.id && !next.id) local.session.reset()
},
{ defer: true },
@@ -90,55 +81,42 @@ export function createActiveSessionRegion(input: {
const openAttachment: NonNullable<SessionUserActions["openAttachment"]> = (file) => {
const url = file.source.type === "uri" ? file.source.uri : `data:${file.mime};base64,${file.data}`
const download = () => {
const anchor = document.createElement("a")
anchor.href = url
anchor.download = getFilename(file.name) || "attachment"
anchor.click()
}
const path = file.name ?? ""
const absolute = path.startsWith("/") || path.startsWith("\\\\") || /^[a-zA-Z]:[\\/]/.test(path)
if (!platform.revealPath || !absolute) return download()
void platform.revealPath(path).then((revealed) => {
if (!revealed) download()
}, download)
}
const focus = () => {
if (!input.session.data.isChild()) promptRef?.focus()
}
const openParent = () => {
const id = input.session.data.parentID()
if (id) navigate(sessionHref(requireServerKey(input.session.identity.params.serverKey), id))
}
const editable = (target: EventTarget | null | undefined) => {
if (!(target instanceof HTMLElement)) return false
return /^(INPUT|TEXTAREA|SELECT|BUTTON)$/.test(target.tagName) || target.isContentEditable
}
const activeElement = () => {
let current: Element | null = document.activeElement
while (current instanceof HTMLElement && current.shadowRoot?.activeElement) {
current = current.shadowRoot.activeElement
}
return current instanceof HTMLElement ? current : undefined
}
const handleKeyDown = (event: KeyboardEvent) => {
if (event.defaultPrevented) return
const path = event.composedPath()
const target = path.find((item): item is HTMLElement => item instanceof HTMLElement)
const active = activeElement()
if (
path.some((item) => item instanceof HTMLElement && item.closest("[data-prevent-autofocus]") !== null) ||
editable(target) ||
@@ -147,71 +125,34 @@ export function createActiveSessionRegion(input: {
) {
return
}
if (event.key === "Escape" && input.session.data.isChild()) {
event.preventDefault()
openParent()
return
}
if (active === promptRef) {
if (event.key === "Escape") promptRef?.blur()
return
}
const key = scrollKey(event)
if (key) {
const scroller = input.timeline.scroller()
if (!scroller || !isScrollKeyTarget(target ?? null, key)) return
if (scrollKeyOwner(scroller, target ?? null, key) !== scroller) return
input.timeline.view.markUserScroll(scroller)
return
}
if (event.key.length !== 1 || event.key === "Unidentified" || event.ctrlKey || event.metaKey) return
if (state.blocked() || input.session.data.isChild() || !promptRef) return
promptRef.focus()
setCursorPosition(promptRef, prompt.cursor() ?? promptLength(prompt.current()))
}
onMount(() => makeEventListener(document, "keydown", handleKeyDown))
const revert = createSessionRevert({
session: input.session,
setActiveMessage: input.timeline.actions.setActiveMessage,
})
const revertMessage: NonNullable<SessionUserActions["revert"]> = ({ messageID }) => revert.to(messageID)
const notifyFailed = () => {
showToast({ title: language.t("common.requestFailed") })
}
const pendingSteer: NonNullable<SessionUserActions["pending"]> = {
steer: (messageID) => {
const sessionID = input.session.identity.params.id
if (!sessionID) return false
return data.session.pending
.list(sessionID)
.some((item) => item.id === messageID && item.type === "user" && item.delivery === "steer")
},
queue: ({ sessionID, messageID }) =>
server.api.session.inbox
.update({ sessionID, inboxID: messageID, delivery: "queue" })
.then(() => undefined, notifyFailed),
remove: ({ sessionID, messageID }) =>
server.api.session.inbox.cancel({ sessionID, inboxID: messageID }).then(() => undefined, notifyFailed),
}
useComposerCommands()
useSessionCommands({
session: input.session,
@@ -223,12 +164,6 @@ export function createActiveSessionRegion(input: {
navigateMessageByOffset: input.timeline.actions.navigateMessage,
revert,
focusInput: focus,
model: () => {
const selection = controls().model.selection
const model = selection.current()
return model ? { id: model.id, providerID: model.provider.id, variant: selection.variant.current() } : undefined
},
})
command.register("session-palette", () => [
{
@@ -250,7 +185,6 @@ export function createActiveSessionRegion(input: {
},
setDockRef: input.timeline.view.setDockRef,
}
const active = createMemo(
on(
() => (input.visible() ? input.session.identity.sessionID() : undefined),
@@ -268,14 +202,8 @@ export function createActiveSessionRegion(input: {
timeline: {
get revert() {
if (input.session.data.isChild()) return
return revertMessage
},
get pending() {
if (input.session.data.isChild()) return
return pendingSteer
},
openAttachment,
} satisfies SessionUserActions,
},
@@ -286,51 +214,24 @@ export function createActiveSessionRegion(input: {
export type ActiveSessionRegionModel = ReturnType<typeof createActiveSessionRegion>
export function ActiveSessionComposerRegion(props: {
session: SessionModel
model: SessionComposerController
suggestionBoundary: () => HTMLElement | undefined
}) {
const location = useWorkspaceLocation()
// Only the server's LocationNotFoundError for the session's own Location replaces the composer.
const missing = createMemo(() => {
const ref = location().missing
const info = props.session.data.info()
if (!ref || !info || !sameDirectory(info.location.directory, ref.directory)) return
return { directory: info.location.directory, sessionID: info.id, projectID: info.projectID }
})
return (
<SessionComposerRegion
controller={props.model.region}
composer={
<Show
when={missing()}
keyed
fallback={
<div class="relative">
<SessionQueuePanel queue={props.model.queue} />
<div class="relative z-10">
<Composer
model={props.model.composer}
borderUnderlay
readOnly={props.model.queue.undoing()}
suggestionBoundary={props.suggestionBoundary}
/>
</div>
</div>
}
>
{(current) => (
<SessionLocationMissing
sessionID={current.sessionID}
projectID={current.projectID}
directory={current.directory}
<div class="relative">
<SessionQueuePanel queue={props.model.queue} />
<div class="relative z-10">
<Composer
model={props.model.composer}
borderUnderlay
readOnly={props.model.queue.undoing()}
suggestionBoundary={props.suggestionBoundary}
/>
)}
</Show>
</div>
</div>
}
/>
)
@@ -1,204 +0,0 @@
import type { BackgroundTask } from "@opencode/gui-extensions/sdk"
import { SessionProgressIndicatorV2 } from "@opencode/session-ui/v2/session-progress-indicator-v2"
import { Icon } from "@opencode/ui/icon"
import { IconButton } from "@opencode/ui/icon-button"
import { Menu } from "@opencode/ui/menu"
import { TextShimmer } from "@opencode/ui/text-shimmer"
import { createMemo, For, Show } from "solid-js"
import { createStore } from "solid-js/store"
import { useLanguage } from "@/runtime/i18n/language"
import { useServerSDK } from "@/runtime/server/client"
import { useServer } from "@/runtime/server/current"
import { useOpenSessionRoute } from "@/session/session-identity-header"
import { errorMessage } from "@/shell/layout/helpers"
import { showToast } from "@/shell/notifications/toast"
const neutral = "light-dark(var(--v2-text-text-base), #ffffff)"
type RunningItem = {
key: string
type: "subagent" | "shell"
label?: string
agent?: string
sessionID?: string
target: string
}
export function SessionRunningMenu(props: {
sessionID?: string
// The session whose running work is listed: this one, or its parent inside a subagent.
owner?: string
blocking: readonly { type: "shell" | "subagent"; partID: string; id?: string; label?: string }[]
tasks: readonly BackgroundTask[]
onReveal: (target: string) => void
}) {
const language = useLanguage()
const openRoute = useOpenSessionRoute()
const server = useServer()
const sdk = useServerSDK()
const [menu, setMenu] = createStore({ open: false })
const sessionAgent = (id: string | undefined) => (id ? server.ctx.data.session.get(id)?.agent : undefined)
// Foreground shells stay out: the timeline already shows them at the bottom.
const items = createMemo<RunningItem[]>(() => [
...props.blocking.flatMap((task) =>
task.type === "subagent"
? [
{
...task,
key: task.id ?? task.partID,
agent: sessionAgent(task.id),
sessionID: task.id,
target: task.partID,
},
]
: [],
),
...props.tasks.flatMap((task) =>
task.type === "subagent"
? [{ ...task, key: task.id, agent: task.agent ?? sessionAgent(task.id), sessionID: task.id, target: task.id }]
: [],
),
...props.tasks.flatMap((task) => (task.type === "shell" ? [{ ...task, key: task.id, target: task.id }] : [])),
])
const label = createMemo(() => {
const count = items().length
if (items().some((item) => item.type === "shell")) return language.plural("session.running.running", count)
return language.plural("session.running.working", count)
})
// Inside a subagent, its own row is listed among its siblings.
const viewing = (item: RunningItem) => !!item.sessionID && item.sessionID === props.sessionID
const open = (item: RunningItem) => {
const current = props.sessionID
if (!current || viewing(item)) return
if (item.sessionID) return openRoute(current, item.sessionID)
// Shell calls and starting subagent calls live in the owner's timeline, which reveals them once open.
if (props.owner && props.owner !== current) return openRoute(current, props.owner, item.target)
props.onReveal(item.target)
}
// A subagent can only be interrupted once its child session exists.
const stoppable = (item: RunningItem) => item.type === "shell" || !!item.sessionID
const stop = (item: RunningItem) => {
const request = item.sessionID
? sdk.api.session.interrupt({ sessionID: item.sessionID })
: sdk.api.shell.remove({
id: item.target,
location: { directory: server.ctx.data.shell.get(item.target)?.location.directory },
})
void request.catch((error) =>
showToast({
title: language.t("common.requestFailed"),
description: errorMessage(error, language.t("common.requestFailed")),
}),
)
}
return (
<Show when={items().length > 0}>
<Menu gutter={6} placement="bottom-start" open={menu.open} onOpenChange={(open) => setMenu("open", open)}>
<Menu.Trigger
as="button"
type="button"
aria-label={label()}
// 8px from the ··· menu, or from the padded title inside a subagent, which has no menu.
class={`${props.owner === props.sessionID ? "ms-1.5" : "ms-0.5"} flex h-7 shrink-0 items-center rounded-[6px] px-2 text-[13px] font-[530] leading-text-compact tracking-[-0.04px] whitespace-nowrap text-v2-text-text-base outline-none hover:bg-v2-overlay-simple-overlay-hover focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-v2-border-border-focus data-[expanded]:bg-v2-overlay-simple-overlay-hover`}
>
<TextShimmer text={label()} active />
</Menu.Trigger>
<Menu.Portal>
<Menu.Content class="w-60" aria-label={label()}>
<For each={items()}>
{(item) => (
<Menu.Item
class="group/running-item"
classList={{ "!bg-v2-overlay-simple-overlay-hover": viewing(item) }}
aria-current={viewing(item) ? "page" : undefined}
onSelect={() => {
// Close before navigating: the router's transition would hold the close until this
// timeline detaches, and the menu would flash at the viewport origin.
setMenu("open", false)
open(item)
}}
onKeyDown={(event) => {
if ((event.key !== "Delete" && event.key !== "Backspace") || !stoppable(item)) return
event.preventDefault()
stop(item)
}}
>
<Show
when={item.type === "subagent"}
fallback={<Icon name="console" class="shrink-0 text-v2-icon-icon-muted" />}
>
{/* Built-in agents have theme tokens; any other agent keeps the neutral color. */}
<SessionProgressIndicatorV2
class="shrink-0"
style={{
color: item.agent ? `var(--v2-agent-${item.agent.toLowerCase()}-solid, ${neutral})` : neutral,
}}
/>
</Show>
<span class="shrink-0 font-[530]">
{item.type === "shell"
? language.t("ui.tool.shell")
: item.agent
? `${item.agent[0].toUpperCase()}${item.agent.slice(1)}`
: language.t("ui.tool.agent.default")}
</span>
{/* Menu rows end 6px in for trailing controls; text alone ends 12px in, like the leading edge. */}
<span
dir="auto"
class="me-1.5 min-w-0 flex-1 truncate text-v2-text-text-muted"
classList={{
"group-hover/running-item:me-0 group-data-[highlighted]/running-item:me-0 [@media(hover:none)]:me-0":
stoppable(item),
}}
>
{item.label}
</span>
<Show when={stoppable(item)}>
{/* The button's own display rule outranks utilities, so this wrapper shows and hides it. */}
<span class="hidden shrink-0 group-hover/running-item:flex group-data-[highlighted]/running-item:flex [@media(hover:none)]:flex">
{/* The row selects on press, so the stop button keeps its pointer events to itself. */}
<IconButton
type="button"
size="small"
variant="ghost-muted"
tabIndex={-1}
icon={<Icon name="outline-xmark" />}
aria-label={language.t(
item.type === "shell" ? "session.running.stop.shell" : "session.running.stop.subagent",
)}
onPointerDown={(event) => {
event.preventDefault()
event.stopPropagation()
}}
onPointerUp={(event) => event.stopPropagation()}
onClick={(event) => {
event.stopPropagation()
stop(item)
}}
/>
</span>
</Show>
</Menu.Item>
)}
</For>
</Menu.Content>
</Menu.Portal>
</Menu>
</Show>
)
}
+14 -46
View File
@@ -21,15 +21,14 @@ export function createSessionRequestModel() {
const settings = useSettings()
createEffect(() => {
const id = params.id
if (!id || serverSDK.connection.status() !== "connected") return
void Promise.all([data.shell.sync({ directory: sdk().directory }), data.session.permission.sync(id)]).catch(
() => undefined,
)
void Promise.all([
data.shell.sync({ directory: sdk().directory }),
data.session.permission.sync(id),
]).catch(() => undefined)
})
createEffect(() => {
const id = params.id
if (!id || serverSDK.connection.status() !== "connected") return
void Promise.all(
sessionTreeIDs(data.session.list(), id).map((sessionID) => data.session.form.sync(sessionID)),
@@ -39,80 +38,54 @@ export function createSessionRequestModel() {
const formRequest = createMemo((): FormInfo | undefined => {
return sessionFormRequest(data.session.list(), data.session.form.list, params.id)
})
const websearch = createWebSearchRequest({
owner: () => params.id,
connected: () => serverSDK.connection.status() === "connected",
request: () => {
const form = formRequest()
return form?.metadata?.kind === "websearch.provider" ? form : undefined
},
providers: async (sessionID) => {
const session = data.session.get(sessionID) ?? (await serverSDK.api.session.get({ sessionID }))
const result = await serverSDK.api.websearch.providers({
location: { directory: session.location.directory },
})
return result.data.map((provider) => ({ value: provider.id, label: provider.name }))
},
reply: (input) => data.session.form.reply(input),
events: serverSDK.event,
})
const questionRequest = createMemo(() => {
if (websearch.request()) return
const form = formRequest()
return form?.metadata?.kind === "question" ? form : undefined
})
const permissionRequest = createMemo((): PermissionRequest | undefined => {
if (settings.permissions.autoApprove()) return undefined
return sessionPermissionRequest(data.session.list(), data.session.permission.list, params.id)
})
const blocked = createMemo(() => {
const id = params.id
if (!id) return false
return !!permissionRequest() || !!questionRequest() || !!websearch.request()
})
const primary = () => {
const id = params.id
return !!id && !data.session.get(id)?.parentID
}
// Inside a subagent, running work is the parent's, so the header lists its siblings.
const owner = createMemo(() => {
const id = params.id
if (!id) return
return data.session.get(id)?.parentID ?? id
})
const running = createSessionBackground({
sessionID: owner,
const background = createSessionBackground({
sessionID: () => (primary() ? params.id : undefined),
messages: data.session.message.list,
sessions: data.session.list,
status: data.session.status,
shells: () => data.shell.list({ directory: sdk().directory }),
})
// Moving to the background and the extensions' background list stay with the primary session's own work.
const blocking = createMemo(() => (primary() ? running.blocking() : []))
const tasks = createMemo(() => (primary() ? running.tasks() : []))
const moveToBackground = async () => {
if (!primary()) return
const sessionID = params.id
if (!sessionID) return
await serverSDK.api.session.background({ sessionID }).catch((error) => {
showToast({
@@ -122,31 +95,27 @@ export function createSessionRequestModel() {
})
}
const [store, setStore] = createStore<{ responding: string | undefined }>({ responding: undefined })
const [store, setStore] = createStore({
responding: undefined as string | undefined,
})
const permissionResponding = createMemo(() => {
const perm = permissionRequest()
if (!perm) return false
return store.responding === perm.id
})
const decide = (response: "once" | "always" | "reject") => {
const perm = permissionRequest()
if (!perm) return
if (store.responding === perm.id) return
setStore("responding", perm.id)
serverSDK.api.permission
.reply({ sessionID: perm.sessionID, requestID: perm.id, decision: response })
.catch((error) => {
showToast({
title: language.t("common.requestFailed"),
description: error instanceof Error ? error.message : String(error),
})
.catch((err: unknown) => {
const description = err instanceof Error ? err.message : String(err)
showToast({ title: language.t("common.requestFailed"), description })
})
.finally(() => {
setStore("responding", (id) => (id === perm.id ? undefined : id))
@@ -160,9 +129,8 @@ export function createSessionRequestModel() {
permissionRequest,
permissionResponding,
background: {
blocking,
tasks,
running: { sessionID: owner, blocking: running.blocking, tasks: running.tasks },
blocking: background.blocking,
tasks: background.tasks,
move: moveToBackground,
},
decide,
+45 -50
View File
@@ -4,9 +4,8 @@ import { useData } from "@/runtime/server/current"
import { useServerSDK } from "@/runtime/server/client"
import { useWorkspaceLocation } from "@/workspaces/location"
import { useLanguage } from "@/runtime/i18n/language"
import { extractPromptContext, extractPromptFromMessage } from "@/composer/prompt"
import { promptLength } from "@/composer/prompt-parts"
import { commentContextItem } from "@/composer/comment-note"
import { extractPromptComments, extractPromptFromMessage } from "@/composer/prompt"
import { showToast } from "@/shell/notifications/toast"
import type { SessionModel } from "./model"
@@ -20,7 +19,7 @@ export function createSessionRevert(input: {
const location = useWorkspaceLocation()
const language = useLanguage()
const request = async <A>(action: () => Promise<A>) =>
const request = async (action: () => Promise<unknown>) =>
action()
.then(() => true)
.catch((error) => {
@@ -28,50 +27,48 @@ export function createSessionRevert(input: {
title: language.t("common.requestFailed"),
description: error instanceof Error ? error.message : String(error),
})
return false
})
const restore = (target: ReturnType<typeof prompt.capture>, message: SessionMessageUser) => {
const restored = extractPromptFromMessage(message, {
directory: location().directory,
attachmentName: language.t("common.attachment"),
})
const context = extractPromptContext(message, { directory: location().directory })
target.set(restored, promptLength(restored))
// The restored prompt replaces the draft, so chips from an earlier restore do not ride along.
target.context.replace([...context.comments, ...context.files])
target.set(
extractPromptFromMessage(message, {
directory: location().directory,
}),
)
target.context.replaceComments(extractPromptComments(message).map(commentContextItem))
}
const stage = async (message: SessionMessageUser, previous: SessionMessageUser | undefined) => {
const sessionID = input.session.identity.params.id
if (!sessionID) return
const owner = input.session.ownership.capture()
const target = prompt.capture()
// An undelivered prompt has no history to rewind. Withdraw it like the TUI
// instead of interrupting the work it is waiting behind.
if (data.session.input.has(sessionID, message.id)) {
if (!(await request(() => server.api.session.inbox.cancel({ sessionID, inboxID: message.id })))) return
restore(target, message)
owner.run(() => input.setActiveMessage(previous))
return
if (data.session.status(sessionID) === "running") {
await server.api.session.interrupt({ sessionID }).catch(() => undefined)
}
// Interrupt acknowledges before the execution settles, and staging a busy Session fails. The
// local status can lag the server either way, so always settle first; both are idle no-ops.
// Like the TUI, stop at the first failure instead of waiting on work that was never interrupted.
if (!(await request(() => server.api.session.interrupt({ sessionID })))) return
if (!(await request(() => server.api.session.wait({ sessionID })))) return
if (!(await request(() => server.api.session.revert.stage({ sessionID, messageID: message.id })))) return
// Like the TUI, pending inputs are left alone: the revert hides them, committing it drops them,
// and redo delivers them.
// Reverting to a previous prompt discards the pending queue (and pending
// steers): they were written against the history being rewound. Cancel
// the authoritative inbox merged with the local snapshot, fire-and-forget
// so a slow request cannot delay restoring the composer. The cutoff keeps
// the asynchronous sweep away from prompts admitted after the revert; an
// old admission still in flight when the list is fetched can survive it,
// and fully closing that race needs a server-side revert-discards-inbox
// rule.
const cutoff = Date.now()
const local = data.session.pending
.list(sessionID)
.filter((item) => item.type === "user")
.map((item) => item.id)
void server.api.session.inbox
.list({ sessionID })
.then((rows) => rows.filter((row) => row.type === "user" && row.time.created <= cutoff).map((row) => row.id))
.catch(() => [])
.then((authoritative) => {
new Set([...local, ...authoritative]).forEach(
(inboxID) => void server.api.session.inbox.cancel({ sessionID, inboxID }).catch(() => undefined),
)
})
restore(target, message)
owner.run(() => input.setActiveMessage(previous))
}
@@ -80,7 +77,6 @@ export function createSessionRevert(input: {
const messages = input.session.history.userMessages()
const index = messages.findIndex((message) => message.id === messageID)
const message = messages[index]
if (!message) return
await stage(message, messages[index - 1])
}
@@ -89,30 +85,29 @@ export function createSessionRevert(input: {
const messages = input.session.history.userMessages()
const reverted = input.session.data.revertMessageID()
const boundary = reverted ? messages.findIndex((message) => message.id === reverted) : messages.length
if (boundary <= 0) return
const message = messages[boundary - 1]
if (message) await stage(message, messages[boundary - 2])
}
const redo = async () => {
const sessionID = input.session.identity.params.id
const reverted = input.session.data.revertMessageID()
if (!sessionID || !reverted) return
const messages = input.session.history.userMessages()
const boundary = messages.findIndex((message) => message.id === reverted)
if (boundary < 0) return
const next = messages[boundary + 1]
if (next) {
await stage(next, messages[boundary])
return
}
const owner = input.session.ownership.capture()
// Like the TUI, redo restores every reverted message at once and leaves the composer alone.
const target = prompt.capture()
if (!(await request(() => server.api.session.revert.clear({ sessionID })))) return
owner.run(() =>
input.setActiveMessage(
input.session.history
.userMessages()
.filter((message) => !data.session.input.has(sessionID, message.id))
.at(-1),
),
)
target.reset()
target.context.replaceComments([])
owner.run(() => input.setActiveMessage(messages.at(-1)))
}
return { to, undo, redo }
+1 -1
View File
@@ -139,7 +139,7 @@ function ResolvedTargetSessionRoute() {
>
<Show when={directory()} fallback={<PendingSessionState sessionID={params.id} />}>
{(value) => (
<LocationProvider directory={value()}>
<LocationProvider directory={value}>
<SessionUIProvider directory={value()} server={server.key}>
<TargetSessionPage />
</SessionUIProvider>
+1 -4
View File
@@ -260,7 +260,6 @@ function SessionScreenContent(props: {
setContentRef={timeline.view.setContentRef}
anchor={timeline.view.anchor}
setRevealMessage={timeline.view.setRevealMessage}
reveal={timeline.view.reveal}
setScrollToEnd={timeline.view.setScrollToEnd}
search={
<Show when={active()}>
@@ -341,9 +340,7 @@ function SessionScreenContent(props: {
</div>
<Show when={composer.active()} keyed>
{(model) => (
<ActiveSessionComposerRegion session={session} model={model} suggestionBoundary={timeline.scroller} />
)}
{(model) => <ActiveSessionComposerRegion model={model} suggestionBoundary={timeline.scroller} />}
</Show>
</>
)
@@ -55,19 +55,15 @@ export function SessionProjectMenu(props: {
const layout = useLayout()
const settingsSurface = useSettingsSurface()
const navigate = useNavigate()
const [state, setState] = createStore({
open: false,
projectTruncated: false,
pathTruncated: false,
pathFocused: false,
})
const projectName = createMemo(() => displayName(props.project ?? { worktree: props.directory ?? "" }))
const canOpenPath = () =>
platform.platform === "desktop" && !!platform.openPath && server.isLocal && !!props.directory
const openPath = () => {
if (!canOpenPath() || !platform.openPath || !props.directory) return
void platform.openPath(props.directory).catch((cause: unknown) =>
@@ -77,10 +73,8 @@ export function SessionProjectMenu(props: {
}),
)
}
const openProjectSettings = () => {
const current = props.project
if (!current) return
settingsSurface.openProject({
server: ServerConnection.key(server.conn),
@@ -125,7 +119,6 @@ export function SessionProjectMenu(props: {
disabled={!props.project}
onSelect={() => {
const project = props.project
if (!project) return
server.ctx.projects.open(project.worktree)
layout.home.setSelection({ server: server.key, directory: project.worktree })
@@ -215,14 +208,13 @@ export function SessionAncestorTrail(props: {
trailing: boolean
}) {
const server = useServer()
const tabs = useTabs()
const navigate = useNavigate()
const language = useLanguage()
const open = useOpenSessionRoute()
const ancestors = createMemo(() => {
const path: { id: string; title: string; direct: boolean }[] = []
const seen = new Set([props.sessionID])
let id: string | undefined = props.parentID
while (id && !seen.has(id)) {
seen.add(id)
const info = server.ctx.data.session.get(id)
@@ -235,9 +227,18 @@ export function SessionAncestorTrail(props: {
})
id = info?.parentID
}
return path
})
const open = (id: string) => {
const tab = tabs.store.find(
(item) =>
item.type === "session" &&
item.server === server.key &&
(item.sessionId === props.sessionID || item.routeSessionId === props.sessionID),
)
if (tab?.type === "session") tabs.rememberSessionRoute(tab, id, server.ctx.data.session.get(id)?.parentID)
navigate(sessionHref(server.key, id))
}
return (
<div class="flex min-w-0 max-w-full items-center">
@@ -255,7 +256,7 @@ export function SessionAncestorTrail(props: {
title={ancestor.title}
dir="auto"
class="max-w-[min(200px,40vw)] shrink-0 truncate pl-2 text-[13px] font-[530] leading-4 tracking-[-0.04px] text-v2-text-text-faint transition-colors hover:text-v2-text-text-muted"
onClick={() => open(props.sessionID, ancestor.id)}
onClick={() => open(ancestor.id)}
>
{ancestor.title}
</button>
@@ -285,32 +286,11 @@ export function SessionAncestorTrail(props: {
)
}
// Opens a related session in the tab showing `from`, and records its parent so the tab's route stays consistent.
// `reveal` names a tool call or shell the opened timeline scrolls to and expands.
export function useOpenSessionRoute() {
const server = useServer()
const tabs = useTabs()
const navigate = useNavigate()
return (from: string, id: string, reveal?: string) => {
const tab = tabs.store.find(
(item) =>
item.type === "session" &&
item.server === server.key &&
(item.sessionId === from || item.routeSessionId === from),
)
if (tab?.type === "session") tabs.rememberSessionRoute(tab, id, server.ctx.data.session.get(id)?.parentID)
navigate(sessionHref(server.key, id), reveal ? { state: { reveal } } : undefined)
}
}
export function SessionIdentityHeader(props: { sessionID: string; session?: SessionInfo }) {
const server = useServer()
const tabs = useTabs()
const language = useLanguage()
const pending = createMemo(() => tabs.pendingSession(server.key, props.sessionID))
const tab = createMemo(() =>
tabs.store.find(
(item) =>
@@ -319,64 +299,48 @@ export function SessionIdentityHeader(props: { sessionID: string; session?: Sess
(item.sessionId === props.sessionID || item.routeSessionId === props.sessionID),
),
)
const info = createMemo(() => {
const current = tab()
return current ? tabs.info[tabKey(current)] : undefined
})
const parentID = createMemo(() => {
if (props.session?.parentID) return props.session.parentID
const current = tab()
if (current?.type !== "session" || current.routeSessionId !== props.sessionID) return
return current.routeParentId ?? current.sessionId
})
const parent = createMemo(() => {
const id = parentID()
return id ? server.ctx.data.session.get(id) : undefined
})
const parentTitle = createMemo(() => {
const id = parentID()
const current = tab()
return sessionTitle(
parent()?.title ?? (current?.type === "session" && current.sessionId === id ? info()?.title : undefined),
)
})
const directory = createMemo(
() => props.session?.location.directory ?? pending()?.draft.directory ?? info()?.directory,
)
const title = createMemo(() =>
pending()
? language.t("session.tab.session")
: sessionTitle(props.session?.title ?? (parentID() ? undefined : info()?.title)),
)
const project = createMemo(() => {
if (props.session) return server.ctx.projects.forSession(props.session)
const projects = server.ctx.projects.list()
const value = directory()
if (!value) return undefined
const key = pathKey(value)
return (
projects.find(
(item) => pathKey(item.worktree) === key || item.sandboxes?.some((sandbox) => pathKey(sandbox) === key),
) ?? server.ctx.sync.data.project.find((item) => isProjectDirectory(item, value))
)
})
const workspaceSession = createMemo(() => !!pending() || isWorkspaceDirectory(project(), directory() ?? ""))
return (
<Show when={title() || parentTitle()}>
<SessionTitleHeader>
@@ -28,7 +28,6 @@ describe("visibleTimelineMessages", () => {
delivery: "steer",
payload: { text: "queued" },
} satisfies SessionInboxInfo
const work = {
id: "msg_5",
type: "assistant",
@@ -66,9 +65,8 @@ describe("visibleTimelineMessages", () => {
shellToolDefaultOpen: () => false,
editToolDefaultOpen: () => false,
timelineDetail: () => timelinePresets[2].value,
pendingInputIDs: () => new Set([steer.id]),
pendingUserMessageIDs: () => new Set([steer.id]),
})
expect(projection.activeMessageID()).toBe("msg_1")
expect(projection.rows().map((row) => [row._tag, row.userMessageID])).toEqual([
["UserMessage", "msg_1"],
@@ -115,7 +113,7 @@ describe("visibleTimelineMessages", () => {
expect(visibleTimelineMessages(delivered, [])).toBe(delivered)
})
test("preserves pending input order and excludes reverted steers", () => {
test("preserves steer order and excludes reverted steers", () => {
const source = [...messages, work]
const pending = [steer, { ...steer, id: "msg_4" }]
expect(visibleTimelineMessages(source, pending).map((message) => message.id)).toEqual([
@@ -125,30 +123,6 @@ describe("visibleTimelineMessages", () => {
"msg_3",
"msg_4",
])
// A notice admitted after the steers, before the next step, sinks with them in admission order. The
// server delivers steers in that order and the store moves each delivered input to the end, so the
// rendered order does not change at delivery.
const notice = {
id: "msg_4a",
sessionID: "ses_1",
time: { created: 4 },
type: "synthetic",
delivery: "steer",
payload: { text: "", description: "Task finished" },
} satisfies SessionInboxInfo
const noticeMessage = {
id: notice.id,
type: "synthetic",
...notice.payload,
time: notice.time,
} satisfies SessionMessageInfo
const pendingOrder = visibleTimelineMessages([...messages, noticeMessage, work], [...pending, notice])
// The order the server delivers in: active work, then steers and notices by admission.
expect(pendingOrder.map((message) => message.id)).toEqual(["msg_1", "msg_2", "msg_5", "msg_3", "msg_4", "msg_4a"])
expect(visibleTimelineMessages(source, pending, "msg_4").map((message) => message.id)).toEqual([
"msg_1",
"msg_2",
@@ -211,7 +185,6 @@ describe("applyTimelineMessageHandoff", () => {
...handoff,
files: [{ data: "YQ==", mime: "image/png", source: { type: "inline" } }],
} satisfies SessionMessageInfo
expect(applyTimelineMessageHandoff([durable], handoff)).toEqual([durable])
})
})
@@ -3,12 +3,9 @@ import type { SessionInboxInfo, SessionMessageInfo, SessionMessageUser } from "@
export function applyTimelineMessageHandoff(messages: SessionMessageInfo[], handoff?: SessionMessageUser) {
if (!handoff) return messages
const index = messages.findIndex((message) => message.id === handoff.id)
if (index < 0) return [...messages, handoff]
const message = messages[index]
if (message.type !== "user" || message.files?.length) return messages
return messages.map((item, current) => (current === index ? { ...message, files: handoff.files } : item))
}
@@ -20,26 +17,18 @@ export function visibleTimelineMessages(
const queued = new Set(
pending.flatMap((item) => (item.type === "user" && item.delivery === "queue" ? [item.id] : [])),
)
const inputs = new Set(
pending.flatMap((item) =>
(item.type === "user" && item.delivery === "steer") || item.type === "synthetic" ? [item.id] : [],
),
const steers = new Set(
pending.flatMap((item) => (item.type === "user" && item.delivery === "steer" ? [item.id] : [])),
)
if (queued.size === 0 && inputs.size === 0 && !revertMessageID) return messages
if (queued.size === 0 && steers.size === 0 && !revertMessageID) return messages
const visible = messages.filter(
(message) => !queued.has(message.id) && (!revertMessageID || message.id < revertMessageID),
)
if (inputs.size === 0) return visible
// Undelivered inputs do not own assistant work, so they stay below the active work like the TUI.
// They keep admission order: the server delivers steers in that order, so delivery moves nothing.
if (steers.size === 0) return visible
// Pending steers do not own assistant work until they are delivered.
return [
...visible.filter((message) => !inputs.has(message.id)),
...visible.filter((message) => inputs.has(message.id)),
...visible.filter((message) => !steers.has(message.id)),
...visible.filter((message) => steers.has(message.id)),
]
}
@@ -50,8 +39,6 @@ export function timelineChildTitle(input: {
fallback: string
}) {
if (!input.parentID) return input.title ?? ""
if (input.taskDescription) return input.taskDescription
return input.title?.replace(/\s+\(@[^)]+ subagent\)$/, "") || input.fallback
}
@@ -4,7 +4,6 @@ import { Button } from "@opencode/ui/button"
import { useNavigate } from "@solidjs/router"
import { createEffect, createMemo, on } from "solid-js"
import { createStore } from "solid-js/store"
import { Option, Predicate, Schema } from "effect"
import { notifySessionTabsRemoved } from "@/shell/titlebar/session-events"
import { useDialog } from "@opencode/ui/context/dialog"
import { useLanguage } from "@/runtime/i18n/language"
@@ -26,27 +25,18 @@ import { getSessionMessageHandoff } from "@/session/handoff"
import type { ReasoningMode } from "@opencode/session-ui/timeline/projection"
const emptyMessages: SessionMessageInfo[] = []
const decodeErrorData = Schema.decodeUnknownOption(Schema.Struct({ data: Schema.Struct({ message: Schema.String }) }))
const taskDescription = (message: SessionMessageInfo, sessionID: string): string | undefined => {
if (message.type !== "assistant") return
const tool = message.content.findLast((item) => {
if (item.type !== "tool" || (item.name !== "task" && item.name !== "subagent")) return false
const metadata =
item.state.status === "running" || item.state.status === "completed" ? item.state.metadata : undefined
return metadata?.sessionId === sessionID || metadata?.sessionID === sessionID
})
if (tool?.type !== "tool") return
const input = Predicate.isString(tool.state.input) ? undefined : tool.state.input
const input = typeof tool.state.input === "string" ? undefined : tool.state.input
const value = input?.description
if (Predicate.isString(value) && value) return value
if (typeof value === "string" && value) return value
return undefined
}
@@ -67,63 +57,44 @@ export function createTimelineController(input: { session: TimelineSessionSource
const dialog = useDialog()
const language = useLanguage()
const platform = usePlatform()
const handedOffMessages = createMemo(() =>
applyTimelineMessageHandoff(
input.session.history.messages(),
getSessionMessageHandoff(input.session.identity.sessionKey()),
),
)
const projectedMessages = createMemo(() => {
const id = input.session.identity.sessionID()
return visibleTimelineMessages(
handedOffMessages(),
id ? data.session.pending.list(id) : [],
input.session.data.info()?.revert?.messageID,
)
})
const pendingInputIDs = createMemo(() => {
const pendingUserMessageIDs = createMemo(() => {
const id = input.session.identity.sessionID()
return new Set(
(id ? data.session.pending.list(id) : []).flatMap((item) =>
(item.type === "user" && item.delivery === "steer") || item.type === "synthetic" ? [item.id] : [],
item.type === "user" && item.delivery === "steer" ? [item.id] : [],
),
)
})
const queuedCompactionIDs = createMemo(() => {
const id = input.session.identity.sessionID()
return (id ? data.session.pending.list(id) : []).flatMap((item) => (item.type === "compaction" ? [item.id] : []))
})
const titleValue = createMemo(() => input.session.data.info()?.title)
const titleLabel = createMemo(() => sessionTitle(titleValue()) ?? language.t("session.tab.session"))
const parentMessages = createMemo(() => {
const id = input.session.data.parentID()
return id ? data.session.message.list(id) : emptyMessages
})
const parentTitle = createMemo(
() => sessionTitle(input.session.data.parent()?.title) ?? language.t("session.tab.session"),
)
const childTaskDescription = createMemo(() => {
const id = input.session.identity.sessionID()
if (!id) return undefined
return parentMessages()
.map((message) => taskDescription(message, id))
.findLast((value): value is string => !!value)
})
const childTitle = createMemo(() => {
return timelineChildTitle({
parentID: input.session.data.parentID(),
@@ -132,12 +103,9 @@ export function createTimelineController(input: { session: TimelineSessionSource
fallback: language.t("session.tab.session"),
})
})
const showHeader = createMemo(() => !!input.session.identity.sessionID())
const timelineDetail = createMemo(() => {
const detail = settings.general.timelineDetail()
return {
shell: { ...detail.shell },
edit: { ...detail.edit },
@@ -147,17 +115,14 @@ export function createTimelineController(input: { session: TimelineSessionSource
tools: { ...detail.tools },
}
})
const reasoningMode = (): ReasoningMode =>
timelineDetail().thinking.placement === "hidden"
? "hidden"
: timelineDetail().thinking.details === "expanded"
? "full"
: "compact"
const shellToolPartsExpanded = () => timelineDetail().shell.details === "expanded"
const editToolPartsExpanded = () => timelineDetail().edit.details === "expanded"
const projection = createTimelineProjection({
sessionMessages: projectedMessages,
status: input.session.data.status,
@@ -165,63 +130,48 @@ export function createTimelineController(input: { session: TimelineSessionSource
shellToolDefaultOpen: shellToolPartsExpanded,
editToolDefaultOpen: editToolPartsExpanded,
timelineDetail,
pendingInputIDs,
queuedCompactionIDs,
pendingUserMessageIDs,
})
const [pending, setPending] = createStore({ rename: false })
const errorMessage = (cause: unknown) =>
Option.match(decodeErrorData(cause), {
onSome: (error) => error.data.message,
onNone: () => (cause instanceof Error ? cause.message : language.t("common.requestFailed")),
})
const errorMessage = (error: unknown) => {
if (error && typeof error === "object" && "data" in error) {
const data = error.data
if (data && typeof data === "object" && "message" in data && typeof data.message === "string") return data.message
}
if (error instanceof Error) return error.message
return language.t("common.requestFailed")
}
const rename = async (title: string) => {
const id = input.session.identity.sessionID()
if (!id || pending.rename) return false
const next = title.trim()
if (!next || next === (titleLabel() ?? "")) return true
setPending("rename", true)
const success = await serverSDK.api.session
.update({ sessionID: id, title: next })
.then(() => true)
.catch((error) => {
showToast({ title: language.t("common.requestFailed"), description: errorMessage(error) })
return false
})
setPending("rename", false)
if (!success) return false
const current = data.session.get(id)
if (current) data.session.remember({ ...current, title: next })
return true
}
const href = (id: string) => sessionHref(server.key, id)
const navigateAfterRemoval = (id: string, parent?: string, next?: string) => {
if (input.session.identity.params.id !== id) return
if (parent) return navigate(href(parent))
if (next) return navigate(href(next))
return tabs.newDraft({ server: server.key, directory: sdk().directory })
}
const exportSession = async (id: string) => {
try {
const data = await fetchSessionExport({ sessionID: id, api: serverSDK.api })
const filename = sessionExportFilename(data.info)
if (!(await saveSessionExport(filename, data, platform))) return
showToast({
variant: "success",
@@ -237,29 +187,23 @@ export function createTimelineController(input: { session: TimelineSessionSource
})
}
}
const remove = async (id: string) => {
const session = data.session.get(id)
if (!session) return false
const sessions = data.session.list().filter((item) => !item.parentID && !item.time?.archived)
const index = sessions.findIndex((item) => item.id === id)
const next = index === -1 ? undefined : (sessions[index + 1] ?? sessions[index - 1])
const removed = sessionTreeIDs(data.session.list(), id)
const success = await data.session
.remove(id)
.then(() => true)
.catch((error) => {
showToast({ title: language.t("session.delete.failed.title"), description: errorMessage(error) })
return false
})
if (!success) return false
void navigateAfterRemoval(id, session.parentID, next?.id)
notifySessionTabsRemoved({ server: server.key, directory: sdk().directory, sessionIDs: removed })
return true
}
@@ -267,12 +211,10 @@ export function createTimelineController(input: { session: TimelineSessionSource
const name = createMemo(
() => sessionTitle(data.session.get(props.sessionID)?.title) ?? language.t("session.tab.session"),
)
const confirm = async () => {
await remove(props.sessionID)
dialog.close()
}
return (
<Dialog fit>
<DialogHeader hideClose>
@@ -330,7 +272,6 @@ export function createTimelineController(input: { session: TimelineSessionSource
showDelete: (id: string) => dialog.show(() => <DeleteDialog sessionID={id} />),
navigateParent: () => {
const id = input.session.data.parentID()
if (id) navigate(href(id))
},
},
@@ -10,22 +10,11 @@ import { createTimelineModel } from "./model"
export function createSessionTimelineInteraction(session: SessionModel) {
const layout = useLayout()
const location = useLocation<{ reveal?: string }>()
const location = useLocation()
const timeline = createTimelineModel({ session })
const [state, setState] = createStore<{
messageID: string | undefined
pendingMessage: string | undefined
scroll: { overflow: boolean; jump: boolean }
follow: { sessionKey: string; pinned: boolean }
refs: {
scroller: HTMLDivElement | undefined
content: HTMLDivElement | undefined
dock: HTMLDivElement | undefined
}
}>({
messageID: undefined,
pendingMessage: undefined,
const [state, setState] = createStore({
messageID: undefined as string | undefined,
pendingMessage: undefined as string | undefined,
scroll: {
overflow: false,
jump: false,
@@ -35,32 +24,23 @@ export function createSessionTimelineInteraction(session: SessionModel) {
pinned: true,
},
refs: {
scroller: undefined,
content: undefined,
dock: undefined,
scroller: undefined as HTMLDivElement | undefined,
content: undefined as HTMLDivElement | undefined,
dock: undefined as HTMLDivElement | undefined,
},
})
// The single source of truth for "follow the newest content". The virtualizer pins and unpins
// it from scroll geometry; everything else only expresses explicit intent.
const pinned = () => state.follow.sessionKey !== session.identity.sessionKey() || state.follow.pinned
const pin = () => setState("follow", { sessionKey: session.identity.sessionKey(), pinned: true })
const unpin = () => {
if (!scroller || scroller.scrollHeight - scroller.clientHeight <= 1) return
setState("follow", { sessionKey: session.identity.sessionKey(), pinned: false })
}
let scroller: HTMLDivElement | undefined
let dockHeight = 0
// The history entry whose tool call was already revealed.
let revealed: string | undefined
// The active timeline's navigation, registered by its virtualizer.
let revealMessage = (_id: string, _partID?: string) => {}
let scrollToEnd = () => {}
let scrollMark = 0
let messageMark = 0
let scrollStateFrame: number | undefined
@@ -75,65 +55,49 @@ export function createSessionTimelineInteraction(session: SessionModel) {
if (!scroller) return state.messageID
const box = scroller.getBoundingClientRect()
const line = box.top + 100
const list = [...scroller.querySelectorAll<HTMLElement>("[data-message-id]")].flatMap((element) => {
const id = element.dataset.messageId
if (!id) return []
const rect = element.getBoundingClientRect()
return [{ id, top: rect.top, bottom: rect.bottom }]
})
const list = [...scroller.querySelectorAll<HTMLElement>("[data-message-id]")]
.map((element) => {
const id = element.dataset.messageId
if (!id) return undefined
const rect = element.getBoundingClientRect()
return { id, top: rect.top, bottom: rect.bottom }
})
.filter((item): item is { id: string; top: number; bottom: number } => !!item)
const shown = list.filter((item) => item.bottom > box.top && item.top < box.bottom)
const hit = shown.find((item) => item.top <= line && item.bottom >= line)
if (hit) return hit.id
const near = [...shown].sort((a, b) => {
const aDistance = Math.abs(a.top - line)
const bDistance = Math.abs(b.top - line)
if (aDistance !== bDistance) return aDistance - bDistance
return a.top - b.top
})[0]
if (near) return near.id
return list.filter((item) => item.top <= line).at(-1)?.id ?? list[0]?.id ?? state.messageID
}
const setActiveMessage = (message: SessionMessageUser | undefined) => {
messageMark = scrollMark
setState("messageID", message?.id)
}
const jumpThreshold = (element: HTMLDivElement) => Math.max(400, element.clientHeight)
const updateScrollState = (element: HTMLDivElement) => {
const max = element.scrollHeight - element.clientHeight
const distance = max - element.scrollTop
const overflow = max > 1
const jump = overflow && distance > jumpThreshold(element)
if (state.scroll.overflow === overflow && state.scroll.jump === jump) return
setState("scroll", { overflow, jump })
}
const scheduleScrollState = (element: HTMLDivElement) => {
scrollStateTarget = element
if (scrollStateFrame !== undefined) return
scrollStateFrame = requestAnimationFrame(() => {
scrollStateFrame = undefined
const target = scrollStateTarget
scrollStateTarget = undefined
if (target) updateScrollState(target)
})
}
const { clearMessageHash, scrollToMessage } = useSessionHashScroll({
sessionKey: session.identity.sessionKey,
sessionID: () => session.identity.params.id,
@@ -159,85 +123,64 @@ export function createSessionTimelineInteraction(session: SessionModel) {
scheduleScrollState,
consumePendingMessage: (key) => layout.pendingMessage.consume(key),
})
const resume = () => {
setState("messageID", undefined)
pin()
scrollToEnd()
clearMessageHash()
if (scroller) scheduleScrollState(scroller)
}
const navigateMessage = (offset: number) => {
const messages = visibleUserMessages()
if (messages.length === 0) return
const current = state.messageID && messageMark === scrollMark ? state.messageID : cursor()
const base = current ? messages.findIndex((message) => message.id === current) : messages.length
const target = (base === -1 ? messages.length : base) + offset
if (target < 0 || target > messages.length) return
if (target === messages.length) {
resume()
return
}
unpin()
scrollToMessage(messages[target], "auto")
}
// A gesture inside a nested scrollable region scrolls that region, not the timeline.
const markUserScroll = (target?: EventTarget | null) => {
if (!scroller) return
const element = target instanceof Element ? target : undefined
const nested = element?.closest("[data-scrollable]")
if (nested && nested !== scroller) return
scrollMark += 1
}
const selectionInteraction = () => {
const selection = window.getSelection()
if (selection && selection.toString().length > 0) unpin()
}
const setScrollRef = (element: HTMLDivElement | undefined) => {
scroller = element
setState("refs", "scroller", element)
if (!element) return
scheduleScrollState(element)
fill()
}
async function loadOlder() {
const owner = session.ownership.capture()
if (timeline.history.loading() || historyRequests.has(owner.key)) return
historyRequests.add(owner.key)
const before = timeline.messages().length
try {
await timeline.history.loadOlder()
} finally {
historyRequests.delete(owner.key)
}
if (!owner.current() || timeline.messages().length <= before) return
if (pinned() || !scroller || scroller.scrollTop >= 200 || !timeline.history.more()) return
if (historyContinuationFrame !== undefined) cancelAnimationFrame(historyContinuationFrame)
historyContinuationFrame = requestAnimationFrame(() => {
historyContinuationFrame = undefined
owner.run(onHistoryScroll)
})
}
const onHistoryScroll = () => {
if (
historyRequests.has(session.ownership.key()) ||
@@ -249,21 +192,16 @@ export function createSessionTimelineInteraction(session: SessionModel) {
return
void loadOlder()
}
const fill = () => {
if (fillFrame !== undefined) return
fillFrame = requestAnimationFrame(() => {
fillFrame = undefined
if (!session.identity.params.id || !timeline.ready()) return
if (!pinned() || timeline.history.loading() || !scroller) return
if (scroller.scrollHeight > scroller.clientHeight + 1 || !timeline.history.more()) return
void loadOlder()
})
}
createEffect(
on(
() => visibleUserMessages().at(-1)?.id,
@@ -334,27 +272,20 @@ export function createSessionTimelineInteraction(session: SessionModel) {
() => state.refs.dock,
({ height }) => {
const next = Math.ceil(height)
if (next === dockHeight) return
const delta = next - dockHeight
const stick = scroller
? pinned() || scroller.scrollHeight - scroller.clientHeight - scroller.scrollTop < 10 + Math.max(0, delta)
: false
dockHeight = next
if (stick) scrollToEnd()
if (scroller) scheduleScrollState(scroller)
fill()
},
)
onCleanup(() => {
if (historyContinuationFrame !== undefined) cancelAnimationFrame(historyContinuationFrame)
if (scrollStateFrame !== undefined) cancelAnimationFrame(scrollStateFrame)
if (fillFrame !== undefined) cancelAnimationFrame(fillFrame)
})
@@ -380,7 +311,6 @@ export function createSessionTimelineInteraction(session: SessionModel) {
scheduleScrollState,
setContentRef: (element: HTMLDivElement | undefined) => {
setState("refs", "content", element)
if (scroller) scheduleScrollState(scroller)
},
setDockRef: (element: HTMLDivElement | undefined) => {
@@ -389,13 +319,6 @@ export function createSessionTimelineInteraction(session: SessionModel) {
setRevealMessage: (reveal: (id: string, partID?: string) => void) => {
revealMessage = reveal
},
// The tool call the route opened the session on, revealed once per history entry.
reveal: {
target: () => (revealed === location.key ? undefined : location.state?.reveal),
done: () => {
revealed = location.key
},
},
setScrollRef,
setScrollToEnd: (scroll: () => void) => {
scrollToEnd = scroll
@@ -26,18 +26,10 @@ import { parseCommentNote, readPromptPresentation } from "@/composer/comment-not
import { useCommand } from "@/shell/commands/command"
import { SessionAncestorTrail, SessionProjectMenu, SessionTitleHeader } from "../session-identity-header"
import { SessionHeaderSpacer } from "@/session/header/session-header"
import { SessionRunningMenu } from "@/session/header/session-running-menu"
type BlockingTask = { type: "shell" | "subagent"; partID: string; id?: string; label?: string }
type SessionBackground = {
blocking: Accessor<BlockingTask[]>
blocking: Accessor<{ type: "shell" | "subagent"; partID: string; id?: string; label?: string }[]>
tasks: Accessor<readonly BackgroundTask[]>
running: {
sessionID: Accessor<string | undefined>
blocking: Accessor<BlockingTask[]>
tasks: Accessor<readonly BackgroundTask[]>
}
move: () => Promise<void>
}
@@ -87,7 +79,6 @@ type MessageTimelineProps = {
anchor: (id: string) => string
setRevealMessage?: (fn: (id: string, partID?: string) => void) => void
setScrollToEnd?: (fn: () => void) => void
reveal?: { target: () => string | undefined; done: () => void }
search?: JSX.Element
}
@@ -183,12 +174,7 @@ function MessageTimelineView(
onUserScroll: props.onUserScroll,
onHistoryScroll: props.onHistoryScroll,
canRenderImmediately: (row, disclosure) => {
if (
Predicate.isTagged(row, "TurnGap") ||
Predicate.isTagged(row, "TurnDivider") ||
Predicate.isTagged(row, "CompactionQueued")
)
return true
if (Predicate.isTagged(row, "TurnGap") || Predicate.isTagged(row, "TurnDivider")) return true
if (Predicate.isTagged(row, "Notice")) {
const message = messageByID().get(row.messageID)
@@ -231,7 +217,6 @@ function MessageTimelineView(
},
setRevealMessage: props.setRevealMessage,
setScrollToEnd: props.setScrollToEnd,
reveal: props.reveal,
})
const VirtualizedTimeline = virtualized.View
@@ -374,9 +359,8 @@ function MessageTimelineView(
return message?.type === "compaction" && message.status === "running"
}
// Used and read groups keep the fallback, so each new read does not swap Working out for its short call.
if (!Predicate.isTagged(row, "AssistantPart") || row.group.type === "context" || row.group.type === "read")
return false
// Used groups keep the fallback regardless of disclosure state.
if (!Predicate.isTagged(row, "AssistantPart") || row.group.type === "context") return false
return (row.group.type === "part" ? [row.group.ref] : row.group.refs).some((ref) => {
const content = Timeline.resolveContent(messageByID().get(ref.messageID), ref.partID)
@@ -559,13 +543,6 @@ function MessageTimelineView(
</Menu>
)}
</Show>
<SessionRunningMenu
sessionID={sessionID()}
owner={props.background.running.sessionID()}
blocking={props.background.running.blocking()}
tasks={props.background.running.tasks()}
onReveal={virtualized.revealPart}
/>
</div>
</div>
<Show when={sessionID()} keyed>
+2 -26
View File
@@ -1,11 +1,9 @@
import { createMemo, createResource, type Accessor } from "solid-js"
import type { SessionMessageInfo } from "@opencode/client/promise"
import { useData } from "@/runtime/server/current"
import { getSessionMessageHandoff } from "@/session/handoff"
import type { SessionModel } from "../model"
const leadingTurnPageDelay = 200
const leadingTurnPageLimit = 3
export function createTimelineModel(input: { session: Pick<SessionModel, "identity" | "history" | "ownership"> }) {
@@ -26,36 +24,22 @@ export function createTimelineModel(input: { session: Pick<SessionModel, "identi
pause: () => new Promise((resolve) => setTimeout(resolve, leadingTurnPageDelay)),
maxPages: leadingTurnPageLimit,
}).catch(() => undefined)
return id
},
)
const ready = createMemo(() => {
const id = input.session.identity.sessionID()
if (!id || (!resource.loading && resource.latest === id)) return true
// Enrich the partial leading group without withholding the already loaded tail. Undelivered inbox rows
// load with the inbox, not the transcript, so alone they are no tail; a first prompt shows through its handoff.
return (
data.session.message.list(id).some((message) => !data.session.input.has(id, message.id)) ||
!!getSessionMessageHandoff(input.session.identity.sessionKey())
)
// Enrich the partial leading group without withholding the already loaded tail.
return !id || data.session.message.list(id).length > 0 || (!resource.loading && resource.latest === id)
})
const more = () => {
const id = input.session.identity.sessionID()
return id ? data.session.message.more(id) : false
}
const loading = () => {
const id = input.session.identity.sessionID()
return id ? data.session.message.loading(id) : false
}
const loadOlder = async (options?: { before?: () => void; after?: (done: boolean) => void }) => {
return loadOlderTimeline({
sessionID: input.session.identity.sessionID,
@@ -90,24 +74,18 @@ export async function enrichLeadingTurn(input: {
if (!input.current() || pages >= input.maxPages || !leadingTurnNeedsParent(input.messages()) || !input.more())
return
await input.pause()
if (!input.current() || !leadingTurnNeedsParent(input.messages()) || !input.more()) return
if (input.loading()) return load(pages)
await input.loadMore()
return load(pages + 1)
}
return load(0)
}
function leadingTurnNeedsParent(messages: SessionMessageInfo[]) {
const assistant = messages.findIndex((message) => message.type === "assistant")
if (assistant === -1) return false
const boundary = messages.findIndex((message) => message.type === "user" || message.type === "shell")
return boundary === -1 || assistant < boundary
}
@@ -120,7 +98,6 @@ export async function loadOlderTimeline(input: {
after?: (done: boolean) => void
}) {
const id = input.sessionID()
if (!id || !input.more() || input.loading()) return
input.before?.()
@@ -128,7 +105,6 @@ export async function loadOlderTimeline(input: {
if (input.sessionID() === id) input.after?.(true)
throw error
})
if (input.sessionID() !== id) return
input.after?.(true)
}
@@ -7,8 +7,7 @@ import {
type VirtualItem,
} from "@tanstack/solid-virtual"
import { isScrollKeyTarget, scrollKey, scrollKeyOwner, ScrollView } from "@opencode/ui/scroll-view"
import { Predicate } from "effect"
import { Timeline, TimelineRow } from "@opencode/session-ui/timeline/projection"
import { TimelineRow } from "@opencode/session-ui/timeline/projection"
import { useLanguage } from "@/runtime/i18n/language"
import {
batch,
@@ -30,15 +29,11 @@ import { observeElementOffsetReconnectAware } from "./observe-element-offset"
import { filterVirtualIndexes } from "./virtual-items"
const fallbackItemSize = 60
const pendingMarkdown = '[data-component="markdown"]:not([data-markdown-ready])'
// Distance from the bottom that counts as "at the end". Deliberately tight: a collapse clamps
// exactly to the end, while a one-pixel nudge upward is a deliberate move away from it.
const endEpsilon = 0.5
const upwardKeys = new Set(["up", "page-up", "home"])
const cache = new Map<
string,
{
@@ -51,7 +46,7 @@ const cache = new Map<
type Projection = Pick<
ReturnType<typeof createTimelineProjection>,
"activeMessageID" | "messageByID" | "messageLastRowIndex" | "messageRowIndex" | "rowByKey" | "rows"
"activeMessageID" | "messageLastRowIndex" | "messageRowIndex" | "rowByKey" | "rows"
>
type Input = {
@@ -78,8 +73,6 @@ type Input = {
) => boolean
setRevealMessage?: (fn: (id: string, partID?: string) => void) => void
setScrollToEnd?: (fn: () => void) => void
/** A tool call the route asks this timeline to open on; `done` clears it once revealed. */
reveal?: { target: () => string | undefined; done: () => void }
}
type ViewProps = {
@@ -113,14 +106,11 @@ export function createTimelineVirtualizer(input: Input) {
const [rendering, setRendering] = createStore({ initialTail: coldBottomMount, scrollAdjustment: 0 })
const rows = input.projection.rows
const rowByKey = input.projection.rowByKey
const rowKeys = createMemo(() => rows().map(TimelineRow.key), undefined, {
equals: (previous, next) => previous.length === next.length && previous.every((key, index) => key === next[index]),
})
const knownKeys = new Set(rowKeys())
const addedKeys = new Set<string>()
const getItemKey = createMemo(() => {
const keys = rowKeys()
keys
@@ -129,47 +119,39 @@ export function createTimelineVirtualizer(input: Input) {
knownKeys.add(key)
addedKeys.add(key)
})
return (index: number) => keys[index] ?? `removed:${index}`
})
const rangeExtractor = createMemo(() => {
const id = input.projection.activeMessageID()
const active = id ? (input.projection.messageLastRowIndex().get(id) ?? -1) : -1
const initialTail = rendering.initialTail && input.pinned()
return (range: Range) => {
// Batch a bounded cheap suffix, but stop before unknown/large content.
// A large tail still mounts alone before estimates expose earlier history.
const start = Math.max(0, range.startIndex - 2)
const boundary = initialTail
? rows()
.slice(start, range.count)
.findLastIndex(
(row) =>
!(
Predicate.isTagged(row, "AssistantPart") &&
row._tag === "AssistantPart" &&
row.group.type === "context" &&
row.group.refs.length <= 64 &&
!toolOpen[`context:${row.group.key}`]
) && !input.canRenderImmediately?.(row, toolOpen),
)
: -1
const first = Math.min(range.count - 1, start + boundary + 1)
const indexes = initialTail
? Array.from({ length: range.count - first }, (_, index) => first + index)
: defaultRangeExtractor({ ...range, overscan: 2 })
return filterVirtualIndexes(
[...new Set([...indexes, ...(active < 0 ? [] : [active])])].sort((a, b) => a - b),
range.count,
)
}
})
const measuredElements = new WeakSet<Element>()
let touchStart: number | undefined
let touchTarget: EventTarget | null = null
@@ -191,7 +173,6 @@ export function createTimelineVirtualizer(input: Input) {
getScrollElement: () => listRoot() ?? null,
observeElementRect: (instance, callback) => {
reportRect = callback
return observeElementRect(instance, (rect) => {
if (active()) callback(rect)
})
@@ -209,23 +190,19 @@ export function createTimelineVirtualizer(input: Input) {
// clamping row translations lets the compositor paint between
// corrections and makes the content oscillate at the top.
const root = listRoot()
if (
rendering.scrollAdjustment !== 0 &&
root &&
(logicalOffset <= 0 || offset <= 0 || (touchStart !== undefined && offset <= root.clientHeight))
)
flushTouchAdjustment()
if (!scrolling && touchStart === undefined) finishTouchScroll()
})
settleColdBottom()
}
return observeElementOffsetReconnectAware(instance, reportOffset, () => {
if (!active()) return
virtualContent?.querySelectorAll<HTMLDivElement>("[data-index]").forEach(virtualizer.measureElement)
if (input.pinned()) virtualizer.scrollToEnd()
settleColdBottom()
})
@@ -245,23 +222,17 @@ export function createTimelineVirtualizer(input: Input) {
const initial = !measuredElements.has(element)
measuredElements.add(element)
const box = entry?.borderBoxSize[0]
if (box) return Math.round(box.blockSize)
if (initial) {
const size = instance.itemSizeCache.get(instance.options.getItemKey(instance.indexFromElement(element)))
if (size !== undefined || coldPending) return size ?? fallbackItemSize
}
return element.offsetHeight
},
scrollToFn: (offset, options, instance) => {
if (!active()) return
if (batchingColdSizes && input.pinned()) return
setRendering("scrollAdjustment", 0)
if (virtualContent) virtualContent.style.height = `${instance.getTotalSize()}px`
elementScroll(offset, options, instance)
},
@@ -284,7 +255,6 @@ export function createTimelineVirtualizer(input: Input) {
return rangeExtractor()
},
})
const resizeItem = virtualizer.resizeItem
const pendingSizes = new Map<number, { key: string; size: number }>()
let resizeScheduled = false
@@ -293,27 +263,20 @@ export function createTimelineVirtualizer(input: Input) {
virtualizer.resizeItem = (index, size) => {
if (!active()) return
const row = rows()[index]
if (!row) return
const key = TimelineRow.key(row)
if ((virtualizer.itemSizeCache.get(key) ?? fallbackItemSize) === size) {
pendingSizes.delete(index)
return
}
pendingSizes.set(index, { key, size })
if (resizeScheduled) return
resizeScheduled = true
queueMicrotask(() => {
resizeScheduled = false
if (!pendingSizes.size) return
const sizes = [...pendingSizes]
pendingSizes.clear()
if (!active()) return
// The hidden pinned mount needs one bottom write after the whole batch,
// not a layout-forcing scroll adjustment for every measured row.
@@ -321,52 +284,40 @@ export function createTimelineVirtualizer(input: Input) {
batch(() => {
sizes.forEach(([index, value]) => {
const row = rows()[index]
if (!row || TimelineRow.key(row) !== value.key) return
resizeItem(index, value.size)
// TanStack recalculates its range after each resize. Advance the
// logical fold before deciding whether the next row needs anchoring.
if (!touchAdjustment) return
setRendering("scrollAdjustment", (value) => value + touchAdjustment)
touchAdjustment = 0
const root = listRoot()
if (root) reportOffset?.(root.scrollTop, virtualizer.isScrolling)
})
})
batchingColdSizes = false
if (coldPending) pinColdBottom()
settleColdBottom()
if (coldPending) return
if (!input.pinned()) return
const root = listRoot()
// Reopening a settled scroll-to-end operation can fight subsequent keyboard scrolling.
if (root && Math.abs(root.scrollHeight - root.clientHeight - root.scrollTop) > endEpsilon)
virtualizer.scrollToEnd()
})
}
onCleanup(() => pendingSizes.clear())
virtualizer.shouldAdjustScrollPositionOnItemSizeChange = (item, delta, instance) => {
// Prepended rows can resize more than once as deferred content mounts. Keep
// compensating while they remain entirely above the visible content fold.
const first = instance.range?.startIndex
const adjust = addedKeys.has(String(item.key))
? item.end <= (instance.scrollOffset ?? 0) + instance.scrollAdjustments + instance.options.scrollMargin
: first !== undefined && item.index < first
if (!touchScrolling || input.pinned()) return adjust
// iOS defers native scroll writes until momentum ends. Keep the same visual
// anchor now, rather than moving rows now and snapping the viewport back later.
if (adjust) touchAdjustment += delta
return false
}
@@ -375,46 +326,6 @@ export function createTimelineVirtualizer(input: Input) {
flushTouchAdjustment()
}
// Scrolls to a tool call, named by its ID or by the shell ID a backgrounded shell call reports, and expands it and
// the collapsed context group that hides it. Returns whether the call is in the list.
function revealPart(target: string) {
if (!active()) return false
const matches = (ref: { messageID: string; partID: string }) => {
const content = Timeline.resolveContent(input.projection.messageByID().get(ref.messageID), ref.partID)
return (
content?.type === "tool" &&
(content.id === target || (content.state.status !== "streaming" && content.state.metadata?.shellID === target))
)
}
const found = rows()
.flatMap((row, index) =>
Predicate.isTagged(row, "AssistantPart")
? (row.group.type === "part" ? [row.group.ref] : row.group.refs).flatMap((ref) =>
matches(ref) ? [{ group: row.group, index, partID: ref.partID }] : [],
)
: [],
)
.at(-1)
if (!found) return false
const key = found.group.key
setToolOpen(
found.group.type === "context"
? { [`context:${key}`]: true, [`${key}:tool:${found.partID}`]: true }
: { [key]: true },
)
input.onUnpin()
prepareNavigation()
virtualizer.scrollToIndex(found.index, { align: "center" })
return true
}
function prepareNavigation() {
if (touchStart === undefined) touchScrolling = false
flushTouchAdjustment()
@@ -423,57 +334,41 @@ export function createTimelineVirtualizer(input: Input) {
function flushTouchAdjustment() {
const adjustment = rendering.scrollAdjustment
const root = listRoot()
if (!adjustment || !root) return
// Transfer the translation into the native offset in the same paint.
batch(() => {
setRendering("scrollAdjustment", 0)
if (virtualContent) virtualContent.style.height = `${virtualizer.getTotalSize()}px`
elementScroll(Math.max(0, root.scrollTop + adjustment), {}, virtualizer)
})
}
const virtualItemByKey = createMemo(
() => new Map(virtualizer.getVirtualItems().map((item) => [item.key, item] as const)),
)
const virtualRowKeys = createMemo(() => virtualizer.getVirtualItems().map((item) => String(item.key)))
createEffect(() => {
if (!active()) return
const root = listRoot()
if (root) input.setScrollRef(root)
if (virtualContent) input.setContentRef(virtualContent)
queueMicrotask(() => {
if (!active() || !root?.isConnected) return
// A detached view can miss its nonzero ResizeObserver delivery. Publish
// its real viewport before restoring the offset and admitting rows.
reportRect?.({ width: root.offsetWidth, height: root.offsetHeight })
// A route can open this timeline on a tool call, e.g. a shell picked from inside a subagent, instead of its end.
const target = input.reveal?.target()
if (target && revealPart(target)) input.reveal?.done()
else if (input.pinned()) virtualizer.scrollToEnd()
if (input.pinned()) virtualizer.scrollToEnd()
reportOffset?.(root.scrollTop, false)
settleColdBottom()
})
input.setRevealMessage?.((id, partID) => {
if (!active()) return
const partIndex = partID
? rows().findIndex(
(row) =>
Predicate.isTagged(row, "AssistantPart") && row.group.type === "part" && row.group.ref.partID === partID,
(row) => row._tag === "AssistantPart" && row.group.type === "part" && row.group.ref.partID === partID,
)
: -1
const index = partIndex >= 0 ? partIndex : input.projection.messageRowIndex().get(id)
if (index === undefined) return
prepareNavigation()
virtualizer.scrollToIndex(index, { align: "center" })
@@ -490,42 +385,32 @@ export function createTimelineVirtualizer(input: Input) {
let settleQueued = false
let contentObserver: MutationObserver | undefined
let viewportObserver: ResizeObserver | undefined
const pinColdBottom = () => {
if (!active()) return
const root = listRoot()
if (!input.pinned() || !virtualContent || !root) return
// scrollToEnd computes its target from the DOM, not the new size cache.
virtualContent.style.height = `${virtualizer.getTotalSize()}px`
if (Math.abs(root.scrollHeight - root.clientHeight - root.scrollTop) > endEpsilon) virtualizer.scrollToEnd()
// Report after core size adjustments finish so they cannot apply a delta
// twice. This avoids waiting a frame for the native scroll event.
if (virtualizer.scrollOffset !== root.scrollTop) reportOffset?.(root.scrollTop, false)
}
const pendingMeasurements = () => {
const items = virtualizer.getVirtualItems()
return (
(rows().length > 0 && items.length === 0) ||
items.some((item) => !virtualizer.elementsCache.get(item.key)?.isConnected)
)
}
const settleColdBottom = () => {
if (!active() || !coldPending || settleQueued) return
settleQueued = true
queueMicrotask(() => {
settleQueued = false
const root = listRoot()
if (!coldPending || !virtualContent?.isConnected || !root) return
if (virtualContent.querySelector(pendingMarkdown)) return
if (!root.clientHeight) return
// Markdown can finish before ResizeObserver delivers its new box. The
// normal measureElement path skips reads while scrolling; this gate needs
@@ -533,23 +418,17 @@ export function createTimelineVirtualizer(input: Input) {
virtualizer.elementsCache.forEach((element) => {
if (element.isConnected) virtualizer.resizeItem(virtualizer.indexFromElement(element), element.offsetHeight)
})
if (pendingSizes.size || pendingMeasurements()) return
pinColdBottom()
if (input.pinned() && Math.abs(root.scrollHeight - root.clientHeight - root.scrollTop) > 1) return
// The scroll event must update the range before newly exposed rows can reveal.
if (root.scrollHeight > root.clientHeight && Math.abs((virtualizer.scrollOffset ?? 0) - root.scrollTop) > 1)
return
if (rendering.initialTail) {
setRendering("initialTail", false)
settleColdBottom()
return
}
if (pendingSizes.size || pendingMeasurements() || virtualContent.querySelector(pendingMarkdown)) return
coldPending = false
contentObserver?.disconnect()
@@ -557,7 +436,6 @@ export function createTimelineVirtualizer(input: Input) {
virtualContent.style.removeProperty("visibility")
})
}
onMount(() => {
if (!coldPending || !virtualContent) return
contentObserver = new MutationObserver(settleColdBottom)
@@ -569,7 +447,6 @@ export function createTimelineVirtualizer(input: Input) {
})
viewportObserver = new ResizeObserver(settleColdBottom)
const root = listRoot()
if (root) viewportObserver.observe(root)
settleColdBottom()
})
@@ -577,7 +454,6 @@ export function createTimelineVirtualizer(input: Input) {
let measuredSessionKey = input.sessionKey()
createEffect(() => {
const key = input.sessionKey()
if (measuredSessionKey !== key) {
measuredSessionKey = key
virtualizer.measure()
@@ -591,7 +467,6 @@ export function createTimelineVirtualizer(input: Input) {
setListRoot(root)
scrollTop = root.scrollTop
maxScroll = root.scrollHeight - root.clientHeight
if (active()) input.setScrollRef(root)
viewportObserver?.observe(root)
settleColdBottom()
@@ -601,7 +476,6 @@ export function createTimelineVirtualizer(input: Input) {
// the end must stop following, even though the resulting position still looks like the end.
const handleListWheel = (event: WheelEvent & { currentTarget: HTMLDivElement }) => {
input.onUserScroll(event.target)
if (event.deltaY < 0) input.onUnpin()
}
@@ -619,25 +493,20 @@ export function createTimelineVirtualizer(input: Input) {
touchTarget?.addEventListener("touchmove", handleListTouchMove, { passive: true })
touchTarget?.addEventListener("touchend", handleListTouchEnd, { passive: true })
touchTarget?.addEventListener("touchcancel", handleListTouchEnd, { passive: true })
if (root) reportOffset?.(root.scrollTop, virtualizer.isScrolling)
}
const handleListTouchMove = (event: Event) => {
if (!(event instanceof TouchEvent)) return
const current = event.touches[0]?.clientY
if (current === undefined || touchStart === undefined) return
const previous = touchStart
touchStart = current
// A retained target can outlive its whole session view. Only the active
// timeline may change the shared follow state; release still cleans up below.
if (!active()) return
// Dragging the content downward reveals earlier messages.
if (current <= previous) return
// A nested scrollport owns the intent. If it chains into the timeline at a
// boundary, the resulting native timeline scroll below will unpin instead.
if (touchNested) return
@@ -647,7 +516,6 @@ export function createTimelineVirtualizer(input: Input) {
const handleListTouchEnd = () => {
clearTouchTarget()
touchStart = undefined
if (!virtualizer.isScrolling) finishTouchScroll()
}
@@ -657,7 +525,6 @@ export function createTimelineVirtualizer(input: Input) {
touchTarget?.removeEventListener("touchcancel", handleListTouchEnd)
touchTarget = null
}
onCleanup(clearTouchTarget)
// Drag-selecting past the edge and dragging the scrollbar both scroll without a wheel or key,
@@ -666,11 +533,9 @@ export function createTimelineVirtualizer(input: Input) {
input.onUserScroll(event.target)
pointerHeld = true
}
const releasePointer = () => {
pointerHeld = false
}
onMount(() => {
window.addEventListener("pointerup", releasePointer)
window.addEventListener("pointercancel", releasePointer)
@@ -682,14 +547,10 @@ export function createTimelineVirtualizer(input: Input) {
const handleListKeyDown = (event: KeyboardEvent & { currentTarget: HTMLDivElement }) => {
const key = scrollKey(event)
if (!key) return
if (!isScrollKeyTarget(event.target, key)) return
if (scrollKeyOwner(event.currentTarget, event.target, key) !== event.currentTarget) return
input.onUserScroll(event.currentTarget)
if (upwardKeys.has(key)) input.onUnpin()
}
@@ -705,7 +566,6 @@ export function createTimelineVirtualizer(input: Input) {
maxScroll = root.scrollHeight - root.clientHeight
const atEnd = maxScroll - scrollTop <= endEpsilon
const arrived = scrollTop > previousTop + endEpsilon || maxScroll < previousMaxScroll
if (maxScroll <= 1 || (atEnd && arrived)) input.onPin()
else if ((pointerHeld || touchScrolling) && scrollTop < previousTop - endEpsilon) input.onUnpin()
settleColdBottom()
@@ -741,19 +601,13 @@ export function createTimelineVirtualizer(input: Input) {
width: "100%",
height: `${item().size}px`,
overflow: "clip",
"overflow-clip-margin": Predicate.isTagged(row(), "TurnGap") ? undefined : "0.5px",
"overflow-clip-margin": row()._tag === "TurnGap" ? undefined : "0.5px",
}}
>
<div
ref={(value) => {
element = value
if (
!Predicate.isTagged(row(), "UserMessage") ||
!addedKeys.has(rowProps.rowKey) ||
!input.pinned() ||
coldPending
)
if (row()._tag !== "UserMessage" || !addedKeys.has(rowProps.rowKey) || !input.pinned() || coldPending)
return
// The optimistic row can paint before ResizeObserver corrects the tail estimates.
// Measure the mounted tail and pin it in this render's microtask instead.
@@ -772,11 +626,9 @@ export function createTimelineVirtualizer(input: Input) {
>
{props.renderRow(row, () => {
setReady(true)
if (contentMeasureFrame !== undefined) cancelAnimationFrame(contentMeasureFrame)
contentMeasureFrame = requestAnimationFrame(() => {
contentMeasureFrame = undefined
if (active() && element.isConnected) virtualizer.measureElement(element)
})
})}
@@ -840,7 +692,6 @@ export function createTimelineVirtualizer(input: Input) {
data-timeline-virtual-content
ref={(element) => {
virtualContent = element
if (active()) input.setContentRef(element)
}}
style={{
@@ -872,12 +723,10 @@ export function createTimelineVirtualizer(input: Input) {
patchGroupKeys,
presentationKey: input.presentationKey?.(),
})
while (cache.size > 16) cache.delete(cache.keys().next().value!)
coldPending = false
contentObserver?.disconnect()
viewportObserver?.disconnect()
if (active()) {
input.setScrollRef(undefined)
input.setRevealMessage?.(() => {})
@@ -891,7 +740,6 @@ export function createTimelineVirtualizer(input: Input) {
value: (key: string) => toolOpen[key],
set: (key: string, open: boolean) => setToolOpen(key, open),
},
revealPart,
View,
}
}
@@ -27,20 +27,17 @@ export const useSessionHashScroll = (input: {
let pendingKey = ""
let clearing = false
const location = useLocation<{ reveal?: string }>()
const location = useLocation()
const navigate = useNavigate()
const frames = new Set<number>()
const queue = (fn: () => void) => {
const id = requestAnimationFrame(() => {
frames.delete(id)
fn()
})
frames.add(id)
}
const cancel = () => {
for (const id of frames) cancelAnimationFrame(id)
frames.clear()
@@ -49,9 +46,7 @@ export const useSessionHashScroll = (input: {
const clearMessageHash = () => {
cancel()
input.consumePendingMessage(input.sessionKey())
if (input.pendingMessage()) input.setPendingMessage(undefined)
if (!location.hash) return
clearing = true
navigate(location.pathname + location.search, { replace: true })
@@ -59,7 +54,6 @@ export const useSessionHashScroll = (input: {
const updateHash = (id: string) => {
const hash = `#${input.anchor(id)}`
if (location.hash === hash) return
clearing = false
navigate(location.pathname + location.search + hash, {
@@ -69,7 +63,6 @@ export const useSessionHashScroll = (input: {
const scrollToElement = (el: HTMLElement, behavior: ScrollBehavior) => {
const root = input.scroller()
if (!root) return false
const a = el.getBoundingClientRect()
@@ -78,33 +71,27 @@ export const useSessionHashScroll = (input: {
const inset = sticky instanceof HTMLElement ? sticky.offsetHeight : 0
const top = Math.max(0, a.top - b.top + root.scrollTop - inset)
root.scrollTo({ top, behavior })
return true
}
const seek = (id: string, behavior: ScrollBehavior, left = 4): boolean => {
input.revealMessage?.(id)
const el = document.getElementById(input.anchor(id))
if (el) return scrollToElement(el, behavior)
if (left <= 0) return false
queue(() => {
seek(id, behavior, left - 1)
})
return false
}
const scrollToMessage = (message: SessionMessageUser, behavior: ScrollBehavior = "smooth") => {
cancel()
if (input.currentMessageId() !== message.id) input.setActiveMessage(message)
input.revealMessage?.(message.id)
if (seek(message.id, behavior)) {
updateHash(message.id)
return
}
@@ -113,54 +100,39 @@ export const useSessionHashScroll = (input: {
const applyHash = (behavior: ScrollBehavior) => {
const hash = location.hash.slice(1)
if (!hash) {
// A route that opens the session on a tool call leaves the scroll to the timeline, which reveals the call.
if (location.state?.reveal) return
input.follow.toBottom()
const el = input.scroller()
if (el) input.scheduleScrollState(el)
return
}
const messageId = messageIdFromHash(hash)
if (messageId) {
input.follow.unpin()
const msg = messageById().get(messageId)
if (msg) {
scrollToMessage(msg, behavior)
return
}
return
}
const target = document.getElementById(hash)
if (target) {
input.follow.unpin()
scrollToElement(target, behavior)
return
}
input.follow.toBottom()
const el = input.scroller()
if (el) input.scheduleScrollState(el)
}
createEffect(() => {
const hash = location.hash
if (!hash) clearing = false
if (!input.sessionID() || !input.messagesReady()) return
cancel()
queue(() => applyHash("auto"))
@@ -172,14 +144,11 @@ export const useSessionHashScroll = (input: {
visibleUserMessages()
let targetId = input.pendingMessage()
if (!targetId) {
const key = input.sessionKey()
if (pendingKey !== key) {
pendingKey = key
const next = input.consumePendingMessage(key)
if (next) {
input.setPendingMessage(next)
targetId = next
@@ -188,16 +157,13 @@ export const useSessionHashScroll = (input: {
}
if (!targetId && !clearing) targetId = messageIdFromHash(location.hash)
if (!targetId) return
const pending = input.pendingMessage() === targetId
const msg = messageById().get(targetId)
if (!msg) return
if (pending) input.setPendingMessage(undefined)
if (input.currentMessageId() === targetId && !pending) return
input.follow.unpin()
@@ -207,19 +173,14 @@ export const useSessionHashScroll = (input: {
createEffect(() => {
const sessionID = input.sessionID()
if (!sessionID || !input.messagesReady()) return
visibleUserMessages()
let targetId = input.pendingMessage()
if (!targetId && !clearing) targetId = messageIdFromHash(location.hash)
if (!targetId) return
if (messageById().has(targetId)) return
if (!input.historyMore() || input.historyLoading()) return
void input.loadMore(sessionID)
@@ -13,61 +13,40 @@ export function useSessionTabAvatarState(
const settings = useSettings()
const connection = createMemo(() => global.servers.list().find((item) => ServerConnection.key(item) === server()))
const serverCtx = useServerCtx(connection)
const sessions = createMemo(() => {
const data = serverCtx()?.data
if (!data) return []
if (!root?.()) return data.session.list()
const id = sessionId()
return [...new Set([id, ...data.session.family(id)])].flatMap((id) => {
const info = data.session.get(id)
return info ? [info] : []
})
})
const hasPermissions = createMemo(() => {
if (settings.permissions.autoApprove()) return false
const ctx = serverCtx()
if (!ctx) return false
return !!sessionPermissionRequest(sessions(), ctx.data.session.permission.list, sessionId())
})
const hasForms = createMemo(() => {
const data = serverCtx()?.data
if (!data) return false
return !!sessionFormRequest(sessions(), data.session.form.list, sessionId())
})
const needsAttention = createMemo(() => hasPermissions() || hasForms())
const unread = createMemo(
() => needsAttention() || (serverCtx()?.notification.session.unseenCount(sessionId()) ?? 0) > 0,
)
const loading = createMemo(() => {
const data = serverCtx()?.data
if (!data) return false
if (needsAttention()) return false
// Like the TUI, work waiting in the inbox counts as busy; parked synthetic context does not.
const busy = (id: string) =>
data.session.status(id) === "running" || data.session.pending.list(id).some((item) => item.type !== "synthetic")
if (root?.())
return data.session.list().some((session) => data.session.root(session.id) === sessionId() && busy(session.id))
return busy(sessionId())
return data.session
.list()
.some((session) => data.session.root(session.id) === sessionId() && data.session.status(session.id) === "running")
return data.session.status(sessionId()) === "running"
})
return { unread, loading }
}
@@ -272,30 +272,3 @@
transform: translateX(-50%);
}
}
/* A prompt that arrives in a background tab rings it once, like the TUI's prompt pulse. */
[data-titlebar-tab] [data-slot="tab-prompt-pulse"] {
position: absolute;
inset: 0;
border-radius: inherit;
pointer-events: none;
opacity: 0;
box-shadow: inset 0 0 0 1px var(--v2-border-border-strong);
animation: tab-prompt-pulse 900ms ease-out;
}
@keyframes tab-prompt-pulse {
20% {
opacity: 1;
}
100% {
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) {
[data-titlebar-tab] [data-slot="tab-prompt-pulse"] {
animation: none;
}
}
+2 -58
View File
@@ -1,6 +1,5 @@
import { createEffect, createMemo, createSignal, onCleanup, Show, type Ref } from "solid-js"
import { createStore } from "solid-js/store"
import { Predicate } from "effect"
import { makeEventListener } from "@solid-primitives/event-listener"
import { createResizeObserver } from "@solid-primitives/resize-observer"
import { createMutation } from "@tanstack/solid-query"
@@ -52,75 +51,44 @@ export function TabNavItem(props: {
event.stopPropagation()
props.onClose()
}
const servers = useServers()
const serverCtx = useServerCtx(() => servers.list.find((item) => ServerConnection.key(item) === props.server))
const project = createMemo(() => {
const session = props.session
if (!session) return
return serverCtx()?.projects.forSession(session)
})
const title = createMemo(() => {
const session = props.session
return sessionTabTitle(session ? session.title : props.fallbackTitle, language.t("session.tab.session"))
})
const projectName = createMemo(() => {
const session = props.session
if (!session) return
return displayName(project() ?? { worktree: session.location.directory })
})
const previewPath = createMemo(() => {
const session = props.session
if (!session) return
const home = serverCtx()?.sync.data.path.home
return home ? session.location.directory.replace(home, "~") : session.location.directory
})
// Only label the server when multiple servers are connected.
const serverLabel = createMemo(() => {
if (servers.list.length <= 1) return
const conn = servers.list.find((item) => ServerConnection.key(item) === props.server)
return conn ? serverName(conn) : undefined
})
// Like the TUI, a prompt sent to a background tab's session pulses the tab once.
const [promptPulse, setPromptPulse] = createSignal(0)
createEffect(() => {
const data = serverCtx()?.data
const sessionID = props.session?.id
if (!data || !sessionID) return
onCleanup(
data.on("session.inbox.enqueued", (event) => {
if (props.active || event.data.item.type !== "user" || data.session.root(event.data.sessionID) !== sessionID)
return
setPromptPulse((count) => count + 1)
}),
)
})
const [popoverOpen, setPopoverOpen] = createSignal(false)
const previewBlocked = () => !!props.dragging || editing() || menu.open || !!props.pressed || !props.session
const measureTitleOverflow = () => {
if (!titleEl || editing()) {
setTitleOverflowing(false)
return
}
setTitleOverflowing(titleEl.scrollWidth > titleEl.clientWidth)
}
@@ -172,10 +140,8 @@ export function TabNavItem(props: {
createEffect(() => {
if (editing()) return
if (!titleEl) return
const value = title()
if (value === undefined) return
titleEl.textContent = value
})
@@ -183,10 +149,8 @@ export function TabNavItem(props: {
const openRename = (event?: MouseEvent) => {
event?.preventDefault()
event?.stopPropagation()
if (props.dragging || editing() || rename.isPending) return
const session = props.session
if (!session) return
titleEl.textContent = session.title ?? ""
setEditing(true)
@@ -205,9 +169,7 @@ export function TabNavItem(props: {
"pointerdown",
(event) => {
const target = event.target
if (!(target instanceof Node)) return
if (tabRoot.contains(target)) return
void closeRename(true)
},
@@ -221,8 +183,7 @@ export function TabNavItem(props: {
<div
ref={(el) => {
tabRoot = el
if (Predicate.isFunction(props.ref)) props.ref(el)
if (typeof props.ref === "function") props.ref(el)
}}
data-titlebar-tab
data-slot="titlebar-tab-item"
@@ -244,9 +205,6 @@ export function TabNavItem(props: {
closeTab(event)
}}
>
<Show when={promptPulse()} keyed>
<span data-slot="tab-prompt-pulse" aria-hidden="true" onAnimationEnd={() => setPromptPulse(0)} />
</Show>
<Menu.Context.Trigger
as="a"
disabled={editing() || props.dragging}
@@ -263,20 +221,15 @@ export function TabNavItem(props: {
onMouseDown={(event) => {
// Navigate on mousedown to shave the press-release delay off tab switches.
if (event.button !== 0) return
if (editing()) return
if (props.suppressNavigation) return
props.onNavigate()
}}
onClick={(event) => {
event.preventDefault()
// Mouse navigation already happened on mousedown; detail 0 means keyboard activation.
if (event.detail > 0) return
if (editing()) return
if (props.suppressNavigation) return
props.onNavigate()
}}
@@ -324,14 +277,11 @@ export function TabNavItem(props: {
onDblClick={openRename}
onKeyDown={(event) => {
event.stopPropagation()
if (event.key === "Enter") {
event.preventDefault()
void closeRename(true)
return
}
if (event.key !== "Escape") return
event.preventDefault()
titleEl.textContent = props.session?.title ?? ""
@@ -371,7 +321,6 @@ export function TabNavItem(props: {
modal={false}
onOpenChange={(open) => {
setMenu("open", open)
if (open) setPopoverOpen(false)
}}
>
@@ -423,17 +372,15 @@ export function DraftTabItem(props: {
orientation?: "horizontal" | "vertical"
}) {
const language = useLanguage()
const closeTab = (event: MouseEvent) => {
event.preventDefault()
event.stopPropagation()
props.onClose()
}
return (
<div
ref={(el) => {
if (Predicate.isFunction(props.ref)) props.ref(el)
if (typeof props.ref === "function") props.ref(el)
}}
data-titlebar-tab
data-slot="titlebar-tab-item"
@@ -465,16 +412,13 @@ export function DraftTabItem(props: {
onMouseDown={(event) => {
// Navigate on mousedown to shave the press-release delay off tab switches.
if (event.button !== 0) return
if (props.suppressNavigation) return
props.onNavigate()
}}
onClick={(event) => {
event.preventDefault()
// Mouse navigation already happened on mousedown; detail 0 means keyboard activation.
if (event.detail > 0) return
if (props.suppressNavigation) return
props.onNavigate()
}}
+1 -26
View File
@@ -40,7 +40,6 @@ function SessionTabSlot(props: {
return props.index
},
})
let ref!: HTMLDivElement
return (
@@ -93,12 +92,10 @@ function SessionTabEntry(props: {
const pending = createMemo(() => tabs.pendingSession(props.tab.server, props.tab.sessionId))
const cachedSession = createMemo(() => props.serverCtx?.data.session.get(props.tab.sessionId))
const persisted = createMemo(() => tabs.info[props.id])
const [loadedSession] = createResource(
() => {
if (pending()) return null
const ctx = props.serverCtx
return ctx ? { id: props.tab.sessionId, ctx } : null
},
({ id, ctx }) =>
@@ -107,7 +104,6 @@ function SessionTabEntry(props: {
.then(() => ctx.data.session.get(id))
.catch(() => undefined),
)
const session = createMemo(() => (pending() ? undefined : (cachedSession() ?? loadedSession())))
const missingSession = createMemo(() => !pending() && !!props.serverCtx && !loadedSession.loading && !session())
const visible = createMemo(() => !!pending() || !!session() || missingSession() || !!persisted()?.title)
@@ -115,17 +111,14 @@ function SessionTabEntry(props: {
const rename = async (title: string) => {
const value = session()
const ctx = props.serverCtx
if (!value || !ctx) return
ctx.data.session.remember({ ...value, title })
try {
await ctx.sdk.api.session.update({ sessionID: value.id, title })
} catch (err) {
const current = session()
const currentCtx = props.serverCtx
if (current && currentCtx) currentCtx.data.session.remember({ ...current, title: value.title })
showToast({
title: language.t("common.requestFailed"),
@@ -139,32 +132,25 @@ function SessionTabEntry(props: {
createEffect(() => {
const ctx = props.serverCtx
const value = session()
if (!ctx || !value || props.active || ctx.sdk.connection.status() !== "connected") return
const timer = window.setTimeout(
() =>
void Promise.allSettled([
ctx.data.session.sync(value.id, { children: true }),
// The selected timeline loads the transcript; inactive tabs need attention, metadata, and the inbox,
// whose waiting work keeps the tab busy.
// The selected timeline loads transcript and inbox data; inactive tabs need only attention and metadata.
ctx.data.session.permission.sync(value.id),
ctx.data.session.form.sync(value.id),
ctx.data.session.pending.sync(value.id),
]),
300 + props.index * 50,
)
onCleanup(() => window.clearTimeout(timer))
})
createEffect(() => {
const value = session()
if (!value) return
tabs.rememberSessionInfo(props.tab, value)
const current = sdk()
if (!current) return
createTabComposerState(tabs, props.tab, current.scope, {
dir: base64Encode(value.location.directory),
@@ -213,7 +199,6 @@ function DraftTabSlot(props: {
return props.index
},
})
let ref!: HTMLDivElement
return (
@@ -285,7 +270,6 @@ export function TitlebarTabStrip(props: {
const current = props.currentTab
const key = adjacentTabKey(visibleTabIds(), current ? tabKey(current) : undefined, offset)
const next = props.tabs.find((tab) => tabKey(tab) === key)
if (next) props.onNavigate(next)
}
@@ -327,12 +311,9 @@ export function TitlebarTabStrip(props: {
]}
onDragStart={(event) => {
const source = event.operation.source
if (!source) return
const tab = props.tabs.find((item) => tabKey(item) === source.id.toString())
if (!tab) return
if (vertical()) return
const tabEl = source.element?.querySelector<HTMLDivElement>("[data-titlebar-tab]")
props.onNavigate(tab, tabEl ?? undefined)
@@ -340,11 +321,9 @@ export function TitlebarTabStrip(props: {
onDragEnd={(event) => {
const current = visibleTabIds()
const source = event.operation.source
if (event.canceled || !isSortable(source)) return
const { initialIndex, index } = source
if (initialIndex !== index) {
props.onReorder(
mergeVisibleTabOrder(
@@ -369,10 +348,8 @@ export function TitlebarTabStrip(props: {
let ref!: HTMLDivElement
const visibleIndex = () => visibleTabs().findIndex((item) => tabKey(item) === id)
useTabShortcut(visibleIndex, () => props.onNavigate(tab, ref))
const serverCtx = useServerCtx(() => {
if (tab.type !== "session") return
return global.servers.list().find((item) => ServerConnection.key(item) === tab.server)
})
@@ -436,9 +413,7 @@ function useTabShortcut(index: () => number, onSelect: () => void) {
command.register(() => {
const number = index() + 1
if (number < 1 || number > 9) return []
return [
{
id: `tab.${number}`,
+3 -12
View File
@@ -59,7 +59,6 @@ export function Titlebar(props: { verticalTabs?: { mount?: HTMLElement } }) {
const location = useLocation()
const mobile = createMediaQuery("(max-width: 767px)")
const bottom = createMemo(() => mobile() && settings.general.mobileTitlebarPosition() === "bottom")
const mobileTop = createMemo(() => platform.platform === "web" && mobile() && !bottom())
const mac = createMemo(() => platform.platform === "desktop" && platform.os === "macos")
const windows = createMemo(() => platform.platform === "desktop" && platform.os === "windows")
@@ -151,17 +150,9 @@ export function Titlebar(props: { verticalTabs?: { mount?: HTMLElement } }) {
platform.platform === "web"
? bottom()
? "calc(28px + max(8px, var(--safe-area-inset-bottom, env(safe-area-inset-bottom, 0px))))"
: mobileTop()
? "calc(28px + 16px + env(safe-area-inset-top, 0px))"
: "calc(28px + max(8px, env(safe-area-inset-top, 0px)))"
: "calc(28px + max(8px, env(safe-area-inset-top, 0px)))"
: undefined,
// iOS blurs page content just below the status bar in Home Screen web apps, so keep the phone titlebar row
// 16px clear of the safe area, as ChatGPT's phone header does.
"padding-top": bottom()
? "0px"
: mobileTop()
? "calc(16px + env(safe-area-inset-top, 0px))"
: "env(safe-area-inset-top, 0px)",
"padding-top": bottom() ? "0px" : "env(safe-area-inset-top, 0px)",
"padding-bottom": bottom() ? "var(--safe-area-inset-bottom, env(safe-area-inset-bottom, 0px))" : "0px",
"min-height": minHeight(),
// Keep native macOS traffic lights clear even when the desktop window is narrow.
@@ -521,7 +512,7 @@ export function Titlebar(props: { verticalTabs?: { mount?: HTMLElement } }) {
<div
class="h-full flex-1 overflow-hidden flex flex-row items-center gap-1.5 px-2 md:pe-3"
classList={{
"pt-[max(0px,calc(8px-env(safe-area-inset-top,0px)))]": !bottom() && !windows() && !mobileTop(),
"pt-[max(0px,calc(8px-env(safe-area-inset-top,0px)))]": !bottom() && !windows(),
"pb-[max(0px,calc(8px-var(--safe-area-inset-bottom,env(safe-area-inset-bottom,0px))))]": bottom(),
"pl-4": macTrafficLights(),
// Center the 20px app icon over the sidebar's 16px icon column.
+7 -24
View File
@@ -1,40 +1,33 @@
import { createSimpleContext } from "@opencode/ui/context"
import { isLocationNotFoundError, type LocationGetOutput, type LocationRef } from "@opencode/client/promise"
import type { LocationGetOutput, LocationRef } from "@opencode/client/promise"
import { retry } from "@opencode/util/retry"
import { type Accessor, createEffect, createMemo, onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
import { type LocationContext, useServerSDK } from "@/runtime/server/client"
import { useData, useServer } from "@/runtime/server/current"
export type { LocationContext } from "@/runtime/server/client"
export type WorkspaceLocation = LocationContext & {
readonly ref: LocationRef
readonly current: LocationGetOutput | undefined
/** Set only when the server reports that this exact Location's folder does not exist. */
readonly missing: LocationRef | undefined
}
const context = createSimpleContext({
name: "Location",
init: (props: { directory: string; workspaceID?: string }) => {
init: (props: { directory: string | Accessor<string>; workspaceID?: string | Accessor<string | undefined> }) => {
const serverSDK = useServerSDK()
const server = useServer()
const data = useData()
const ref = createMemo(
() => ({
directory: props.directory,
workspaceID: props.workspaceID,
directory: typeof props.directory === "function" ? props.directory() : props.directory,
workspaceID: typeof props.workspaceID === "function" ? props.workspaceID() : props.workspaceID,
}),
undefined,
{
equals: (previous, next) => previous.directory === next.directory && previous.workspaceID === next.workspaceID,
},
)
const current = createMemo(() => data.location.info(ref()))
const [state, setState] = createStore<{ missing?: LocationRef }>({})
createEffect(() => {
const location = ref()
@@ -42,37 +35,27 @@ const context = createSimpleContext({
onCleanup(() => {
stale = true
})
setState("missing", undefined)
if (serverSDK.connection.status() !== "connected") return
// Generic sync failures do not prove the directory is missing; only the server's typed
// LocationNotFoundError does, and retrying it cannot succeed until the folder changes.
// A failed sync does not prove the directory is missing. Keep recovery local to reads.
void retry(() => (stale ? Promise.resolve() : data.location.sync(location)), {
retryIf: (error) => !stale && !isLocationNotFoundError(error),
}).catch((error) => {
if (stale || !isLocationNotFoundError(error)) return
setState("missing", location)
})
retryIf: () => !stale,
}).catch(() => undefined)
})
createEffect(() => {
const id = current()?.project.id
if (!id || serverSDK.connection.status() !== "connected") return
// Showing a Location is the demand for its project's worktree inventory (workspace styling, picker).
void server.ctx.sync.worktrees.list(id).then(() => server.ctx.sync.worktrees.refresh(id))
})
const location = createMemo(() => serverSDK.ensureDirSdkContext(current()?.directory ?? ref().directory))
return createMemo<WorkspaceLocation>(() => ({
...location(),
ref: ref(),
current: current(),
missing: state.missing,
}))
},
})
export const useWorkspaceLocation: () => Accessor<WorkspaceLocation> = context.use
export const LocationProvider = context.provider
@@ -49,12 +49,10 @@ export function DirectoryPickerDialog(props: DirectoryPickerDialogProps) {
const dialog = useDialog()
const language = useLanguage()
const policy = pickerMode(props.mode ?? "directory", props.start)
const action = {
file: language.t("dialog.directory.action.selectFile"),
directory: language.t("dialog.directory.action.selectFolder"),
}
const [root, setRoot] = createSignal("")
const [input, setInput] = createSignal("")
const [selected, setSelected] = createSignal("")
@@ -76,42 +74,31 @@ export function DirectoryPickerDialog(props: DirectoryPickerDialogProps) {
() => sdk.api.location.get().catch(() => undefined),
{ initialValue: undefined },
)
const home = createMemo(() => sync.data.path.home || "")
const location = createMemo(() => {
const current = props.location ?? fallbackPath.latest
const current = props.location ?? fallbackPath()
return current ? { directory: current.directory } : undefined
})
const start = createMemo(
() =>
props.start ||
sync.data.path.home ||
props.location?.directory ||
sync.data.path.directory ||
fallbackPath.latest?.directory,
fallbackPath()?.directory,
)
const search = createDirectorySearch({ sdk, home, location, base: () => root() || start() })
const [suggestions] = createResource(input, async (value) => {
const cleaned = cleanPickerInput(value)
const typed = cleaned.replace(/\/+$/, "")
const current = displayPickerPath(root(), value, home()).replace(/\/+$/, "")
if (!cleaned || (root() && typed === current)) return { query: value, items: [] }
const directories = (await search(value)).map((absolute) => ({ absolute, type: "directory" as const }))
if (!policy.includeFiles) return { query: value, items: directories.slice(0, 5) }
const base = location()?.directory
if (!base) return { query: value, items: directories.slice(0, 5) }
const query = pickerRelativePath(base, pickerAbsoluteInput(cleaned, home(), root() || base))
if (query === undefined) return { query: value, items: directories.slice(0, 5) }
const files = await sdk.api.file
.find({
location: location(),
@@ -121,64 +108,48 @@ export function DirectoryPickerDialog(props: DirectoryPickerDialogProps) {
})
.then((result) => result.data)
.catch(() => [])
const results = [
...directories,
...files.map((entry) => ({ absolute: pickerAbsolutePath(entry.path, base), type: "file" as const })),
]
return {
query: value,
items: Array.from(new Map(results.map((result) => [result.absolute, result])).values()).slice(0, 8),
}
})
const currentSuggestions = createMemo(() => currentPickerSuggestions(suggestions.latest, input()))
const currentSuggestions = createMemo(() => currentPickerSuggestions(suggestions(), input()))
async function load(path: string, generation: number, eager = false) {
const key = path.replace(/\/+$/, "")
setError(false)
const absolute = absoluteTreePath(root(), key)
const existing = listings.get(key)
if (existing && !eager) loads.promote(`${generation}:${key}`)
const request =
existing ??
loads.schedule(`${generation}:${key}`, eager ? "background" : "user", () => {
if (!activeTreeNavigation(generation, navigation)) return Promise.resolve(undefined)
const current = location()
if (!current) return Promise.resolve(undefined)
return listPickerDirectory(sdk, current, absolute).catch(() => undefined)
})
listings.set(key, request)
const nodes = await request
if (!activeTreeNavigation(generation, navigation)) return false
if (!nodes) {
listings.delete(key)
if (!key) setError(true)
return false
}
tree?.batch(policy.entries(key, nodes).map((item) => ({ type: "add", path: item })))
if (!eager && advanceTreePreload(advanced, key)) {
for (const directory of preloadTreeDirectories(key, nodes)) void load(directory, generation, true)
}
return true
}
async function navigate(path: string) {
const value = policy.navigation(pickerAbsoluteInput(cleanPickerInput(path), home(), root() || start() || home()))
if (!value) return
const token = ++navigation
setLoading(true)
@@ -192,7 +163,6 @@ export function DirectoryPickerDialog(props: DirectoryPickerDialogProps) {
advanced.clear()
tree?.resetPaths([])
const valid = await load("", token)
if (!activeTreeNavigation(token, navigation)) return
setRootValid(valid)
setLoading(false)
@@ -201,11 +171,9 @@ export function DirectoryPickerDialog(props: DirectoryPickerDialogProps) {
function complete() {
const items = currentSuggestions()
const match = items[activeSuggestion()] ?? items[0]
if (!match) return
const value = displayPickerPath(match.absolute, input(), home())
setInput(match.type === "directory" && !value.endsWith("/") ? value + "/" : value)
if (match.type === "file") {
setSelected(policy.selection(root(), pickerFileSearchQuery(root(), match.absolute, home())) ?? "")
setSuggestionsOpen(false)
@@ -216,10 +184,8 @@ export function DirectoryPickerDialog(props: DirectoryPickerDialogProps) {
function chooseSuggestion(suggestion: { absolute: string; type: "file" | "directory" }) {
if (suggestion.type === "directory") {
void navigate(suggestion.absolute)
return
}
setInput(displayPickerPath(suggestion.absolute, input(), home()))
setSelected(policy.selection(root(), pickerFileSearchQuery(root(), suggestion.absolute, home())) ?? "")
setSuggestionsOpen(false)
@@ -233,31 +199,23 @@ export function DirectoryPickerDialog(props: DirectoryPickerDialogProps) {
function activeSuggestionValue() {
const items = currentSuggestions()
return items[activeSuggestion()] ?? items[0]
}
const keyActions = new Map([
["ArrowDown", () => moveSuggestion(1)],
["ArrowUp", () => moveSuggestion(-1)],
[
"Enter",
() => {
const suggestion = activeSuggestionValue()
if (suggestion) chooseSuggestion(suggestion)
if (!suggestion) void navigate(input())
},
],
["Tab", complete],
])
const keyActions: Partial<Record<string, () => void>> = {
ArrowDown: () => moveSuggestion(1),
ArrowUp: () => moveSuggestion(-1),
Enter: () => {
const suggestion = activeSuggestionValue()
if (suggestion) chooseSuggestion(suggestion)
if (!suggestion) void navigate(input())
},
Tab: complete,
}
function handleInputKey(event: KeyboardEvent) {
const action = keyActions.get(event.key)
const action = keyActions[event.key]
if (!action) return
if (event.key === "Tab" && event.shiftKey) return
event.preventDefault()
action()
@@ -265,7 +223,6 @@ export function DirectoryPickerDialog(props: DirectoryPickerDialogProps) {
function resolve() {
const path = policy.result(root(), selected(), rootValid())
if (!path) return
props.onSelect(props.multiple ? [path] : path)
dialog.close()
@@ -273,11 +230,10 @@ export function DirectoryPickerDialog(props: DirectoryPickerDialogProps) {
onMount(() => {
const closeSuggestions = (event: PointerEvent) => {
if (event.target instanceof Node && pathArea?.contains(event.target)) return
if (pathArea?.contains(event.target as Node)) return
setSuggestionsOpen(false)
setActiveSuggestion(-1)
}
document.addEventListener("pointerdown", closeSuggestions)
onCleanup(() => document.removeEventListener("pointerdown", closeSuggestions))
tree = new FileTree({
@@ -310,7 +266,6 @@ export function DirectoryPickerDialog(props: DirectoryPickerDialogProps) {
setSelected(path ? (policy.selection(root(), path) ?? "") : "")
},
})
if (!container) return
tree.render({ containerWrapper: container })
tree.getFileTreeContainer()?.classList.add("directory-picker-tree")
@@ -318,7 +273,6 @@ export function DirectoryPickerDialog(props: DirectoryPickerDialogProps) {
createEffect(() => {
const path = start()
if (!path || !location() || root()) return
void navigate(path)
})
@@ -393,16 +347,13 @@ export function DirectoryPickerDialog(props: DirectoryPickerDialogProps) {
const scroller = tree
?.getFileTreeContainer()
?.shadowRoot?.querySelector<HTMLElement>("[data-file-tree-virtualized-scroll]")
if (!scroller) return
const next = nextTreeScrollTop(
scroller.scrollTop,
event.deltaY,
scroller.scrollHeight,
scroller.clientHeight,
)
if (next === scroller.scrollTop) return
event.preventDefault()
scroller.scrollTop = next
@@ -1,7 +1,7 @@
import { useDialog } from "@opencode/ui/context/dialog"
import { ServerConnection } from "@/runtime/server/registry"
import { usePlatform } from "@/runtime/platform/platform"
import { lazy, Suspense } from "solid-js"
import { lazy } from "solid-js"
import type { LocationRef } from "@opencode/client/promise"
import { directoryPickerKind } from "./policy"
@@ -24,29 +24,17 @@ export function useDirectoryPicker() {
return (input: DirectoryPickerInput) => {
if (directoryPickerKind(platform.platform, input.server) === "native" && platform.platform === "desktop") {
void platform.openDirectoryPickerDialog({ title: input.title, multiple: input.multiple }).then(input.onSelect)
return
}
let selected = false
const onSelect = (result: string | string[] | null) => {
selected = result !== null
input.onSelect(result)
}
const cancel = () => {
if (!selected) input.onSelect(null)
}
// Dialogs render under the caller's owner, so loading the lazy chunk would otherwise suspend the caller's boundary.
dialog.show(
() => (
<Suspense>
<DirectoryPickerDialog {...input} onSelect={onSelect} />
</Suspense>
),
cancel,
)
dialog.show(() => <DirectoryPickerDialog {...input} onSelect={onSelect} />, cancel)
}
}
@@ -25,8 +25,7 @@ for (const factory of [createTimelineProjection, createReactiveTimelineProjectio
test("only crosses the renderable boundary on text deltas, while retaining live content", () => {
createRoot((dispose) => {
let visits = 0
const [state, setState] = createStore<{ messages: SessionMessageInfo[] }>({
const [state, setState] = createStore({
messages: [
{
id: "old",
@@ -34,25 +33,22 @@ for (const factory of [createTimelineProjection, createReactiveTimelineProjectio
text: "history",
get time() {
visits++
return { created: 0 }
},
},
assistant("old-answer", [{ type: "text", text: "History remains visible." }]),
{ id: "user", type: "user", text: "question", time: { created: 1 } },
assistant("answer", [{ type: "text", text: "" }]),
],
] as SessionMessageInfo[],
})
const projection = factory({
sessionMessages: () => state.messages,
status: () => ({ type: "busy" }),
reasoningMode: () => "compact",
shellToolDefaultOpen: () => false,
editToolDefaultOpen: () => false,
pendingInputIDs: () => new Set(),
pendingUserMessageIDs: () => new Set(),
})
const update = (text: string) =>
setState(
"messages",
@@ -61,7 +57,6 @@ for (const factory of [createTimelineProjection, createReactiveTimelineProjectio
if (message.type === "assistant" && message.content[0].type === "text") message.content[0].text = text
}),
)
const empty = projection.rows()
visits = 0
update(" \n\t")
@@ -87,14 +82,7 @@ for (const factory of [createTimelineProjection, createReactiveTimelineProjectio
test("matches full construction through grouping, notices, history and preference transitions", () => {
createRoot((dispose) => {
const [state, setState] = createStore<{
messages: SessionMessageInfo[]
status: SessionStatus
reasoning: ReasoningMode
shell: boolean
edit: boolean
pending: Set<string>
}>({
const [state, setState] = createStore({
messages: [
assistant("answer", [
{ type: "reasoning", text: "Inspect the source", time: { created: 1 } },
@@ -121,25 +109,22 @@ for (const factory of [createTimelineProjection, createReactiveTimelineProjectio
time: { created: 1 },
},
]),
],
status: { type: "busy" },
reasoning: "compact",
] as SessionMessageInfo[],
status: { type: "busy" } as SessionStatus,
reasoning: "compact" as ReasoningMode,
shell: false,
edit: false,
pending: new Set<string>(),
})
const projection = factory({
sessionMessages: () => state.messages,
status: () => state.status,
reasoningMode: () => state.reasoning,
shellToolDefaultOpen: () => state.shell,
editToolDefaultOpen: () => state.edit,
pendingInputIDs: () => state.pending,
pendingUserMessageIDs: () => state.pending,
})
let previous: TimelineRow.TimelineRow[] | undefined
const verify = () => {
const full = Timeline.constructSessionMessageRows(
state.messages,
@@ -149,7 +134,6 @@ for (const factory of [createTimelineProjection, createReactiveTimelineProjectio
state.shell,
state.edit,
)
previous = reuseTimelineRows(previous, full.rows)
expect(projection.rows()).toEqual(previous)
expect(projection.activeMessageID()).toBe(full.activeMessageID)
@@ -165,7 +149,6 @@ for (const factory of [createTimelineProjection, createReactiveTimelineProjectio
expect(projection.rowByKey().get(TimelineRow.key(row))).toBe(projection.rows()[index])
})
}
const change = (update: (message: SessionMessageAssistant) => void) => {
setState(
"messages",
@@ -176,7 +159,6 @@ for (const factory of [createTimelineProjection, createReactiveTimelineProjectio
)
verify()
}
verify()
change((message) => {
if (message.content[2].type === "text") message.content[2].text = "Split the context group"
@@ -233,7 +215,6 @@ for (const factory of [createTimelineProjection, createReactiveTimelineProjectio
verify()
change((message) => {
const tool = message.content.at(-1)
if (tool?.type === "tool" && tool.state.status === "completed")
tool.state.metadata = { files: [{ status: "modified" }] }
})
@@ -243,7 +224,6 @@ for (const factory of [createTimelineProjection, createReactiveTimelineProjectio
expect(projection.rows().at(-1)?._tag).toBe("Thinking")
change((message) => {
const part = message.content.at(-1)
if (part?.type === "reasoning") part.time = { created: 3, completed: 4 }
})
change((message) => {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/cli",
"version": "2.0.23",
"version": "2.0.22",
"type": "module",
"license": "MIT",
"bin": {
+1 -66
View File
@@ -7,7 +7,6 @@ import { Script } from "@opencode/script"
import { createSolidTransformPlugin } from "@opentui/solid/bun-plugin"
import type { BunPlugin } from "bun"
import pkg from "../package.json"
import { discoverPluginRuntimeSpecifiers } from "../src/plugin-runtime"
import { buildAppArchive } from "./app-assets"
import { verifyArtifact, verifySimulationGraph } from "./verify-artifact"
import { resolveOpencodePty } from "./opencode-pty"
@@ -79,63 +78,6 @@ const appAssetsPlugin: BunPlugin = {
}))
},
}
const pluginRuntimeSpecifiers = discoverPluginRuntimeSpecifiers()
const pluginRuntimeLoaderCode = (specifier: string) => {
if (specifier.startsWith("effect/")) {
const subpath = specifier.slice("effect/".length)
const slash = subpath.lastIndexOf("/")
const parent = slash === -1 ? "effect" : `effect/${subpath.slice(0, slash)}`
const member = slash === -1 ? subpath : subpath.slice(slash + 1)
const parentPath = pluginRuntimeSpecifiers.get(parent)
if (parentPath && /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(member) && member in require(parentPath)) {
return `() => require(${JSON.stringify(parent)}).${member}`
}
}
return `() => require(${JSON.stringify(specifier)})`
}
const pluginRuntimeSource = `const runtimeModulesKey = Symbol.for("opencode.plugin.runtime-modules")
const modules = {
${[...pluginRuntimeSpecifiers.keys()].map((specifier) => ` ${JSON.stringify(specifier)}: ${pluginRuntimeLoaderCode(specifier)},`).join("\n")}
}
export function ensurePluginRuntime() {
const state = globalThis
if (state[runtimeModulesKey]) return state[runtimeModulesKey]
state[runtimeModulesKey] = modules
Bun.plugin({
name: "opencode-plugin-runtime",
setup(build) {
for (const [specifier, load] of Object.entries(modules)) {
build.module(specifier, () => ({ exports: load(), loader: "object" }))
}
build.onLoad(
{ filter: /[/\\\\]node_modules[/\\\\](?:effect|@opencode[/\\\\]plugin)[/\\\\].*\\.[cm]?[jt]sx?(?:[?#].*)?$/ },
(args) => {
const match = args.path.replaceAll("\\\\", "/").match(/\\/node_modules\\/((?:@opencode\\/plugin|effect)\\/.+)$/)
const target = match ? match[1] : args.path
throw new Error(
\`Cannot load "\${target}" from plugin node_modules: "\${target}" is not provided by OpenCode; plugins must use the host's "effect" and "@opencode/plugin" modules.\`,
)
},
)
},
})
return modules
}
`
const pluginRuntimePlugin: BunPlugin = {
name: "opencode-plugin-runtime",
setup(build) {
build.onLoad({ filter: /cli[/\\]src[/\\]plugin-runtime\.ts$/ }, () => ({
contents: pluginRuntimeSource,
loader: "js",
}))
build.onLoad({ filter: /[/\\]internal[/\\]httpApi(?:Scalar|Swagger)\.js$/ }, () => ({
contents:
'export const css = ""; export const javascript = \'document.body.textContent = "Scalar/Swagger UI assets are not bundled in OpenCode"\'',
loader: "js",
}))
},
}
for (const item of targets) {
const opencodePty = await resolveOpencodePty({
@@ -182,14 +124,7 @@ export default { path: file, version: ${JSON.stringify(opencodePty.version)}, sh
const result = await Bun.build({
entrypoints: ["./src/index.ts"],
tsconfig: "./tsconfig.json",
plugins: [
appAssetsPlugin,
solidPlugin,
parcelWatcherPlugin,
opencodePtyPlugin,
pluginRuntimePlugin,
simulationGraphPlugin,
],
plugins: [appAssetsPlugin, solidPlugin, parcelWatcherPlugin, opencodePtyPlugin, simulationGraphPlugin],
external: ["node-gyp"],
format: "esm",
minify: true,
+10 -18
View File
@@ -20,30 +20,20 @@ import { ACPService } from "./service"
import { ACPSessions } from "./sessions"
import { ACPTurn } from "./turn"
type HandlerContext<Params> = AgentHandlerContext<Params> & { readonly requestId?: JsonRpcId }
// Untraced so request spans parent to the caller's span instead of a setup span that has already ended.
export const connect = Effect.fnUntraced(function* (client: OpenCodeClient, stream: Stream) {
const run = Effect.runPromiseWith(yield* Effect.context<Scope.Scope>())
const catalog = yield* ACPCatalog.make(client)
// Requests can dispatch before the service below is built.
const ready = yield* Deferred.make<{
readonly service: ACPService.Interface
readonly connection: ACPConnection.Interface
}>()
const ready = yield* Deferred.make<ACPService.Interface>()
const handle =
<Params, A>(
call: (service: ACPService.Interface, ctx: AgentHandlerContext<Params>) => Effect.Effect<A, ACPError.Failure>,
) =>
(name: string) => {
const handler = Effect.fn(name)(
function* (ctx: HandlerContext<Params>) {
const connected = yield* Deferred.await(ready)
if (ctx.requestId === undefined) return yield* call(connected.service, ctx)
return yield* call(connected.service, ctx).pipe(
Effect.provideService(ACPConnection.Responded, connected.connection.responded(ctx.requestId)),
)
},
(ctx: AgentHandlerContext<Params>) =>
Deferred.await(ready).pipe(Effect.flatMap((service) => call(service, ctx))),
Effect.catchTags({
ACPCatalogLoadError: (error) => ACPClient.classify(error.cause),
ACPCatalogNotReadyError: (error) =>
@@ -53,7 +43,12 @@ export const connect = Effect.fnUntraced(function* (client: OpenCodeClient, stre
Effect.tapCauseIf(Cause.hasDies, (cause) => Effect.logError("ACP request failed", cause)),
Effect.catchDefect((defect) => Effect.fail(ACPError.toRequestError(ACPError.fromUnknown(defect)))),
)
return (ctx: HandlerContext<Params>) => run(handler(ctx))
return (ctx: AgentHandlerContext<Params> & { readonly requestId?: JsonRpcId }) => {
if (ctx.requestId === undefined) return run(handler(ctx))
return run(
handler(ctx).pipe(Effect.provideService(ACPConnection.Responded, acp.connection.responded(ctx.requestId))),
)
}
}
const app = agent({ name: "opencode" })
const request = <Method extends AgentRequestMethod>(
@@ -122,10 +117,7 @@ export const connect = Effect.fnUntraced(function* (client: OpenCodeClient, stre
const sessions = yield* ACPSessions.make({ client, connection, catalog })
const capabilities = yield* Ref.make(ACPCapabilities.parse(undefined))
const turn = yield* ACPTurn.make({ client, connection, sessions, catalog, capabilities })
yield* Deferred.succeed(ready, {
service: ACPService.make({ client, connection, catalog, sessions, capabilities, turn }),
connection,
})
yield* Deferred.succeed(ready, ACPService.make({ client, connection, catalog, sessions, capabilities, turn }))
return acp.agent
})
+39 -13
View File
@@ -3,14 +3,15 @@ import type { Agent } from "@opencode/schema/agent"
import type { Command } from "@opencode/schema/command"
import type { Model } from "@opencode/schema/model"
import { FSUtil } from "@opencode/util/fs-util"
import { Cause, Deferred, Effect, Exit, Schedule, Semaphore, Stream, SubscriptionRef } from "effect"
import { ACPError } from "./error"
import { Cause, Deferred, Effect, Exit, Schedule, Schema, Semaphore, Stream, SubscriptionRef } from "effect"
import type { ConfigOptionProvider } from "./config-option"
export const builtinCommands = new Map([
["compact", { description: "Compact the session", start: "compaction" as const }],
])
export type Catalog = {
readonly providers: ConfigOptionProvider[]
readonly models: ReadonlyArray<Model.Info>
readonly defaultModel: Model.Ref
readonly modes: ReadonlyArray<{ id: Agent.ID; name: string; description?: string }>
@@ -18,14 +19,24 @@ export type Catalog = {
readonly commands: ReadonlyArray<Command.Info>
}
export function findModel(models: ReadonlyArray<Model.Info>, ref: Model.Ref) {
return models.find((model) => model.providerID === ref.providerID && model.id === ref.id)
class NotReadyError extends Schema.TaggedError<NotReadyError>()("ACPCatalogNotReadyError", {
reason: Schema.Literals(["models", "agents"]),
}) {
override get message() {
return this.reason === "models" ? "No models are available" : "No primary agents are available"
}
}
class LoadError extends Schema.TaggedError<LoadError>()("ACPCatalogLoadError", {
cause: Schema.Defect(),
}) {}
export type Error = NotReadyError | LoadError
export interface Interface {
readonly get: (cwd: string) => Effect.Effect<Catalog, ACPError.CatalogError>
readonly reload: (cwd: string) => Effect.Effect<void, ACPError.CatalogError>
readonly changes: (cwd: string) => Stream.Stream<Catalog, ACPError.CatalogError>
readonly get: (cwd: string) => Effect.Effect<Catalog, Error>
readonly reload: (cwd: string) => Effect.Effect<void, Error>
readonly changes: (cwd: string) => Stream.Stream<Catalog, Error>
}
type Entry = {
@@ -40,7 +51,7 @@ const reloadOn = new Set<OpenCodeEvent["type"]>(["model.updated", "agent.updated
export const make = Effect.fnUntraced(function* (client: OpenCodeClient) {
const scope = yield* Effect.scope
const entries = new Map<string, Deferred.Deferred<Entry, ACPError.CatalogError>>()
const entries = new Map<string, Deferred.Deferred<Entry, Error>>()
const connected = yield* Deferred.make<void>()
// Requests queued behind a running load share the next one.
@@ -100,7 +111,7 @@ export const make = Effect.fnUntraced(function* (client: OpenCodeClient) {
const key = FSUtil.resolve(cwd)
const cached = entries.get(key)
if (cached) return Deferred.await(cached)
const loading = Deferred.makeUnsafe<Entry, ACPError.CatalogError>()
const loading = Deferred.makeUnsafe<Entry, Error>()
entries.set(key, loading)
return create(cwd).pipe(
Effect.onExit((exit) => {
@@ -144,17 +155,20 @@ const read = Effect.fnUntraced(function* (client: OpenCodeClient, cwd: string) {
client.command.list({ location }),
],
{ concurrency: "unbounded" },
).pipe(Effect.mapError((cause) => new ACPError.CatalogLoadError({ cause })))
).pipe(Effect.mapError((cause) => new LoadError({ cause })))
const models = modelResult.data.filter((model) => model.enabled)
const preferred = defaultResult.data
// The parallel default read can name a model missing from this list.
const defaultModel = preferred ? findModel(models, preferred) : models[0]
if (!defaultModel) return yield* new ACPError.CatalogNotReadyError({ reason: "models" })
const defaultModel = preferred
? models.find((model) => model.providerID === preferred.providerID && model.id === preferred.id)
: models[0]
if (!defaultModel) return yield* new NotReadyError({ reason: "models" })
const agents = agentResult.data.filter((agent) => agent.mode !== "subagent" && !agent.hidden)
// Core lists its resolved default agent first, the same one a new session runs.
const defaultAgent = agents[0]
if (!defaultAgent) return yield* new ACPError.CatalogNotReadyError({ reason: "agents" })
if (!defaultAgent) return yield* new NotReadyError({ reason: "agents" })
return {
providers: providers(models),
models,
defaultModel: {
providerID: defaultModel.providerID,
@@ -167,4 +181,16 @@ const read = Effect.fnUntraced(function* (client: OpenCodeClient, cwd: string) {
} satisfies Catalog
})
function providers(models: ReadonlyArray<Model.Info>) {
return Array.from(new Set(models.map((model) => model.providerID)))
.toSorted()
.map((providerID) => ({
id: providerID,
name: providerID,
models: models
.filter((model) => model.providerID === providerID)
.map((model) => ({ id: model.id, name: model.name, variants: model.variants.map((variant) => variant.id) })),
}))
}
export * as ACPCatalog from "./catalog"
+134 -114
View File
@@ -1,70 +1,51 @@
import type { SessionConfigOption } from "@agentclientprotocol/sdk"
import type { Agent } from "@opencode/schema/agent"
import { Model } from "@opencode/schema/model"
import { Provider } from "@opencode/schema/provider"
import { Effect, Order } from "effect"
import { builtinCommands, findModel, type Catalog } from "./catalog"
import { ACPError } from "./error"
import type { Model } from "@opencode/schema/model"
import { builtinCommands, type Catalog } from "./catalog"
const DEFAULT_VARIANT_VALUE = "default"
export const DEFAULT_VARIANT_VALUE = "default"
type ConfigOptionModel = {
id: string
name: string
variants?: ReadonlyArray<string>
}
export type ConfigOptionProvider = {
id: string
name: string
models: ReadonlyArray<ConfigOptionModel>
}
type ConfigOptionMode = {
id: string
name: string
description?: string
}
export type ModelSelection = {
model: { providerID: string; modelID: string }
variant?: string
}
export type Selection = {
readonly model?: Model.Ref
readonly modeID?: Agent.ID
}
export type Change = { readonly model: Model.Ref } | { readonly modeID: Agent.ID }
export function currentModel(catalog: Catalog, selection: Selection) {
return selection.model ?? catalog.defaultModel
}
export function configOptions(catalog: Catalog, selection: Selection): SessionConfigOption[] {
export function configOptions(catalog: Catalog, selection: Selection) {
const model = currentModel(catalog, selection)
const variants = findModel(catalog.models, model)?.variants.map((variant) => variant.id) ?? []
return [
{
id: "model",
name: "Model",
category: "model",
type: "select",
currentValue: `${model.providerID}/${model.id}`,
options: catalog.models
.toSorted((a, b) => Order.String(a.providerID, b.providerID) || a.name.localeCompare(b.name))
.map((item) => ({ value: `${item.providerID}/${item.id}`, name: `${item.providerID}/${item.name}` })),
},
...(variants.length > 0
? [
{
id: "effort",
name: "Effort",
description: "Available effort levels for this model",
category: "thought_level",
type: "select",
currentValue: selectVariant(model.variant, variants),
options: [...new Set([...variants, DEFAULT_VARIANT_VALUE])].map((variant) => ({
value: variant,
name: variant
.split(/[_-]/)
.map((part) => (part ? part.charAt(0).toUpperCase() + part.slice(1) : part))
.join(" "),
})),
} satisfies SessionConfigOption,
]
: []),
{
id: "mode",
name: "Session Mode",
category: "mode",
type: "select",
currentValue: selection.modeID ?? catalog.defaultModeID,
options: catalog.modes.map((mode) => ({
value: mode.id,
name: mode.name,
...(mode.description ? { description: mode.description } : {}),
})),
},
]
return buildConfigOptions({
providers: catalog.providers,
currentModel: { providerID: model.providerID, modelID: model.id },
currentVariant: model.variant,
modes: catalog.modes,
currentModeId: selection.modeID ?? catalog.defaultModeID,
})
}
export function availableCommands(catalog: Catalog) {
@@ -74,72 +55,111 @@ export function availableCommands(catalog: Catalog) {
]
}
export const resolveChange = Effect.fnUntraced(function* (
catalog: Catalog,
selection: Selection,
configId: string,
value: string,
) {
const current = currentModel(catalog, selection)
switch (configId) {
case "model":
return { model: yield* requireModel(catalog, value, current) }
case "effort": {
const model = findModel(catalog.models, current)
if (!model || (value !== DEFAULT_VARIANT_VALUE && !model.variants.some((variant) => variant.id === value)))
return yield* new ACPError.InvalidEffortError({ effort: value })
return { model: { ...current, variant: Model.VariantID.make(value) } }
}
case "mode": {
const mode = catalog.modes.find((item) => item.id === value)
if (!mode) return yield* new ACPError.InvalidModeError({ mode: value })
return { modeID: mode.id }
}
default:
return yield* new ACPError.InvalidConfigOptionError({ configId })
}
})
export function parseModelSelection(value: string, models: ReadonlyArray<Model.Info>): Model.Ref {
const providerID = models
.map((model) => model.providerID)
.toSorted()
.find((id) => value.startsWith(`${id}/`))
if (!providerID) {
const separator = value.indexOf("/")
if (separator === -1) return { providerID: Provider.ID.make(value), id: Model.ID.make("") }
return { providerID: Provider.ID.make(value.slice(0, separator)), id: Model.ID.make(value.slice(separator + 1)) }
}
const id = Model.ID.make(value.slice(providerID.length + 1))
if (findModel(models, { providerID, id })) return { providerID, id }
const separator = id.lastIndexOf("/")
const baseID = Model.ID.make(separator === -1 ? id : id.slice(0, separator))
const variant = separator === -1 ? undefined : id.slice(separator + 1)
const model = findModel(models, { providerID, id: baseID })
if (model && variant && model.variants.some((item) => item.id === variant))
return { providerID, id: baseID, variant: Model.VariantID.make(variant) }
return { providerID, id }
export function buildConfigOptions(input: {
providers: readonly ConfigOptionProvider[]
currentModel: ModelSelection["model"]
currentVariant?: string
modes?: readonly ConfigOptionMode[]
currentModeId?: string
}): SessionConfigOption[] {
const variants =
input.providers
.find((provider) => provider.id === input.currentModel.providerID)
?.models.find((model) => model.id === input.currentModel.modelID)?.variants ?? []
const effort =
variants.length > 0 ? buildEffortSelectOption({ variants, currentVariant: input.currentVariant }) : undefined
return [
buildModelSelectOption({ providers: input.providers, currentModel: input.currentModel }),
...(effort ? [effort] : []),
...(input.modes && input.currentModeId
? [buildModeSelectOption({ modes: input.modes, currentModeId: input.currentModeId })]
: []),
]
}
const requireModel = Effect.fnUntraced(function* (catalog: Catalog, value: string, current: Model.Ref) {
const selected = parseModelSelection(value, catalog.models)
const model = findModel(catalog.models, selected)
if (!model) return yield* new ACPError.InvalidModelError({ providerId: selected.providerID, modelId: value })
const selectedVariant = model.variants.find((variant) => variant.id === selected.variant)
if (selected.variant && !selectedVariant) return yield* new ACPError.InvalidEffortError({ effort: selected.variant })
const variant =
selectedVariant?.id ??
(current.providerID === model.providerID &&
current.id === model.id &&
(current.variant === DEFAULT_VARIANT_VALUE || model.variants.some((variant) => variant.id === current.variant))
? current.variant
: undefined)
return { providerID: model.providerID, id: model.id, variant } satisfies Model.Ref
})
export function buildModelSelectOption(input: {
providers: readonly ConfigOptionProvider[]
currentModel: ModelSelection["model"]
}): SessionConfigOption {
return {
id: "model",
name: "Model",
category: "model",
type: "select",
currentValue: `${input.currentModel.providerID}/${input.currentModel.modelID}`,
options: input.providers.flatMap((provider) =>
provider.models
.toSorted((a, b) => a.name.localeCompare(b.name))
.map((model) => ({ value: `${provider.id}/${model.id}`, name: `${provider.name}/${model.name}` })),
),
}
}
export function buildEffortSelectOption(input: {
variants: readonly string[]
currentVariant?: string
}): SessionConfigOption {
return {
id: "effort",
name: "Effort",
description: "Available effort levels for this model",
category: "thought_level",
type: "select",
currentValue: selectVariant(input.currentVariant, input.variants),
options: [...new Set([...input.variants, DEFAULT_VARIANT_VALUE])].map((variant) => ({
value: variant,
name: formatVariantName(variant),
})),
}
}
export function buildModeSelectOption(input: {
modes: readonly ConfigOptionMode[]
currentModeId: string
}): SessionConfigOption {
return {
id: "mode",
name: "Session Mode",
category: "mode",
type: "select",
currentValue: input.currentModeId,
options: input.modes.map((mode) => ({
value: mode.id,
name: mode.name,
...(mode.description ? { description: mode.description } : {}),
})),
}
}
export function parseModelSelection(modelId: string, providers: readonly ConfigOptionProvider[]): ModelSelection {
const provider = providers.find((item) => modelId.startsWith(`${item.id}/`))
if (!provider) {
const separator = modelId.indexOf("/")
if (separator === -1) return { model: { providerID: modelId, modelID: "" } }
return { model: { providerID: modelId.slice(0, separator), modelID: modelId.slice(separator + 1) } }
}
const modelID = modelId.slice(provider.id.length + 1)
if (provider.models.some((model) => model.id === modelID)) return { model: { providerID: provider.id, modelID } }
const separator = modelID.lastIndexOf("/")
const baseModelID = separator === -1 ? modelID : modelID.slice(0, separator)
const variant = separator === -1 ? undefined : modelID.slice(separator + 1)
const model = provider.models.find((item) => item.id === baseModelID)
if (model && variant && model.variants?.includes(variant)) {
return { model: { providerID: provider.id, modelID: baseModelID }, variant }
}
return { model: { providerID: provider.id, modelID } }
}
export function formatVariantName(variant: string) {
return variant
.split(/[_-]/)
.map((part) => (part ? part.charAt(0).toUpperCase() + part.slice(1) : part))
.join(" ")
}
function selectVariant(variant: string | undefined, variants: readonly string[]) {
if (!variant || variant === DEFAULT_VARIANT_VALUE) return DEFAULT_VARIANT_VALUE
if (variants.includes(variant)) return variant
if (variant && variants.includes(variant)) return variant
if (variants.includes(DEFAULT_VARIANT_VALUE)) return DEFAULT_VARIANT_VALUE
return variants[0] ?? DEFAULT_VARIANT_VALUE
}
+24 -3
View File
@@ -6,7 +6,7 @@ import type {
} from "@agentclientprotocol/sdk"
import type { OpenCodeClient } from "@opencode/client/effect"
import { Form } from "@opencode/schema/form"
import { Effect, Option, Schema } from "effect"
import { Cause, Effect, Option, Schema } from "effect"
import type { Capabilities } from "./capabilities"
import { ACPChild } from "./child"
import { ACPClient } from "./client"
@@ -30,8 +30,28 @@ type Input = {
readonly clientSessionID: string
readonly child?: ACPChild.Session
readonly toolCallSent: boolean
readonly settled: Effect.Effect<void>
}
type Outcome = Form.Answer | "cancel" | "settled"
export const reply = Effect.fn("cli.acp.elicitation.reply")(function* (input: Input, cancelled: Effect.Effect<void>) {
yield* Effect.uninterruptibleMask((restore) =>
// The race starts racers in order and stops once one is done, so an earlier cancel never starts the ask.
restore(
cancelled.pipe(
Effect.as("cancel" as const),
Effect.raceFirst(input.settled.pipe(Effect.as("settled" as const))),
Effect.raceFirst(ask(input)),
),
).pipe(
Effect.tapCauseIf(Cause.hasDies, (cause) => Effect.logWarning("ACP elicitation failed", cause)),
Effect.catchCause(() => Effect.succeed("cancel" as const)),
Effect.flatMap((outcome) => respond(input, outcome)),
),
)
})
export const UnshownQuestionMessage =
"The question couldn't be shown to the user in this client. Continue without an answer: make reasonable assumptions and state them, or ask the user in your reply if you can't proceed."
@@ -83,7 +103,7 @@ function answer(form: AskedForm, response: CreateElicitationResponse): Form.Answ
)
}
export const ask = Effect.fnUntraced(function* (input: Input) {
const ask = Effect.fnUntraced(function* (input: Input) {
const source = input.toolCallSent ? Schema.decodeUnknownOption(ToolSource)(input.form.metadata) : Option.none()
const toolCallID = Option.getOrUndefined(Option.map(source, (metadata) => metadata.tool.id))
const response = yield* input.connection.createElicitation({
@@ -96,7 +116,8 @@ export const ask = Effect.fnUntraced(function* (input: Input) {
return answer(input.form, response) ?? "cancel"
})
export function respond(input: Input, outcome: Form.Answer | "cancel") {
function respond(input: Input, outcome: Outcome) {
if (outcome === "settled") return Effect.void
if (outcome === "cancel") return cancel(input.client, input.form)
return input.client.session.form
.reply({ sessionID: input.form.sessionID, formID: input.form.id, answer: outcome })
+2 -15
View File
@@ -1,5 +1,6 @@
import { RequestError } from "@agentclientprotocol/sdk"
import { Schema } from "effect"
import type { ACPCatalog } from "./catalog"
export class SessionNotFoundError extends Schema.TaggedError<SessionNotFoundError>()("ACPSessionNotFoundError", {
sessionId: Schema.String,
@@ -55,20 +56,6 @@ export class ServerUnavailableError extends Schema.TaggedError<ServerUnavailable
{},
) {}
export class CatalogNotReadyError extends Schema.TaggedError<CatalogNotReadyError>()("ACPCatalogNotReadyError", {
reason: Schema.Literals(["models", "agents"]),
}) {
override get message() {
return this.reason === "models" ? "No models are available" : "No primary agents are available"
}
}
export class CatalogLoadError extends Schema.TaggedError<CatalogLoadError>()("ACPCatalogLoadError", {
cause: Schema.Defect(),
}) {}
export type CatalogError = CatalogNotReadyError | CatalogLoadError
export type Error =
| SessionNotFoundError
| SessionDirectoryMismatchError
@@ -83,7 +70,7 @@ export type Error =
| ServiceFailureError
| ServerUnavailableError
export type Failure = Error | RequestError | CatalogError
export type Failure = Error | RequestError | ACPCatalog.Error
export function toRequestError(error: Error): RequestError {
switch (error._tag) {
+27 -15
View File
@@ -5,20 +5,11 @@ import type { Permission } from "@opencode/schema/permission"
import type { Session } from "@opencode/schema/session"
import { Patch } from "@opencode/util/patch"
import { applyPatch } from "diff"
import { Effect, Option, Schema } from "effect"
import { Cause, Effect, Option, Schema } from "effect"
import { ACPChild } from "./child"
import { ACPClient } from "./client"
import type { ACPConnection } from "./connection"
import {
absolutePath,
canonicalName,
filePath,
patchHunks,
pendingToolCall,
stringValue,
toLocations,
type ToolInput,
} from "./tool"
import { absolutePath, filePath, patchHunks, pendingToolCall, stringValue, toLocations, type ToolInput } from "./tool"
type PermissionEvent = Extract<OpenCodeEvent, { type: "permission.asked" }>
type Tool = { readonly id: string; readonly name: string; readonly input: ToolInput }
@@ -33,6 +24,7 @@ type Input = {
readonly cwd: string
readonly tool?: Tool
readonly child?: ACPChild.Session
readonly settled: Effect.Effect<void>
}
const options: PermissionOption[] = [
@@ -43,7 +35,24 @@ const options: PermissionOption[] = [
const decodeFiles = Schema.decodeUnknownOption(Schema.Array(FileDiff.Info))
export const ask = Effect.fnUntraced(function* (input: Input) {
export const reply = Effect.fn("cli.acp.permission.reply")(function* (input: Input, cancelled: Effect.Effect<void>) {
yield* Effect.uninterruptibleMask((restore) =>
// The race starts racers in order and stops once one is done, so an earlier cancel never starts the ask.
restore(
cancelled.pipe(
Effect.as("reject" as const),
Effect.raceFirst(input.settled.pipe(Effect.as("settled" as const))),
Effect.raceFirst(ask(input)),
),
).pipe(
Effect.tapCauseIf(Cause.hasDies, (cause) => Effect.logWarning("ACP permission ask failed", cause)),
Effect.catchCause(() => Effect.succeed("reject" as const)),
Effect.flatMap((decision) => respond(input, decision)),
),
)
})
const ask = Effect.fnUntraced(function* (input: Input) {
const toolName = input.tool?.name ?? input.event.data.action
const toolInput = input.tool?.input ?? input.event.data.metadata ?? {}
const previews = yield* permissionPreviews(toolName, toolInput, input.event.data.metadata, input.cwd).pipe(
@@ -71,7 +80,8 @@ export const ask = Effect.fnUntraced(function* (input: Input) {
return selected === "once" || selected === "always" ? selected : "reject"
})
export function respond(input: Input, decision: Permission.Reply) {
function respond(input: Input, decision: Permission.Reply | "settled") {
if (decision === "settled") return Effect.void
return input.client.permission.reply({ sessionID: input.sessionID, requestID: input.event.data.id, decision }).pipe(
Effect.catchTag("PermissionNotFoundError", () => Effect.void),
Effect.catch(ACPClient.classify),
@@ -85,7 +95,8 @@ const permissionPreviews = Effect.fnUntraced(function* (
metadata: ToolInput | undefined,
cwd: string,
) {
if (canonicalName(toolName) === "patch") return yield* patchPreviews(input, cwd)
const tool = toolName.toLocaleLowerCase()
if (tool === "patch" || tool === "apply_patch") return yield* patchPreviews(input, cwd)
const files = Option.getOrElse(decodeFiles(metadata?.files), () => [])
const previews = yield* Effect.forEach(
files,
@@ -126,7 +137,7 @@ function diff(path: string, oldText: string | null, newText: string) {
function permissionTitle(toolName: string, input: ToolInput, previews: ReadonlyArray<Preview>) {
if (previews.length > 1) return `${previews.length} files`
switch (canonicalName(toolName)) {
switch (toolName.toLocaleLowerCase()) {
case "external_directory":
return stringValue(input.description) ?? stringValue(input.command) ?? stringValue(input.parentDir)
case "webfetch":
@@ -140,6 +151,7 @@ function permissionTitle(toolName: string, input: ToolInput, previews: ReadonlyA
case "edit":
case "write":
case "patch":
case "apply_patch":
return filePath(input) ?? previews[0]?.path
default:
return undefined
-61
View File
@@ -1,61 +0,0 @@
import type { ContentBlock } from "@agentclientprotocol/sdk"
import type { Command } from "@opencode/schema/command"
import { SessionMessage } from "@opencode/schema/session-message"
import { Effect } from "effect"
import { access, constants } from "node:fs/promises"
import { fileURLToPath } from "node:url"
import { builtinCommands, type Catalog } from "./catalog"
import { linkReference, promptContentToParts, type PromptPart } from "./content"
import { ACPError } from "./error"
import type { ACPTranslate } from "./translate"
export type Prepared = {
readonly start: ACPTranslate.TurnStart
readonly text: string
readonly files: Array<{ readonly uri: string; readonly name?: string }>
readonly synthetic: ReadonlyArray<string>
readonly slash?: { readonly name: string; readonly args: string }
readonly command?: Command.Info
}
export const prepare = Effect.fnUntraced(function* (catalog: Catalog, content: readonly ContentBlock[]) {
if (content.some((block) => block.type === "image" && !block.data && !block.uri)) {
return yield* new ACPError.InvalidRequestError({ message: "image content has no data or uri", field: "prompt" })
}
const parts = yield* Effect.forEach(promptContentToParts(content), referenceUnreadableFile, {
concurrency: "unbounded",
})
const visible = parts.filter((part) => part.type !== "text" || (!part.synthetic && !part.ignored))
const text = visible.flatMap((part) => (part.type === "text" ? [part.text] : [])).join("\n")
const slash = detectSlashCommand(text)
const id = SessionMessage.ID.create()
return {
start:
slash && builtinCommands.get(slash.name)?.start === "compaction"
? { type: "compaction", id }
: { type: "input", id },
text,
files: visible.flatMap((part) => (part.type === "file" ? [{ uri: part.url, name: part.filename }] : [])),
synthetic: parts.flatMap((part) => (part.type === "text" && part.synthetic ? [part.text] : [])),
slash,
command: slash ? catalog.commands.find((item) => item.name === slash.name) : undefined,
} satisfies Prepared
})
function referenceUnreadableFile(part: PromptPart) {
if (part.type !== "file" || !part.url.startsWith("file://")) return Effect.succeed(part)
return Effect.tryPromise(() => access(fileURLToPath(part.url), constants.R_OK)).pipe(
Effect.as(part),
Effect.orElseSucceed(() => linkReference(part.filename, part.url)),
)
}
function detectSlashCommand(text: string) {
const value = text.trim()
if (!value.startsWith("/")) return undefined
const [name, ...rest] = value.slice(1).split(/\s+/)
if (!name) return undefined
return { name, args: rest.join(" ").trim() }
}
export * as ACPPrompt from "./prompt"
+62 -15
View File
@@ -1,5 +1,6 @@
import type { OpenCodeClient } from "@opencode/client/effect"
import { SessionsCursor } from "@opencode/protocol/groups/session"
import { Model } from "@opencode/schema/model"
import { AbsolutePath } from "@opencode/schema/schema"
import { FSUtil } from "@opencode/util/fs-util"
import { DateTime, Effect, Ref, Schema } from "effect"
@@ -35,9 +36,9 @@ import type {
} from "@agentclientprotocol/sdk"
import { OPENCODE_VERSION } from "../version"
import { ACPCapabilities, type Capabilities } from "./capabilities"
import type { ACPCatalog } from "./catalog"
import type { ACPCatalog, Catalog } from "./catalog"
import { ACPClient } from "./client"
import { configOptions, resolveChange } from "./config-option"
import { configOptions, currentModel, DEFAULT_VARIANT_VALUE, parseModelSelection } from "./config-option"
import type { ACPConnection } from "./connection"
import { ACPDirectories } from "./directories"
import { ACPError } from "./error"
@@ -77,21 +78,42 @@ export function make(input: {
return configOptions(yield* input.catalog.get(attached.cwd), yield* Ref.get(attached.selection))
})
const withReload = <A>(attached: Attached, attempt: Effect.Effect<A, ACPError.Failure>) => {
const retry = () => input.catalog.reload(attached.cwd).pipe(Effect.andThen(attempt))
return attempt.pipe(
const withReload = <A>(attached: Attached, select: Effect.Effect<A, ACPError.Failure>) => {
const retry = () => input.catalog.reload(attached.cwd).pipe(Effect.andThen(select))
return select.pipe(
Effect.catchTags({ ACPInvalidModelError: retry, ACPInvalidModeError: retry, ACPInvalidEffortError: retry }),
)
}
const select = Effect.fnUntraced(function* (attached: Attached, configId: string, value: string) {
const change = yield* resolveChange(
yield* input.catalog.get(attached.cwd),
yield* Ref.get(attached.selection),
configId,
value,
)
yield* input.sessions.select(attached, change)
const selectOption = Effect.fnUntraced(function* (attached: Attached, configId: string, value: string) {
const catalog = yield* input.catalog.get(attached.cwd)
const current = currentModel(catalog, yield* Ref.get(attached.selection))
switch (configId) {
case "model":
return yield* selectModel(attached, yield* requireModel(catalog, value, current))
case "effort":
return yield* selectModel(attached, yield* requireEffort(catalog, value, current))
case "mode":
return yield* selectMode(attached, value)
default:
return yield* new ACPError.InvalidConfigOptionError({ configId })
}
})
// Update selection before switching so the echoed event is a no-op.
const selectModel = Effect.fnUntraced(function* (attached: Attached, model: Model.Ref) {
yield* Ref.update(attached.selection, (selection) => ({ ...selection, model }))
yield* input.client.session.switchModel({ sessionID: attached.id, model }).pipe(Effect.catch(ACPClient.classify))
})
const selectMode = Effect.fnUntraced(function* (attached: Attached, modeID: string) {
const catalog = yield* input.catalog.get(attached.cwd)
const mode = catalog.modes.find((item) => item.id === modeID)
if (!mode) return yield* new ACPError.InvalidModeError({ mode: modeID })
yield* Ref.update(attached.selection, (selection) => ({ ...selection, modeID: mode.id }))
yield* input.client.session
.switchAgent({ sessionID: attached.id, agent: mode.id })
.pipe(Effect.catch(ACPClient.classify))
})
const getSession = Effect.fnUntraced(function* (sessionId: string, cwd: string) {
@@ -223,12 +245,12 @@ export function make(input: {
const attached = yield* input.sessions.require(params.sessionId)
const value = params.value
if (typeof value !== "string") return yield* new ACPError.InvalidConfigOptionError({ configId: params.configId })
yield* withReload(attached, select(attached, params.configId, value))
yield* withReload(attached, selectOption(attached, params.configId, value))
return { configOptions: yield* currentOptions(attached) }
}),
setSessionMode: Effect.fnUntraced(function* (params) {
const attached = yield* input.sessions.require(params.sessionId)
yield* withReload(attached, select(attached, "mode", params.modeId))
yield* withReload(attached, selectMode(attached, params.modeId))
return {}
}),
prompt: input.turn.prompt,
@@ -248,4 +270,29 @@ const supportedMcpServers = Effect.fnUntraced(function* (servers: readonly McpSe
return supported
})
const requireModel = Effect.fnUntraced(function* (catalog: Catalog, modelID: string, current: Model.Ref) {
const selected = parseModelSelection(modelID, catalog.providers)
const model = catalog.models.find(
(item) => item.providerID === selected.model.providerID && item.id === selected.model.modelID,
)
if (!model) return yield* new ACPError.InvalidModelError({ providerId: selected.model.providerID, modelId: modelID })
const selectedVariant = model.variants.find((variant) => variant.id === selected.variant)
if (selected.variant && !selectedVariant) return yield* new ACPError.InvalidEffortError({ effort: selected.variant })
const variant =
selectedVariant?.id ??
(current.providerID === model.providerID &&
current.id === model.id &&
(current.variant === DEFAULT_VARIANT_VALUE || model.variants.some((variant) => variant.id === current.variant))
? current.variant
: undefined)
return { providerID: model.providerID, id: model.id, variant } satisfies Model.Ref
})
const requireEffort = Effect.fnUntraced(function* (catalog: Catalog, effort: string, current: Model.Ref) {
const model = catalog.models.find((item) => item.providerID === current.providerID && item.id === current.id)
if (!model || (effort !== DEFAULT_VARIANT_VALUE && !model.variants.some((variant) => variant.id === effort)))
return yield* new ACPError.InvalidEffortError({ effort })
return { ...current, variant: Model.VariantID.make(effort) } satisfies Model.Ref
})
export * as ACPService from "./service"
+3 -13
View File
@@ -6,7 +6,7 @@ import type { Session } from "@opencode/schema/session"
import { Cause, Deferred, Effect, Exit, Queue, Ref, Scope, Stream } from "effect"
import type { ACPCatalog, Catalog } from "./catalog"
import { ACPClient } from "./client"
import { availableCommands, configOptions, type Change, type Selection } from "./config-option"
import { availableCommands, configOptions, type Selection } from "./config-option"
import { ACPConnection } from "./connection"
import { ACPError } from "./error"
@@ -28,13 +28,12 @@ export interface Interface {
readonly release: (attached: Attached) => Effect.Effect<void>
readonly require: (sessionID: string) => Effect.Effect<Attached, ACPError.SessionNotFoundError>
readonly fork: (attached: Attached, effect: Effect.Effect<void>) => Effect.Effect<void, ACPError.SessionNotFoundError>
readonly select: (attached: Attached, change: Change) => Effect.Effect<void, ACPError.Error>
}
type Entry = {
readonly attached: Attached
readonly scope: Scope.Closeable
readonly selected: Queue.Queue<Change>
readonly selected: Queue.Queue<Selection>
}
type SelectedEvent = Extract<OpenCodeEvent, { type: "session.model.selected" | "session.agent.selected" }>
@@ -131,7 +130,7 @@ export const make = Effect.fnUntraced(function* (input: {
selection: yield* Ref.make<Selection>({ model: session.model, modeID: session.agent }),
},
scope: Scope.forkUnsafe(scope),
selected: yield* Queue.unbounded<Change>(),
selected: yield* Queue.unbounded<Selection>(),
}
// Swap synchronously so concurrent attaches of one ID cannot both keep a scope.
const replaced = sessions.get(session.id)
@@ -180,15 +179,6 @@ export const make = Effect.fnUntraced(function* (input: {
if (entry?.attached !== attached) return yield* new ACPError.SessionNotFoundError({ sessionId: attached.id })
yield* Effect.forkIn(effect, entry.scope, { startImmediately: true })
}),
// Update selection before switching so the echoed event is a no-op.
select: Effect.fnUntraced(function* (attached, change) {
yield* Ref.update(attached.selection, (selection) => ({ ...selection, ...change }))
yield* (
"model" in change
? input.client.session.switchModel({ sessionID: attached.id, model: change.model })
: input.client.session.switchAgent({ sessionID: attached.id, agent: change.modeID })
).pipe(Effect.catch(ACPClient.classify))
}),
} satisfies Interface
})
+10 -15
View File
@@ -8,12 +8,14 @@ import { Result } from "effect"
export type ToolInput = Record<string, unknown>
function toToolKind(toolName: string): ToolKind {
switch (canonicalName(toolName)) {
switch (toolName.toLocaleLowerCase()) {
case "bash":
case "shell":
return "execute"
case "webfetch":
return "fetch"
case "edit":
case "apply_patch":
case "patch":
case "write":
return "edit"
@@ -25,6 +27,7 @@ function toToolKind(toolName: string): ToolKind {
return "search"
case "read":
return "read"
case "task":
case "subagent":
return "think"
default:
@@ -33,7 +36,8 @@ function toToolKind(toolName: string): ToolKind {
}
export function toLocations(toolName: string, input: ToolInput, cwd: string): ToolCallLocation[] {
switch (canonicalName(toolName)) {
switch (toolName.toLocaleLowerCase()) {
case "bash":
case "shell":
return locationFrom(cwd, stringValue(input.workdir) ?? stringValue(input.cwd) ?? cwd)
case "read":
@@ -41,6 +45,7 @@ export function toLocations(toolName: string, input: ToolInput, cwd: string): To
case "write":
return locationFrom(cwd, filePath(input))
case "patch":
case "apply_patch":
return locationFrom(
cwd,
...patchHunks(input).flatMap((hunk) => [hunk.path, hunk.type === "update" ? hunk.movePath : undefined]),
@@ -100,7 +105,7 @@ export function completedToolUpdate(input: {
}): ToolCallUpdate {
const normalized = toolContent(input.content)
const firstText = input.content.find((part) => part.type === "text")
const read = canonicalName(input.toolName) === "read" && firstText ? readDisplayText(firstText.text) : undefined
const read = input.toolName.toLocaleLowerCase() === "read" && firstText ? readDisplayText(firstText.text) : undefined
const images = normalized.filter((part) => part.type === "content" && part.content.type === "image")
const primary =
read === undefined
@@ -168,7 +173,8 @@ function rawInput(toolName: string, input: ToolInput, cwd: string): ToolInput {
}
function isShell(toolName: string) {
return canonicalName(toolName) === "shell"
const tool = toolName.toLocaleLowerCase()
return tool === "bash" || tool === "shell"
}
function locationFrom(cwd: string, ...values: unknown[]): ToolCallLocation[] {
@@ -178,17 +184,6 @@ function locationFrom(cwd: string, ...values: unknown[]): ToolCallLocation[] {
)
}
const V1Aliases = new Map([
["bash", "shell"],
["task", "subagent"],
["apply_patch", "patch"],
])
export function canonicalName(toolName: string) {
const name = toolName.toLocaleLowerCase()
return V1Aliases.get(name) ?? name
}
// Sessions migrated from V1 keep their original `filePath` tool inputs.
export function filePath(input: ToolInput) {
return stringValue(input.path) ?? stringValue(input.filePath)
Loaded 100 of 304 files, more files were not shown because too many files have changed in this diff. Show more