Compare commits

..
Author SHA1 Message Date
neriousy 45dda0a109 fix(app): stage Build when hiding the agent picker
Stage the Build agent and its model for the next prompt in an existing session without changing the running server session. The normal submission path applies that selection when the next steered prompt is sent.
2026-10-04 19:16:43 +00:00
neriousy 58f233503e fix(app): honor hidden agent setting for cycling 2026-10-04 19:12:53 +00:00
332 changed files with 3751 additions and 12709 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="
}
}
+1 -1
View File
@@ -2,7 +2,7 @@
"$schema": "https://json.schemastore.org/package.json",
"name": "opencode",
"description": "AI-powered development tool",
"version": "2.0.23",
"version": "2.0.22",
"private": true,
"type": "module",
"packageManager": "bun@1.4.2",
-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",
-1
View File
@@ -42,7 +42,6 @@ const RESPECTS_INLINE_HINTS = new Set([
"alibaba-messages",
"anthropic-messages",
"anthropic-compatible-messages",
"bedrock-mantle-messages",
"cloudflare-ai-gateway-messages",
"google-vertex-messages",
"meta-messages",
+92 -104
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.fn("AnthropicMessages.lowerServerToolResult")(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.fn("AnthropicMessages.lowerMedia")(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>()
@@ -839,7 +847,7 @@ const splitsLocalToolResults = (messages: LLMRequest["messages"], index: number)
return pending.size > 0
}
const lowerNativeSystemUpdate = Effect.fnUntraced(function* (
const lowerNativeSystemUpdate = Effect.fn("AnthropicMessages.lowerNativeSystemUpdate")(function* (
message: LLMRequest["messages"][number],
breakpoints: Cache.Breakpoints,
) {
@@ -854,34 +862,12 @@ const lowerNativeSystemUpdate = Effect.fnUntraced(function* (
}
})
const lowerWrappedSystemUpdate = Effect.fnUntraced(function* (
message: LLMRequest["messages"][number],
const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(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,14 +985,10 @@ 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
})
// TODO: Move per-model capability heuristics (`supportsEffortUpdates`, `supportsNativeSystemUpdates`,
// `supportsThinkingBlockBinding`) into explicit model/provider `compatibility` metadata so the protocol
// only reads `request.model.compatibility`.
// Per-turn effort started with Claude Opus 5 and every Claude 5.1 model; later versions of any family inherit it.
const supportsEffortUpdates = (model: LLMRequest["model"]) => {
const override = model.compatibility?.supportsEffortUpdates
@@ -1312,7 +1300,7 @@ const onContentBlockStart = (
return [{ ...state, lifecycle: Lifecycle.stepStart(state.lifecycle, events) }, [...events, result]]
}
const onContentBlockDelta = Effect.fnUntraced(function* (
const onContentBlockDelta = Effect.fn("AnthropicMessages.onContentBlockDelta")(function* (
state: ParserState,
event: AnthropicEvent & { readonly delta: AnthropicStreamDelta },
) {
@@ -1380,7 +1368,7 @@ const onContentBlockDelta = Effect.fnUntraced(function* (
return [state, NO_EVENTS] satisfies StepResult
})
const onContentBlockStop = Effect.fnUntraced(function* (
const onContentBlockStop = Effect.fn("AnthropicMessages.onContentBlockStop")(function* (
state: ParserState,
event: AnthropicEvent,
) {
@@ -1451,7 +1439,7 @@ const onMessageDelta = (
]
}
const onMessageStop = Effect.fnUntraced(function* (state: ParserState) {
const onMessageStop = Effect.fn("AnthropicMessages.onMessageStop")(function* (state: ParserState) {
if (Object.keys(state.compactions).length)
return yield* ProviderShared.eventError(ADAPTER, "Response ended with an incomplete compaction block")
const result = yield* ToolStream.finishAll(ADAPTER, state.tools)
@@ -285,7 +285,7 @@ const lowerToolCall = (part: ToolCallPart, normalizeID: (id: string) => string):
},
})
const lowerToolResultContent = Effect.fnUntraced(function* (
const lowerToolResultContent = Effect.fn("BedrockConverse.lowerToolResultContent")(function* (
part: ToolResultPart,
documentNames: Set<string>,
) {
@@ -305,7 +305,7 @@ const lowerToolResultContent = Effect.fnUntraced(function* (
return content
})
const lowerToolResult = Effect.fnUntraced(function* (
const lowerToolResult = Effect.fn("BedrockConverse.lowerToolResult")(function* (
part: ToolResultPart,
documentNames: Set<string>,
normalizeID: (id: string) => string,
@@ -322,7 +322,7 @@ const lowerToolResult = Effect.fnUntraced(function* (
// Keep Claude and Nova tool-result images inline; put other models' images beside the result.
const keepToolImagesInline = (id: string) => id.includes("anthropic.claude-") || id.includes("amazon.nova-")
const lowerMessages = Effect.fnUntraced(function* (
const lowerMessages = Effect.fn("BedrockConverse.lowerMessages")(function* (
request: LLMRequest,
breakpoints: BedrockCache.Breakpoints,
) {
+3 -3
View File
@@ -75,7 +75,7 @@ const usesSse = (request: MediaProtocol.Addressed<Request>) => request.mode ===
const CONTAINERS: Readonly<Record<string, "raw" | "wav" | "mp3">> = { pcm: "raw", wav: "wav", mp3: "mp3" }
const outputFormat = Effect.fnUntraced(function* (request: MediaProtocol.Addressed<Request>) {
const outputFormat = Effect.fn("CartesiaSpeech.outputFormat")(function* (request: MediaProtocol.Addressed<Request>) {
const sse = usesSse(request)
const format = request.format ?? (sse ? "pcm" : "mp3")
const container = CONTAINERS[format]
@@ -121,7 +121,7 @@ const fromRequest = Effect.fn("CartesiaSpeech.fromRequest")(function* (request:
// 6. Stream parsing
// ---------------------------------------------------------------------------
const onEvent = Effect.fnUntraced(function* (state: State, frame: string) {
const onEvent = Effect.fn("CartesiaSpeech.onEvent")(function* (state: State, frame: string) {
const event = yield* decodeEvent(frame)
if (event.type === "chunk" && event.data !== undefined) return SpeechStream.delta(state, event.data)
if (event.type === "timestamps" && event.word_timestamps !== undefined) {
@@ -140,7 +140,7 @@ const onEvent = Effect.fnUntraced(function* (state: State, frame: string) {
return [state, []] as const
})
const finish = Effect.fnUntraced(function* (
const finish = Effect.fn("CartesiaSpeech.finish")(function* (
state: State,
context: MediaProtocol.ResponseContext<Request>,
) {
+1 -1
View File
@@ -252,7 +252,7 @@ const mapUsage = (usage: typeof NativeUsage.Type) =>
})
// Lifecycle deltas open blocks on demand and ends are no-ops for closed blocks, so content-start needs no handling.
const step = Effect.fnUntraced(function* (state: State, event: Event) {
const step = Effect.fn("CohereChat.step")(function* (state: State, event: Event) {
const events: LLMEvent[] = []
switch (event.type) {
case "message-start":
@@ -95,7 +95,7 @@ const OUTPUT_FORMATS: Readonly<Record<string, string>> = {
}
/** WAV is served only by the non-streaming endpoints. */
const outputFormat = Effect.fnUntraced(function* (request: MediaProtocol.Addressed<Request>) {
const outputFormat = Effect.fn("ElevenLabsSpeech.outputFormat")(function* (request: MediaProtocol.Addressed<Request>) {
const format = request.providerOptions?.outputFormat ?? OUTPUT_FORMATS[request.format ?? "mp3"]
if (format === undefined)
return yield* route.unsupported(
@@ -138,7 +138,7 @@ const path = (request: MediaProtocol.Addressed<Request>) =>
// 6. Stream parsing
// ---------------------------------------------------------------------------
const onRecord = Effect.fnUntraced(function* (state: State, frame: string) {
const onRecord = Effect.fn("ElevenLabsSpeech.onRecord")(function* (state: State, frame: string) {
const record = yield* decodeRecord(frame)
const [next, events] = SpeechStream.delta(state, record.audio_base64)
const alignment = record.alignment
@@ -169,7 +169,7 @@ const describeOutput = (format: string) => {
return encoding === undefined ? SpeechStream.container(codec, sampleRate) : SpeechStream.pcm(encoding, sampleRate)
}
const finish = Effect.fnUntraced(function* (
const finish = Effect.fn("ElevenLabsSpeech.finish")(function* (
state: State,
context: MediaProtocol.ResponseContext<Request>,
) {
+2 -2
View File
@@ -283,7 +283,7 @@ const lowerToolConfig = (toolChoice: NonNullable<LLMRequest["toolChoice"]>) =>
tool: (name) => ({ functionCallingConfig: { mode: "ANY" as const, allowedFunctionNames: [name] } }),
})
const lowerContentPart = Effect.fnUntraced(function* (part: TextPart | MediaPart) {
const lowerContentPart = Effect.fn("Gemini.lowerContentPart")(function* (part: TextPart | MediaPart) {
if (part.type === "text") return { text: part.text }
return yield* GeminiGenerateContent.mediaPart("Gemini", part.media)
})
@@ -302,7 +302,7 @@ const lowerToolCall = (part: ToolCallPart, omitIds: boolean, metadataKey: string
thoughtSignature: thoughtSignature(part.providerMetadata, metadataKey),
})
const lowerMessages = Effect.fnUntraced(function* (request: LLMRequest) {
const lowerMessages = Effect.fn("Gemini.lowerMessages")(function* (request: LLMRequest) {
const contents: GeminiContent[] = []
const metadataKey = request.model.route.providerMetadataKey ?? String(request.model.provider)
const omitCallIds = omitsFunctionCallIds(request.model.id)
@@ -182,7 +182,7 @@ type StepResult = readonly [ParserState, ReadonlyArray<LLMEvent>]
// =============================================================================
// Request Body Construction
// =============================================================================
const mediaContent = Effect.fnUntraced(function* (asset: Media.Asset) {
const mediaContent = Effect.fn("GoogleInteractions.mediaContent")(function* (asset: Media.Asset) {
if (
asset.kind !== "image" &&
asset.kind !== "audio" &&
@@ -208,7 +208,7 @@ const signature = (metadata: ProviderMetadata | undefined, key: string) => {
: undefined
}
const lowerMessages = Effect.fnUntraced(function* (request: LLMRequest) {
const lowerMessages = Effect.fn("GoogleInteractions.lowerMessages")(function* (request: LLMRequest) {
const steps: InputStep[] = []
const key = request.model.route.providerMetadataKey ?? String(request.model.provider)
for (const message of request.messages) {
@@ -275,7 +275,7 @@ const lowerMessages = Effect.fnUntraced(function* (request: LLMRequest) {
return steps
})
const lowerToolResult = Effect.fnUntraced(function* (part: ToolResultPart) {
const lowerToolResult = Effect.fn("GoogleInteractions.lowerToolResult")(function* (part: ToolResultPart) {
if (part.result.type === "json" && ProviderShared.isRecord(part.result.value)) return part.result.value
if (part.result.type !== "content") return ProviderShared.toolResultText(part)
@@ -364,7 +364,7 @@ const mapUsage = (usage: RawUsage | undefined, key: string) => {
})
}
const onStart = Effect.fnUntraced(function* (
const onStart = Effect.fn("GoogleInteractions.onStart")(function* (
state: ParserState,
index: number,
step: OutputStep,
@@ -402,7 +402,7 @@ const onStart = Effect.fnUntraced(function* (
return [{ ...state, lifecycle, tools, steps: { ...state.steps, [index]: step } }, events] satisfies StepResult
})
const onDelta = Effect.fnUntraced(function* (
const onDelta = Effect.fn("GoogleInteractions.onDelta")(function* (
state: ParserState,
index: number,
delta: typeof Delta.Type,
@@ -447,7 +447,7 @@ const onDelta = Effect.fnUntraced(function* (
return yield* ProviderShared.eventError(ADAPTER, `Unsupported Interactions delta: ${delta.type}`, encodeJson(delta))
})
const onStop = Effect.fnUntraced(function* (state: ParserState, index: number) {
const onStop = Effect.fn("GoogleInteractions.onStop")(function* (state: ParserState, index: number) {
const step = state.steps[index]
if (!step) return yield* ProviderShared.eventError(ADAPTER, "Interactions step.stop without step.start")
const events: LLMEvent[] = []
@@ -465,7 +465,7 @@ const onStop = Effect.fnUntraced(function* (state: ParserState, index: number) {
return [{ ...state, tools: result.tools }, result.events ?? []] satisfies StepResult
})
const step = Effect.fnUntraced(function* (state: ParserState, event: Event) {
const step = Effect.fn("GoogleInteractions.step")(function* (state: ParserState, event: Event) {
switch (event.event_type) {
case "step.start":
return yield* onStart(state, event.index, event.step)
+1 -1
View File
@@ -94,7 +94,7 @@ const fromRequest = Effect.fn("GoogleSpeech.fromRequest")(function* (request: Me
// 6. Stream parsing
// ---------------------------------------------------------------------------
const step = Effect.fnUntraced(function* (state: State, frame: string) {
const step = Effect.fn("GoogleSpeech.step")(function* (state: State, frame: string) {
const chunk = yield* decodeChunk(frame)
const blocked = GeminiGenerateContent.blocked(route.name, chunk, frame)
if (blocked !== undefined) return yield* blocked
@@ -138,7 +138,7 @@ const turn = (part: Schema.Schema.Type<typeof AudioTranscription>) => {
}
}
const step = Effect.fnUntraced(function* (state: State, frame: string) {
const step = Effect.fn("GoogleTranscription.step")(function* (state: State, frame: string) {
const chunk = yield* decodeChunk(frame)
const blocked = GeminiGenerateContent.blocked(route.name, chunk, frame)
if (blocked !== undefined) return yield* blocked
+3 -3
View File
@@ -95,7 +95,7 @@ const HOSTED_TOOLS = {
image_generation_call: {
name: "image_generation",
input: () => ({}),
result: Effect.fnUntraced(function* (raw: ResponsesHostedTools.Item) {
result: Effect.fn("MetaResponses.imageResult")(function* (raw: ResponsesHostedTools.Item) {
const item = yield* Schema.decodeUnknownEffect(ImageItem)(raw).pipe(
Effect.mapError((cause) =>
ProviderShared.eventError(
@@ -136,7 +136,7 @@ const HOSTED_TOOLS = {
},
} satisfies ResponsesHostedTools.Definitions
const onEvent = Effect.fnUntraced(function* (
const onEvent = Effect.fn("MetaResponses.onEvent")(function* (
state: OpenResponses.ParserState,
input: OpenResponses.Event,
) {
@@ -173,7 +173,7 @@ const onEvent = Effect.fnUntraced(function* (
] satisfies OpenResponses.StepResult
})
const step = Effect.fnUntraced(function* (state: ParserState, input: OpenResponses.Event) {
const step = Effect.fn("MetaResponses.step")(function* (state: ParserState, input: OpenResponses.Event) {
const completedItems = new Set(state.completedItems)
const event = OpenResponses.normalize(state, input)
if (event.type === "response.output_item.done" && event.item && completedItems.has(event.item.id))
+8 -8
View File
@@ -226,7 +226,7 @@ const MistralEvent = Schema.StructWithRest(
type MistralEvent = Schema.Schema.Type<typeof MistralEvent>
const MistralStreamEvent = Schema.Union([Schema.Literal(DONE), Protocol.jsonEvent(MistralEvent)])
const lowerMedia = Effect.fnUntraced(function* (part: MediaPart) {
const lowerMedia = Effect.fn("MistralChat.lowerMedia")(function* (part: MediaPart) {
const mime = part.media.mediaType.toLowerCase()
const url =
ProviderShared.mediaUrl(part.media) ??
@@ -236,7 +236,7 @@ const lowerMedia = Effect.fnUntraced(function* (part: MediaPart) {
return yield* ProviderShared.invalidRequest(`Mistral Chat does not support media type ${part.media.mediaType}`)
})
const lowerUser = Effect.fnUntraced(function* (message: LLMRequest["messages"][number]) {
const lowerUser = Effect.fn("MistralChat.lowerUser")(function* (message: LLMRequest["messages"][number]) {
const content: MistralUserContent[] = []
for (const part of message.content) {
if (part.type === "text") {
@@ -260,7 +260,7 @@ const lowerToolCall = (part: ToolCallPart, normalizeID: (id: string) => string):
function: { name: part.name, arguments: ProviderShared.encodeJson(part.input) },
})
const lowerAssistant = Effect.fnUntraced(function* (
const lowerAssistant = Effect.fn("MistralChat.lowerAssistant")(function* (
message: LLMRequest["messages"][number],
normalizeID: (id: string) => string,
prefix: boolean,
@@ -298,7 +298,7 @@ const lowerAssistant = Effect.fnUntraced(function* (
}
})
const lowerToolResults = Effect.fnUntraced(function* (
const lowerToolResults = Effect.fn("MistralChat.lowerToolResults")(function* (
message: LLMRequest["messages"][number],
normalizeID: (id: string) => string,
) {
@@ -335,7 +335,7 @@ const lowerToolResults = Effect.fnUntraced(function* (
return output
})
const lowerMessages = Effect.fnUntraced(function* (request: LLMRequest) {
const lowerMessages = Effect.fn("MistralChat.lowerMessages")(function* (request: LLMRequest) {
const normalizeID = MistralToolID.normalizer(request)
const messages: MistralMessage[] =
request.system.length === 0
@@ -596,7 +596,7 @@ const toolText = (tool: MistralToolDelta) => {
return value === null || value === undefined ? "" : ProviderShared.encodeJson(value)
}
const appendTools = Effect.fnUntraced(function* (
const appendTools = Effect.fn("MistralChat.appendTools")(function* (
initial: ParserState,
events: LLMEvent[],
deltas: ReadonlyArray<MistralToolDelta>,
@@ -662,7 +662,7 @@ const hasLateContent = (event: MistralEvent) => {
)
}
const step = Effect.fnUntraced(function* (state: ParserState, event: MistralEvent) {
const step = Effect.fn("MistralChat.step")(function* (state: ParserState, event: MistralEvent) {
if (event.error) {
const body = ProviderShared.encodeJson(event)
return yield* new AIError({
@@ -726,7 +726,7 @@ const step = Effect.fnUntraced(function* (state: ParserState, event: MistralEven
] as const
})
const finishEvents = Effect.fnUntraced(function* (state: ParserState) {
const finishEvents = Effect.fn("MistralChat.finishEvents")(function* (state: ParserState) {
if (!state.finishReason)
return yield* new AIError({
reason: new InvalidProviderOutputError({
+25 -34
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
@@ -450,7 +442,7 @@ interface ReasoningStreamItem {
// =============================================================================
// Request Lowering
// =============================================================================
export const lowerTool = Effect.fnUntraced(function* (protocolName: string, tool: ToolDefinition) {
export const lowerTool = Effect.fn("OpenResponses.lowerTool")(function* (protocolName: string, tool: ToolDefinition) {
if (tool.native !== undefined)
return yield* ProviderShared.invalidRequest(`${protocolName} does not support provider-native tool ${tool.name}`)
return {
@@ -515,10 +507,7 @@ const lowerReasoning = (part: ReasoningPart, providerMetadataKey: string): OpenR
}
}
const decodeImageDetail = ProviderShared.validateWith(Schema.decodeUnknownEffect(OpenResponsesInputImage.fields.detail))
const decodeMessageMetadata = ProviderShared.validateWith(Schema.decodeUnknownEffect(MessageMetadata))
const lowerMedia = Effect.fnUntraced(function* (
const lowerMedia = Effect.fn("OpenResponses.lowerMedia")(function* (
part: MediaPart,
request: LLMRequest,
adapter: ProviderAdapter,
@@ -527,8 +516,9 @@ const lowerMedia = Effect.fnUntraced(function* (
const media = part.media.inline()
const providerMedia = adapter.lowerMedia?.({ part, media, request })
if (providerMedia) return providerMedia
const rawDetail = part.providerMetadata?.[metadataKey(request.model)]?.detail
const detail = rawDetail === undefined ? undefined : yield* decodeImageDetail(rawDetail)
const detail = yield* ProviderShared.validateWith(Schema.decodeUnknownEffect(OpenResponsesInputImage.fields.detail))(
part.providerMetadata?.[metadataKey(request.model)]?.detail,
)
const mime = part.media.mediaType.toLowerCase()
const url = ProviderShared.mediaUrl(part.media)
const location = url ?? (yield* ProviderShared.requireInlineMedia(adapter.name, part.media)).dataUrl
@@ -601,7 +591,7 @@ const lowerToolResultOutput = Effect.fnUntraced(function* (
const DEFAULT_EFFORT = "medium"
const lowerMessages = Effect.fnUntraced(function* (
const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (
request: LLMRequest,
adapter: ProviderAdapter,
) {
@@ -609,8 +599,9 @@ const lowerMessages = Effect.fnUntraced(function* (
const providerMetadataKey = metadataKey(request.model)
for (const message of request.messages) {
const rawMetadata = message.providerMetadata?.[providerMetadataKey]
const metadata = rawMetadata === undefined ? undefined : yield* decodeMessageMetadata(rawMetadata)
const metadata = yield* ProviderShared.validateWith(
Schema.decodeUnknownEffect(Schema.UndefinedOr(MessageMetadata)),
)(message.providerMetadata?.[providerMetadataKey])
if (message.role === "system") {
const update = effortUpdate(message)
if (update) {
@@ -764,7 +755,7 @@ const lowerMessages = Effect.fnUntraced(function* (
return input
})
export const lowerConversation = Effect.fnUntraced(function* (
export const lowerConversation = Effect.fn("OpenResponses.lowerConversation")(function* (
request: LLMRequest,
adapter: ProviderAdapter,
) {
@@ -1151,7 +1142,7 @@ const onReasoningSummaryPartDone = (state: ParserState, event: Event): StepResul
]
}
const onFunctionCallArgumentsDelta = Effect.fnUntraced(function* (
const onFunctionCallArgumentsDelta = Effect.fn("OpenResponses.onFunctionCallArgumentsDelta")(function* (
state: ParserState,
event: Event,
) {
@@ -1182,7 +1173,7 @@ const onFunctionCallArgumentsDelta = Effect.fnUntraced(function* (
return [{ ...state, lifecycle, tools: result.tools }, events] satisfies StepResult
})
const onOutputItemDone = Effect.fnUntraced(function* (
const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
state: ParserState,
item: NormalizedEvent["item"],
) {
@@ -1318,7 +1309,7 @@ const onOutputItemDone = Effect.fnUntraced(function* (
return [state, NO_EVENTS] satisfies StepResult
})
const onResponseFinish = Effect.fnUntraced(function* (state: ParserState, event: Event) {
const onResponseFinish = Effect.fn("OpenResponses.onResponseFinish")(function* (state: ParserState, event: Event) {
let current = state
const events: LLMEvent[] = []
if (event.type === "response.completed") {
+16 -20
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 => ({
@@ -362,7 +360,7 @@ const lowerToolCall = (
extra_content: decodeExtraContent(part.providerMetadata?.[options.providerMetadataKey]?.extraContent),
})
const lowerMedia = Effect.fnUntraced(function* (part: MediaPart) {
const lowerMedia = Effect.fn("OpenAIChat.lowerMedia")(function* (part: MediaPart) {
// Chat Completions accepts PDFs, and no other documents, as inline `file` parts; file URLs are not supported.
if (part.media.mediaType.toLowerCase() === "application/pdf")
return {
@@ -408,7 +406,7 @@ const lowerReasoningDetail = (detail: ReasoningDetail) => {
const isKimiDetail = (detail: { readonly type: string }) => detail.type === "summary" || detail.type === "encrypted"
const lowerUserMessage = Effect.fnUntraced(function* (
const lowerUserMessage = Effect.fn("OpenAIChat.lowerUserMessage")(function* (
message: OpenAIChatRequestMessage,
options: LoweringOptions,
) {
@@ -432,7 +430,7 @@ const lowerUserMessage = Effect.fnUntraced(function* (
return { role: "user" as const, content }
})
const lowerAssistantMessage = Effect.fnUntraced(function* (
const lowerAssistantMessage = Effect.fn("OpenAIChat.lowerAssistantMessage")(function* (
message: OpenAIChatRequestMessage,
configuredField: string | undefined,
requireReasoning: boolean,
@@ -497,7 +495,7 @@ const lowerAssistantMessage = Effect.fnUntraced(function* (
return { ...result, [field]: reasoningText }
})
const lowerToolMessages = Effect.fnUntraced(function* (
const lowerToolMessages = Effect.fn("OpenAIChat.lowerToolMessages")(function* (
message: OpenAIChatRequestMessage,
options: LoweringOptions,
) {
@@ -534,21 +532,19 @@ const toolMessage = (toolCallID: string, text: string, cacheControl: OpenAIChatC
content: cacheControl === undefined ? text : [{ type: "text" as const, text, cache_control: cacheControl }],
})
const lowerMessage = Effect.fnUntraced(function* (
const lowerMessage = Effect.fn("OpenAIChat.lowerMessage")(function* (
message: OpenAIChatRequestMessage,
reasoningField: string | undefined,
requireReasoning: boolean,
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
})
const lowerMessages = Effect.fnUntraced(function* (request: LLMRequest, options: LoweringOptions) {
const lowerMessages = Effect.fn("OpenAIChat.lowerMessages")(function* (request: LLMRequest, options: LoweringOptions) {
const system: OpenAIChatMessage[] =
request.system.length === 0
? []
@@ -859,7 +855,7 @@ export const fromRequest = Effect.fn("OpenAIChat.fromRequest")(function* (
// Streaming parsers are small state machines: every event returns a new state
// plus the common `LLMEvent`s produced by that event. Tool calls are accumulated
// because OpenAI streams JSON arguments across multiple deltas.
const mapFinishReason = Effect.fnUntraced(function* (event: OpenAIChatEvent, reason: string) {
const mapFinishReason = Effect.fn("OpenAIChat.mapFinishReason")(function* (event: OpenAIChatEvent, reason: string) {
switch (reason) {
case "error":
return yield* new AIError({
@@ -1218,7 +1214,7 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
] as const
})
export const finishEvents = Effect.fnUntraced(function* (state: ParserState) {
const finishEvents = Effect.fn("OpenAIChat.finishEvents")(function* (state: ParserState) {
if (state.finishReason === undefined && state.requireFinishReason)
return yield* new AIError({
reason: new InvalidProviderOutputError({
+2 -2
View File
@@ -208,7 +208,7 @@ const eventImage = (frame: string, label: string, data: string, format: string,
info: info(format, size),
})
const onEvent = Effect.fnUntraced(function* (state: State, frame: string) {
const onEvent = Effect.fn("OpenAIImages.onEvent")(function* (state: State, frame: string) {
const event = yield* decodeEvent(frame)
const format = event.output_format
if ("partial_image_index" in event) {
@@ -229,7 +229,7 @@ const onEvent = Effect.fnUntraced(function* (state: State, frame: string) {
] as const
})
const onDocument = Effect.fnUntraced(function* (frame: Exclude<Frame, string>) {
const onDocument = Effect.fn("OpenAIImages.onDocument")(function* (frame: Exclude<Frame, string>) {
const invalid = (message: string, cause?: unknown) => route.frameError(message, frame.document, cause)
const decoded = yield* decodeDocument(frame.document).pipe(
Effect.mapError((cause) => invalid(`${route.name} returned an invalid response`, cause)),
@@ -157,7 +157,7 @@ const nativeImageTool = (tool: ToolDefinition) => {
return Schema.is(OpenAIResponsesImageGenerationTool)(native) ? native : undefined
}
const lowerTool = Effect.fnUntraced(function* (tool: ToolDefinition) {
const lowerTool = Effect.fn("OpenAIResponses.lowerTool")(function* (tool: ToolDefinition) {
const native = nativeImageToolInput(tool)
if (native !== undefined) {
if (Schema.is(OpenAIResponsesImageGenerationTool)(native)) return native
@@ -168,7 +168,7 @@ const lowerTool = Effect.fnUntraced(function* (tool: ToolDefinition) {
// Native namespaces hold only function tools, so deeper levels flatten into
// the leaf names the same way non-native protocols flatten the whole tree.
const lowerToolEntry = Effect.fnUntraced(function* (tool: ToolEntry) {
const lowerToolEntry = Effect.fn("OpenAIResponses.lowerToolEntry")(function* (tool: ToolEntry) {
if (tool.type === "tool") return yield* lowerTool(tool)
// OpenAI requires a namespace description; fall back to a generic one so a
// missing description never blocks the request.
@@ -237,7 +237,7 @@ const checkpointBody = {
}),
}
const hostedToolResult = Effect.fnUntraced(function* (item: ResponsesHostedTools.Item) {
const hostedToolResult = Effect.fn("OpenAIResponses.hostedToolResult")(function* (item: ResponsesHostedTools.Item) {
const isError = item.error !== undefined && item.error !== null
if (item.type === "image_generation_call" && item.result) {
yield* Effect.fromResult(Encoding.decodeBase64(item.result)).pipe(
+1 -1
View File
@@ -94,7 +94,7 @@ const fromRequest = Effect.fn("OpenAISpeech.fromRequest")(function* (request: Me
const isSse = (body: MediaProtocol.Body) => body.type === "json" && body.value.stream_format === "sse"
const onEvent = Effect.fnUntraced(function* (state: State, frame: string) {
const onEvent = Effect.fn("OpenAISpeech.onEvent")(function* (state: State, frame: string) {
const event = yield* decodeEvent(frame)
if (event.type === "speech.audio.delta") return SpeechStream.delta(state, event.audio)
const usage = event.usage
@@ -210,7 +210,7 @@ const segment = (value: Schema.Schema.Type<typeof Segment>): TranscriptionSegmen
speaker: value.speaker,
})
const onEvent = Effect.fnUntraced(function* (state: State, frame: string) {
const onEvent = Effect.fn("OpenAITranscription.onEvent")(function* (state: State, frame: string) {
if (!EVENT_TYPES.has((yield* decodeEventType(frame)).type)) return [state, []] as const
const event = yield* decodeEvent(frame)
if (event.type === "error")
+2 -2
View File
@@ -154,7 +154,7 @@ export const wrapSystemUpdate = (parts: ReadonlyArray<{ readonly text: string }>
* raw retrieved, tool, or web content into privileged updates: keep untrusted
* data in ordinary user/tool messages instead.
*/
export const systemUpdateText = Effect.fnUntraced(function* (
export const systemUpdateText = Effect.fn("ProviderShared.systemUpdateText")(function* (
route: string,
message: LLMRequest["messages"][number],
) {
@@ -167,7 +167,7 @@ export const systemUpdateText = Effect.fnUntraced(function* (
})
/** Lower an unsupported privileged update into visible, in-order user text. */
export const wrappedSystemUpdate = Effect.fnUntraced(function* (
export const wrappedSystemUpdate = Effect.fn("ProviderShared.wrappedSystemUpdate")(function* (
route: string,
message: LLMRequest["messages"][number],
) {
@@ -82,7 +82,7 @@ const endpoint = (model: string) => (model.startsWith("sd3") ? "sd3" : model)
const RESERVED_FORM_FIELDS = new Set(["image", "prompt", "mode", "model"])
const form = Effect.fnUntraced(function* (
const form = Effect.fn("StabilityImages.form")(function* (
identity: MediaProtocol.Identity,
fields: Record<string, unknown>,
native: Record<string, unknown> | undefined,
@@ -76,7 +76,7 @@ function documentName(filename: string | undefined, names: Set<string>) {
return name
}
const mediaBase64 = Effect.fnUntraced(function* (part: MediaPart) {
const mediaBase64 = Effect.fn("BedrockMedia.mediaBase64")(function* (part: MediaPart) {
const media = yield* ProviderShared.requireInlineMedia("Bedrock Converse", part.media)
const bytes = yield* Effect.fromResult(Encoding.decodeBase64(media.base64)).pipe(
Effect.mapError((cause) =>
@@ -91,7 +91,7 @@ const mediaBase64 = Effect.fnUntraced(function* (part: MediaPart) {
// document block. Image MIME types not in `IMAGE_FORMATS` (e.g. `image/svg+xml`)
// get an image-specific error so the caller knows it's a format-support issue,
// not a kind-detection issue.
export const lower = Effect.fnUntraced(function* (part: MediaPart, documentNames: Set<string>) {
export const lower = Effect.fn("BedrockMedia.lower")(function* (part: MediaPart, documentNames: Set<string>) {
const mime = part.media.mediaType.toLowerCase()
const imageFormat = IMAGE_FORMATS[mime as keyof typeof IMAGE_FORMATS]
if (imageFormat) {
@@ -11,7 +11,7 @@ interface State {
readonly responseID?: string
}
const onOutputItem = Effect.fnUntraced(function* (
const onOutputItem = Effect.fn("ResponsesCheckpoint.onOutputItem")(function* (
state: State,
input: OpenResponses.Event,
) {
@@ -63,7 +63,7 @@ export const make = <Body>(body: RouteBody<Body>): TriggerCompactOperation =>
checkpoints: {},
}),
terminal: OpenResponses.terminal,
step: Effect.fnUntraced(function* (state: State, event: OpenResponses.Event) {
step: Effect.fn("ResponsesCheckpoint.step")(function* (state: State, event: OpenResponses.Event) {
if (event.response?.id && state.responseID && event.response.id !== state.responseID)
return yield* ProviderShared.eventError(source.id, "Compaction response ID changed during execution")
if (event.type === "response.created") return [{ ...state, responseID: event.response?.id }, []] as const
@@ -33,7 +33,7 @@ export const onDone: (
state: OpenResponses.ParserState,
item: Item,
tools: Definitions,
) => Effect.Effect<OpenResponses.StepResult, AIError> = Effect.fnUntraced(
) => Effect.Effect<OpenResponses.StepResult, AIError> = Effect.fn("ResponsesHostedTools.onDone")(
function* (state, item, tools) {
const tool = tools[item.type]
if (!tool) return [state, []] satisfies OpenResponses.StepResult
+8 -15
View File
@@ -60,21 +60,14 @@ const inputStart = (tool: PendingTool) =>
providerMetadata: tool.providerMetadata,
})
const inputDelta = (tool: PendingTool, text: string): LLMEvent => {
const raw = tool.input
let parsed: unknown
return {
...LLMEvent.toolInputDelta({
id: tool.id,
name: tool.name,
namespace: tool.namespace,
text,
}),
get input() {
return (parsed ??= Option.getOrElse(parsePartialInput(raw), () => ({})))
},
}
}
const inputDelta = (tool: PendingTool, text: string) =>
LLMEvent.toolInputDelta({
id: tool.id,
name: tool.name,
namespace: tool.namespace,
text,
input: Option.getOrElse(parsePartialInput(tool.input), () => ({})),
})
const toolCall = (route: string, tool: PendingTool, inputOverride?: string) => {
const raw = inputOverride ?? tool.input
-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"
@@ -1,20 +1,15 @@
import { Route, type RouteDefaultsInput } from "../route/client.js"
import { Endpoint } from "../route/endpoint.js"
import type { ProviderPackage } from "../provider-package.js"
import { AnthropicMessages } from "../protocols/anthropic-messages.js"
import { OpenAIChat } from "../protocols/openai-chat.js"
import { OpenResponses } from "../protocols/open-responses.js"
import { BedrockAuth, type Credentials } from "../protocols/utils/bedrock-auth.js"
import { claudeVersion } from "../protocols/utils/claude-model.js"
import { ProviderConfigurationError, ProviderID, type ModelID } from "../schema/index.js"
import { withOpenAIOptions, type OpenAIProviderOptionsInput } from "./openai-options.js"
export const id = ProviderID.make("amazon-bedrock")
export type OpenAIOptionsInput = OpenAIProviderOptionsInput
export type MessagesOptionsInput = AnthropicMessages.ProviderOptionsInput
export type Config = Omit<RouteDefaultsInput, "providerOptions"> & {
export type Config = RouteDefaultsInput & {
/** Bedrock API key. Falls back to `AWS_BEARER_TOKEN_BEDROCK`; bearer auth takes precedence over SigV4. */
readonly apiKey?: string
/** `sigv4` ignores `apiKey` fallbacks from the environment; `bearer` requires a token. */
@@ -25,11 +20,11 @@ export type Config = Omit<RouteDefaultsInput, "providerOptions"> & {
/** Shared config profile for the default credential chain. */
readonly profile?: string
readonly region?: string
readonly providerOptions?: OpenAIProviderOptionsInput | AnthropicMessages.ProviderOptionsInput
readonly providerOptions?: OpenAIProviderOptionsInput
}
export type Settings<Options = OpenAIProviderOptionsInput> = ProviderPackage.Settings &
Options & {
export type Settings = ProviderPackage.Settings &
OpenAIProviderOptionsInput & {
readonly apiKey?: string
readonly auth?: "bearer" | "sigv4"
readonly baseURL?: string
@@ -39,8 +34,6 @@ export type Settings<Options = OpenAIProviderOptionsInput> = ProviderPackage.Set
readonly topP?: number
}
export type MessagesSettings = Settings<AnthropicMessages.ProviderOptionsInput>
const responsesRoute = Route.make({
id: "bedrock-mantle-responses",
provider: id,
@@ -57,35 +50,12 @@ const chatRoute = OpenAIChat.route.with({
providerMetadataKey: "mantle",
})
const messagesRoute = Route.make({
id: "bedrock-mantle-messages",
provider: id,
providerMetadataKey: "mantle",
protocol: {
...AnthropicMessages.protocol,
// Mantle rejects mid-conversation `output_config` on Opus 5.0; support starts at 5.1+.
supportsEffortUpdates: (request) => {
const override = request.model.compatibility?.supportsEffortUpdates
if (override !== undefined) return override
const version = claudeVersion(request.model.id)
return version !== undefined && (version.major > 5 || (version.major === 5 && version.minor >= 1))
},
},
endpoint: Endpoint.path(AnthropicMessages.PATH),
transport: AnthropicMessages.transport<AnthropicMessages.AnthropicMessagesBody>(),
headers: () => ({ "anthropic-version": "2023-06-01" }),
})
export const routes = [responsesRoute, chatRoute]
export const routes = [responsesRoute, chatRoute, messagesRoute]
const configuredRoute = <Body, Prepared>(
route: Route<Body, Prepared>,
input: Config,
defaultBaseURL = (region: string) => `https://bedrock-mantle.${region}.api.aws/v1`,
) => {
const configuredRoute = <Body, Prepared>(route: Route<Body, Prepared>, input: Config) => {
const region = BedrockAuth.resolveRegion(input)
return route.with({
endpoint: { baseURL: input.baseURL ?? defaultBaseURL(region) },
endpoint: { baseURL: input.baseURL ?? `https://bedrock-mantle.${region}.api.aws/v1` },
auth: BedrockAuth.resolveAuth(input, region, {
service: "bedrock-mantle",
name: "Bedrock Mantle",
@@ -117,11 +87,6 @@ export const configure = (input: Config = {}) => {
})
const configuredResponsesRoute = configuredRoute(responsesRoute, input)
const configuredChatRoute = configuredRoute(chatRoute, input)
const configuredMessagesRoute = configuredRoute(
messagesRoute,
input,
(region) => `https://bedrock-mantle.${region}.api.aws/anthropic/v1`,
)
const modelDefaults = defaults(input)
const responses = (modelID: string | ModelID) =>
configuredResponsesRoute
@@ -131,14 +96,11 @@ export const configure = (input: Config = {}) => {
configuredChatRoute
.with(withOpenAIOptions(modelID, modelDefaults))
.model<OpenAIProviderOptionsInput>({ id: modelID })
const messages = (modelID: string | ModelID) =>
configuredMessagesRoute.with(modelDefaults).model<AnthropicMessages.ProviderOptionsInput>({ id: modelID })
return {
id,
model: responses,
chat,
messages,
responses,
configure,
}
@@ -157,7 +119,7 @@ const fromSettings = ({
region,
topP,
...providerOptions
}: Settings<Config["providerOptions"]>) =>
}: Settings) =>
configure({
apiKey,
auth,
@@ -175,10 +137,6 @@ export const chatModel: ProviderPackage.Definition<Settings, OpenAIProviderOptio
modelID,
settings,
) => fromSettings(settings).chat(modelID)
export const messagesModel: ProviderPackage.Definition<
MessagesSettings,
AnthropicMessages.ProviderOptionsInput
>["model"] = (modelID, settings) => fromSettings(settings).messages(modelID)
export const responsesModel: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (
modelID,
settings,
@@ -1,2 +0,0 @@
export { messagesModel as model } from "../../amazon-bedrock-mantle.js"
export type { MessagesSettings as Settings } from "../../amazon-bedrock-mantle.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"
+1 -1
View File
@@ -50,7 +50,7 @@ export const gpt5DefaultOptions = (modelID: string): ProviderOptions | undefined
export const openAIDefaultOptions = (modelID: string): ProviderOptions | undefined =>
mergeProviderOptions(openAIProviderOptions({ store: false }), gpt5DefaultOptions(modelID))
export const withOpenAIOptions = <Options extends { readonly providerOptions?: ProviderOptions }>(
export const withOpenAIOptions = <Options extends { readonly providerOptions?: OpenAIProviderOptionsInput }>(
modelID: string,
options: Options,
): Omit<Options, "providerOptions"> & { readonly providerOptions?: ProviderOptions } => {
-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"
+31 -27
View File
@@ -1,5 +1,5 @@
import { Effect, Stream } from "effect"
import { makeParser } from "effect/unstable/encoding/Sse"
import { makeParser, type Event } from "effect/unstable/encoding/Sse"
import { AIError, InvalidProviderOutputError } from "../schema/index.js"
/**
@@ -42,39 +42,43 @@ export const sseFraming = (
Stream.decodeText(),
Stream.mapAccumEffect(
() => {
const output: string[] = []
const output: Event[] = []
return {
output,
parser: makeParser((event) => {
if (
event._tag === "Event" &&
(events === undefined || events.has(event.event)) &&
event.data.length > 0 &&
// Some OpenAI-compatible proxies serialize an empty flush as a bare
// `data: null`, between events or after `[DONE]`. No protocol has a
// null event, so it carries nothing and must not abort the stream.
event.data !== "null" &&
// Vertex AI partner models (e.g. `xai/grok-4.6`) send their SSE
// keepalive comment as `data: : keepalive` while reasoning.
event.data !== ": keepalive" &&
(event.data !== "[DONE]" || includeDone || (events !== undefined && event.event !== "message"))
)
output.push(event.data)
if (event._tag === "Event") output.push(event)
}),
}
},
(state, chunk) => {
const error = state.parser.feed(chunk)
if (!error) return Effect.succeed([state, state.output.splice(0)] as const)
const reason = new InvalidProviderOutputError({
route: "sse",
message: error.message,
body: chunk,
cause: error,
})
return Effect.fail(new AIError({ reason }))
},
(state, chunk) =>
Effect.gen(function* () {
const error = state.parser.feed(chunk)
if (error)
return yield* new AIError({
reason: new InvalidProviderOutputError({
route: "sse",
message: error.message,
body: chunk,
cause: error,
}),
})
return [state, state.output.splice(0)] as const
}),
),
Stream.filter(
(event) =>
(events === undefined || events.has(event.event)) &&
event.data.length > 0 &&
// Some OpenAI-compatible proxies serialize an empty flush as a bare
// `data: null`, between events or after `[DONE]`. No protocol has a
// null event, so it carries nothing and must not abort the stream.
event.data !== "null" &&
// Vertex AI partner models (e.g. `xai/grok-4.6`) send their SSE
// keepalive comment as `data: : keepalive` while reasoning.
event.data !== ": keepalive" &&
(event.data !== "[DONE]" || includeDone || (events !== undefined && event.event !== "message")),
),
Stream.map((event) => event.data),
)
/** Server-Sent Events framing. Used by every JSON-streaming HTTP provider. */
+8 -68
View File
@@ -6,7 +6,7 @@ import { compileRequest } from "../src/route/client.js"
import { AnthropicMessages } from "../src/protocols/anthropic-messages.js"
import { OpenAIResponses } from "../src/protocols/openai-responses.js"
import { Gemini } from "../src/protocols/gemini.js"
import { AmazonBedrockMantle, GoogleVertexMessages, OpenAI } from "../src/providers.js"
import { GoogleVertexMessages, OpenAI } from "../src/providers.js"
import { applyEffortUpdates } from "../src/effort-updates.js"
import { it, testEffect } from "./lib/effect.js"
import { dynamicResponse } from "./lib/http.js"
@@ -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(
@@ -268,53 +241,20 @@ describe("Anthropic Messages effort updates", () => {
}),
)
it.effect("strips markers for Opus 5.0 on Bedrock Mantle Messages while lowering Opus 5.5", () =>
it.effect("strips markers on the Vertex Anthropic route, whose protocol wrapper does not forward support", () =>
Effect.gen(function* () {
const mantle = AmazonBedrockMantle.configure({ apiKey: "test", region: "us-east-1" })
const opus50 = yield* compileRequest(
const prepared = yield* compileRequest(
LLM.request({
model: mantle.messages("anthropic.claude-opus-5"),
messages: conversation,
providerOptions: { effort: "low" },
}),
)
const opus55 = yield* compileRequest(
LLM.request({
model: mantle.messages("anthropic.claude-opus-5-5"),
model: GoogleVertexMessages.configure({ accessToken: "test", location: "global", project: "test" }).model(
"claude-opus-5",
),
messages: conversation,
providerOptions: { effort: "low" },
}),
)
expect(systemMessages(opus50.body)).toHaveLength(0)
expect(opus50.body.output_config).toEqual({ effort: "low" })
expect(systemMessages(opus55.body)).toEqual([{ role: "system", content: [], output_config: { effort: "low" } }])
expect(opus55.body.output_config).toEqual({ effort: "high" })
}),
)
it.effect("lowers markers on the Vertex Anthropic route for models that support them", () =>
Effect.gen(function* () {
const vertex = GoogleVertexMessages.configure({ accessToken: "test", location: "global", project: "test" })
const opus5 = 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"),
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,57 +0,0 @@
{
"version": 1,
"metadata": {
"model": "anthropic.claude-opus-5-5",
"tags": [
"prefix:bedrock-mantle-messages",
"provider:amazon-bedrock",
"protocol:anthropic-messages",
"reasoning",
"effort-update"
],
"name": "bedrock-mantle-messages/applies-mid-conversation-effort-updates-and-thinking-block-binding-on-opus-5-5",
"recordedAt": "2026-10-04T03:56:23.139Z"
},
"interactions": [
{
"transport": "http",
"request": {
"method": "POST",
"url": "https://bedrock-mantle.us-east-1.api.aws/anthropic/v1/messages",
"headers": {
"anthropic-beta": "interleaved-thinking-2025-05-14,thinking-binding-controls-2026-08-01",
"anthropic-version": "2023-06-01",
"content-type": "application/json"
},
"body": "{\"model\":\"anthropic.claude-opus-5-5\",\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"Compute 37 * 43 step by step, then reply with only the integer.\",\"cache_control\":{\"type\":\"ephemeral\"}}]}],\"stream\":true,\"max_tokens\":2048,\"thinking\":{\"type\":\"adaptive\",\"display\":\"summarized\",\"block_binding\":{\"prefix_mismatch_behavior\":\"drop_block\"}},\"output_config\":{\"effort\":\"high\"}}"
},
"response": {
"status": 200,
"headers": {
"content-type": "text/event-stream; charset=utf-8"
},
"body": "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"model\":\"claude-opus-5-5\",\"id\":\"msg_bdrk_y7h2x5bz6w6ggmekuxekbi6rseptglsljkwgj36phisnsnu3syea\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"container\":null,\"stop_reason\":null,\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":31,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":2,\"service_tier\":\"standard\"},\"input_transformations\":[]}}\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"thinking\",\"thinking\":\"\",\"signature\":\"\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\"They\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" want\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" only\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" the final\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" integer, so\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" I just\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" need\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" to compute 37\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\"×43 \"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\"using the dif\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\"ference of squares: 40\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\"²−3²\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" = 1600−9 =\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" 1591.\\n\\n\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"signature_delta\",\"signature\":\"CAQSogUKEggSEAEYAjgBQgh0aGlua2luZxIM48GlQ7IvNDW3jk2BGgw0Aq5cIs+m8gjYEIQiMFOyG984KYDLRzchzn2ApX6Rs3r7+CtXL0KeCRsXr1XKY3Y0UNK73Qrjyk94TGFPTSq9BCCZALbMiaeiIDqgB3H8jfrU26rP6kORoTJuF84ByBhXGI9AGo30c3RU2ZueXE/oLvRkK12g8r3TPql2fam1gcC/SeZ627fqGykV6G8WrPH7kthjbR45vnRuaqgFZmyA4C/XCxEH0zSSlVAhXf3Um9sjO/RD8lMJttQCAJSurbB5wmKbUQmZeOjIn8augHV/KmsGcFaHBjGLNfnwfEUwLEbRjNh5eIsmcs9dFfIeYHYNRgtLnBkERACoKPQLp2xU090/wCwrFHIGgLYbklR5wUhGqrAERhOAybqMxlNN3dwd7dV5Tbd16xEghDWb6wUtoxnNQdsUsV/0F/b8Ov9TSu93db/QJAkR74OBMtPuEu914SxtC4NBtqX5YZQAo//O4piaQGzFRVE7TahKzMS5bD111w/qz/cLpqEI/bZMqsXKdzBuMMOAox9TvgTumemdiiPu8tn2ONQeAnuNLjArrr7Jz1mbE9GqPl6aJW9HYWPb0BxCQ/hRXFz5d0v+okmmn5KCV2nD55wk2ySWz9OxIMQUsJ6uEUVOZPk7O0ZJ2ppoLAx6GYq2f55nNcUCsbKB4gB4+QlNp3Tf5IemDdZaY/tGW71dgpYI7iWYYEhqNtlC8pXpoEIbO31IvAHS9BGeQRYQQh1h0muEz4S1+YICPTdXnUmjc0uJ9MGBIKOuBsUhg+E4dzZzOfwS4KteNmJgjhj53WVdjJgRslemZle0tZphrTLLgvZpLZDVIYmOkIpbo2tf5Cflx/rf/NPg7RgB\"}}\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0}\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":1,\"content_block\":{\"type\":\"text\",\"text\":\"\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"text_delta\",\"text\":\"1591\"}}\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":1}\n\nevent: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\",\"stop_sequence\":null,\"stop_details\":null,\"container\":null},\"usage\":{\"input_tokens\":31,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"output_tokens\":86,\"output_tokens_details\":{\"thinking_tokens\":82}}}\n\nevent: message_stop\ndata: {\"type\":\"message_stop\"}\n\n"
}
},
{
"transport": "http",
"request": {
"method": "POST",
"url": "https://bedrock-mantle.us-east-1.api.aws/anthropic/v1/messages",
"headers": {
"anthropic-beta": "interleaved-thinking-2025-05-14,mid-conversation-output-config-2026-07-01,thinking-binding-controls-2026-08-01",
"anthropic-version": "2023-06-01",
"content-type": "application/json"
},
"body": "{\"model\":\"anthropic.claude-opus-5-5\",\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"Compute 37 * 43 step by step, then reply with only the integer.\"}]},{\"role\":\"assistant\",\"content\":[{\"type\":\"thinking\",\"thinking\":\"They want only the final integer, so I just need to compute 37×43 using the difference of squares: 40²−3² = 1600−9 = 1591.\\n\\n\",\"signature\":\"CAQSogUKEggSEAEYAjgBQgh0aGlua2luZxIM48GlQ7IvNDW3jk2BGgw0Aq5cIs+m8gjYEIQiMFOyG984KYDLRzchzn2ApX6Rs3r7+CtXL0KeCRsXr1XKY3Y0UNK73Qrjyk94TGFPTSq9BCCZALbMiaeiIDqgB3H8jfrU26rP6kORoTJuF84ByBhXGI9AGo30c3RU2ZueXE/oLvRkK12g8r3TPql2fam1gcC/SeZ627fqGykV6G8WrPH7kthjbR45vnRuaqgFZmyA4C/XCxEH0zSSlVAhXf3Um9sjO/RD8lMJttQCAJSurbB5wmKbUQmZeOjIn8augHV/KmsGcFaHBjGLNfnwfEUwLEbRjNh5eIsmcs9dFfIeYHYNRgtLnBkERACoKPQLp2xU090/wCwrFHIGgLYbklR5wUhGqrAERhOAybqMxlNN3dwd7dV5Tbd16xEghDWb6wUtoxnNQdsUsV/0F/b8Ov9TSu93db/QJAkR74OBMtPuEu914SxtC4NBtqX5YZQAo//O4piaQGzFRVE7TahKzMS5bD111w/qz/cLpqEI/bZMqsXKdzBuMMOAox9TvgTumemdiiPu8tn2ONQeAnuNLjArrr7Jz1mbE9GqPl6aJW9HYWPb0BxCQ/hRXFz5d0v+okmmn5KCV2nD55wk2ySWz9OxIMQUsJ6uEUVOZPk7O0ZJ2ppoLAx6GYq2f55nNcUCsbKB4gB4+QlNp3Tf5IemDdZaY/tGW71dgpYI7iWYYEhqNtlC8pXpoEIbO31IvAHS9BGeQRYQQh1h0muEz4S1+YICPTdXnUmjc0uJ9MGBIKOuBsUhg+E4dzZzOfwS4KteNmJgjhj53WVdjJgRslemZle0tZphrTLLgvZpLZDVIYmOkIpbo2tf5Cflx/rf/NPg7RgB\"},{\"type\":\"text\",\"text\":\"1591\"}]},{\"role\":\"system\",\"content\":[],\"output_config\":{\"effort\":\"low\"}},{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"Add 9 to that result. Reply with only the integer.\",\"cache_control\":{\"type\":\"ephemeral\"}}]}],\"stream\":true,\"max_tokens\":2048,\"thinking\":{\"type\":\"adaptive\",\"display\":\"summarized\",\"block_binding\":{\"prefix_mismatch_behavior\":\"drop_block\"}},\"output_config\":{\"effort\":\"high\"}}"
},
"response": {
"status": 200,
"headers": {
"content-type": "text/event-stream; charset=utf-8"
},
"body": "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"model\":\"claude-opus-5-5\",\"id\":\"msg_bdrk_yzpmbqh3aag3i7kymdm7ctqputcpeuln3cy44msgbdnctkcweena\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"container\":null,\"stop_reason\":null,\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":139,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":4,\"service_tier\":\"standard\"},\"input_transformations\":[]}}\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"1600\"}}\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0}\n\nevent: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\",\"stop_sequence\":null,\"stop_details\":null,\"container\":null},\"usage\":{\"input_tokens\":139,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"output_tokens\":4,\"output_tokens_details\":{\"thinking_tokens\":0}}}\n\nevent: message_stop\ndata: {\"type\":\"message_stop\"}\n\n"
}
}
]
}
@@ -1,37 +0,0 @@
{
"version": 1,
"metadata": {
"model": "anthropic.claude-haiku-4-5",
"tags": [
"prefix:bedrock-mantle-messages",
"provider:amazon-bedrock",
"protocol:anthropic-messages",
"reasoning",
"system-update"
],
"name": "bedrock-mantle-messages/lowers-system-updates-to-wrapped-user-text-on-haiku-4-5-with-budget-thinking",
"recordedAt": "2026-10-04T03:56:18.874Z"
},
"interactions": [
{
"transport": "http",
"request": {
"method": "POST",
"url": "https://bedrock-mantle.us-east-1.api.aws/anthropic/v1/messages",
"headers": {
"anthropic-beta": "interleaved-thinking-2025-05-14",
"anthropic-version": "2023-06-01",
"content-type": "application/json"
},
"body": "{\"model\":\"anthropic.claude-haiku-4-5\",\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"What is 19 multiplied by 23?\"},{\"type\":\"text\",\"text\":\"<system-update>\\nReply with only the integer.\\n</system-update>\",\"cache_control\":{\"type\":\"ephemeral\"}}]}],\"stream\":true,\"max_tokens\":2048,\"thinking\":{\"type\":\"enabled\",\"budget_tokens\":1024}}"
},
"response": {
"status": 200,
"headers": {
"content-type": "text/event-stream; charset=utf-8"
},
"body": "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"model\":\"claude-haiku-4-5-20251001\",\"id\":\"msg_bdrk_prlwlyj7mdyfbm6ejq74ibwylnwd6deibs6j5xjq6lsc4dzuyzqa\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"container\":null,\"stop_reason\":null,\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":65,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":1,\"service_tier\":\"standard\"}}}\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"thinking\",\"thinking\":\"\",\"signature\":\"\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\"19\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" × 23 = ?\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\"\\n\\nLet me calculate\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" this:\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\"\\n19 × 23 =\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" 19 × (20 +\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" 3)\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\"\\n= 19 × 20\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" + 19 × 3\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\"\\n= 380 + 57\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\"\\n= 437\\n\\nOr\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" alternatively\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\":\\n19 × 23 \"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\"= (\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\"20 - 1) × \"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\"23\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\"\\n= 20 × 23 \"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\"- 23\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\"\\n= 460 - 23\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\"\\n= 437\\n\\nSo\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" the\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" answer is 437.\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\"\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"signature_delta\",\"signature\":\"ErEDCoQBCBIQARgCKkBdVqTX3D5rRVz4O3SlBHJaAUb4Jv4CxOT9igcrHKXsDtU6Wwh9dw7QJbtvpCA0UJh0Wi0PCzu1Tlp8W8jmIU9wMhljbGF1ZGUtaGFpa3UtNC01LTIwMjUxMDAxOABCCHRoaW5raW5nWgwyMzI3NzE4NTY3ODGoAeKch9YGEgyP1xZ80Q63y4Uko9EaDHZo9a+qBGcqeBRLcSIw5/J5NpbvXm6aIqCN58pfjKvW9/35kh6B+t2jS/7dssFzks6J4m+5jtG7BRf9x65GKtkBOhxxxMXrJDYW8RJkXU9iKampxOVFPJuSrvSw43H1MF4V/dt2RFIXeKMNmIJ4umr4nTNVtSlaIEUt+uYmT/F0QL4wXB1Pd40e4VMcBqkJ3CmHkspPJgkyLursJIpDVJQ4TMCHDBzvEObimdAgoAzHM8ahMVOzb9nW9C7ZGE7OFinrq/uR+AzXR0N4zyBIenjcDF76n+OFm3ap3S7ngBQl6+2enaRiseO/UZ4UzgrYYkxk/V4oobGytu64Dn+ngeceb7P+uszDG8BsDs3zXWQdi2w56OvD6HGFVhgB\"}}\n\nevent: content_block_stop\ndata: {Line truncated
}
}
]
}
@@ -1,59 +0,0 @@
{
"version": 1,
"metadata": {
"model": "anthropic.claude-opus-4-8",
"tags": [
"prefix:bedrock-mantle-messages",
"provider:amazon-bedrock",
"protocol:anthropic-messages",
"tool",
"tool-loop",
"reasoning",
"system-update"
],
"name": "bedrock-mantle-messages/replays-signed-thinking-through-a-tool-loop-and-native-system-update",
"recordedAt": "2026-10-04T04:05:26.433Z"
},
"interactions": [
{
"transport": "http",
"request": {
"method": "POST",
"url": "https://bedrock-mantle.us-east-1.api.aws/anthropic/v1/messages",
"headers": {
"anthropic-beta": "interleaved-thinking-2025-05-14",
"anthropic-version": "2023-06-01",
"content-type": "application/json"
},
"body": "{\"model\":\"anthropic.claude-opus-4-8\",\"system\":[{\"type\":\"text\",\"text\":\"You are a concise assistant.\",\"cache_control\":{\"type\":\"ephemeral\"}}],\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"First calculate 37 * 43. Then call get_weather for Paris. After receiving the tool result, state both the product and the weather.\",\"cache_control\":{\"type\":\"ephemeral\"}}]}],\"tools\":[{\"name\":\"get_weather\",\"description\":\"Get the current weather in a city\",\"input_schema\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\",\"enum\":[\"Paris\"]}},\"required\":[\"city\"],\"additionalProperties\":false},\"cache_control\":{\"type\":\"ephemeral\"}}],\"stream\":true,\"max_tokens\":2048,\"thinking\":{\"type\":\"adaptive\",\"display\":\"summarized\"},\"output_config\":{\"effort\":\"medium\"}}"
},
"response": {
"status": 200,
"headers": {
"content-type": "text/event-stream; charset=utf-8"
},
"body": "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"model\":\"claude-opus-4-8\",\"id\":\"msg_bdrk_fycfxoffsnlt6mj5wu2urbd2psp276vwn4pddgbaazfim6fxcvhq\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"container\":null,\"stop_reason\":null,\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":439,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":2,\"service_tier\":\"standard\"}}}\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"thinking\",\"thinking\":\"\",\"signature\":\"\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\"37\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" times\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" 43\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" is 1591\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\", that\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" one\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\"'s\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" quick to do\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" my\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\"self. Now I'll go\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" ahead and call get\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\"_weather.\\n\\n\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"signature_delta\",\"signature\":\"EqoDCngIEhABGAIqQIGpXRhzl8JEPJWxprG5sxaT1WjZ8/kEJgxZTN2qtzM5uC63GiumUjsHzfEIJikrldkFbwPbc0enJCXIoLr0G5UyDWNsYXVkZS1xdWluY2U4AEIIdGhpbmtpbmdaDDIzMjc3MTg1Njc4MagBhaGH1gYSDI7iCpVUnPgOaCirJBoMR20SZNRICFBH7f84IjDDaA7qNPHUlJN+eQa/aYqKn6Z2LobkjfWtscoBJs9sjR8qTy477EOozPy6lTkt1Pwq3wFcgWmJHdh5a0V2iRnSN4CtMEwxVPqC2im7vDCJDpNeAld8VmAO0u594guMJca+jme4lM/MW5oy3WrkmcL6Uc+qRByhUXhSqTWra8w9Xxf5S2nbPcT+FXjx1z1oDR1I8SIMzZrUM1DGOtQp7L/oWPadGwJYZ9EqKf9beRmSoc23kJKF5eA6+ZE9+HbS5gG1XxMDRFvx5rETS+mYMamOsfdowxWGWP7TzqHMhv+VGSCXoy2IXEIPBkXwen0K+dSAtgy/t21chzEmsGlBdBrAn7Ab0rCSqHvbZNZuA2T8CFPtGAE=\"}}\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0}\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":1,\"content_block\":{\"type\":\"text\",\"text\":\"\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"text_delta\",\"text\":\"37 × 43 \"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"text_delta\",\"text\":\"= 1591.\"}}\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":1}\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":2,\"content_block\":{\"type\":\"tool_use\",\"id\":\"toolu_bdrk_011We3Z1qCBXge7JLoyNwDat\",\"name\":\"get_weather\",\"input\":{}}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":2,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":2,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"{\\\"city\\\": \\\"Pa\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":2,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"ris\\\"}\"}}\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":2}\n\nevent: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"tool_use\",\"stop_sequence\":null,\"stop_details\":null,\"container\":null},\"usage\":{\"input_tokens\":439,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"output_tokens\":135,\"output_tokens_details\":{\"thinking_tokens\":73}}}\n\nevent: message_stop\ndata: {\"type\":\"message_stop\"}\n\n"
}
},
{
"transport": "http",
"request": {
"method": "POST",
"url": "https://bedrock-mantle.us-east-1.api.aws/anthropic/v1/messages",
"headers": {
"anthropic-beta": "interleaved-thinking-2025-05-14",
"anthropic-version": "2023-06-01",
"content-type": "application/json"
},
"body": "{\"model\":\"anthropic.claude-opus-4-8\",\"system\":[{\"type\":\"text\",\"text\":\"You are a concise assistant.\",\"cache_control\":{\"type\":\"ephemeral\"}}],\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"First calculate 37 * 43. Then call get_weather for Paris. After receiving the tool result, state both the product and the weather.\"}]},{\"role\":\"assistant\",\"content\":[{\"type\":\"thinking\",\"thinking\":\"37 times 43 is 1591, that one's quick to do myself. Now I'll go ahead and call get_weather.\\n\\n\",\"signature\":\"EqoDCngIEhABGAIqQIGpXRhzl8JEPJWxprG5sxaT1WjZ8/kEJgxZTN2qtzM5uC63GiumUjsHzfEIJikrldkFbwPbc0enJCXIoLr0G5UyDWNsYXVkZS1xdWluY2U4AEIIdGhpbmtpbmdaDDIzMjc3MTg1Njc4MagBhaGH1gYSDI7iCpVUnPgOaCirJBoMR20SZNRICFBH7f84IjDDaA7qNPHUlJN+eQa/aYqKn6Z2LobkjfWtscoBJs9sjR8qTy477EOozPy6lTkt1Pwq3wFcgWmJHdh5a0V2iRnSN4CtMEwxVPqC2im7vDCJDpNeAld8VmAO0u594guMJca+jme4lM/MW5oy3WrkmcL6Uc+qRByhUXhSqTWra8w9Xxf5S2nbPcT+FXjx1z1oDR1I8SIMzZrUM1DGOtQp7L/oWPadGwJYZ9EqKf9beRmSoc23kJKF5eA6+ZE9+HbS5gG1XxMDRFvx5rETS+mYMamOsfdowxWGWP7TzqHMhv+VGSCXoy2IXEIPBkXwen0K+dSAtgy/t21chzEmsGlBdBrAn7Ab0rCSqHvbZNZuA2T8CFPtGAE=\"},{\"type\":\"text\",\"text\":\"37 × 43 = 1591.\"},{\"type\":\"tool_use\",\"id\":\"toolu_bdrk_011We3Z1qCBXge7JLoyNwDat\",\"name\":\"get_weather\",\"input\":{\"city\":\"Paris\"}}]},{\"role\":\"user\",\"content\":[{\"type\":\"tool_result\",\"tool_use_id\":\"toolu_bdrk_011We3Z1qCBXge7JLoyNwDat\",\"content\":\"{\\\"condition\\\":\\\"sunny\\\",\\\"temperatureC\\\":18}\"}]},{\"role\":\"system\",\"content\":[{\"type\":\"text\",\"text\":\"Reply in French in one short sentence.\",\"cache_control\":{\"type\":\"ephemeral\"}}]}],\"tools\":[{\"name\":\"get_weather\",\"description\":\"Get the current weather in a city\",\"input_schema\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\",\"enum\":[\"Paris\"]}},\"required\":[\"city\"],\"additionalProperties\":false},\"cache_control\":{\"type\":\"ephemeral\"}}],\"stream\":true,\"max_tokens\":2048,\"thinking\":{\"type\":\"adaptive\",\"display\":\"summarized\"},\"output_config\":{\"effort\":\"medium\"}}"
},
"response": {
"status": 200,
"headers": {
"content-type": "text/event-stream; charset=utf-8"
},
"body": "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"model\":\"claude-opus-4-8\",\"id\":\"msg_bdrk_uak5dibczxblk4xunblaiowmid7zhky7ozwinjhpjtcbtgzvuwyq\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"container\":null,\"stop_reason\":null,\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":611,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":3,\"service_tier\":\"standard\"}}}\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"Le produ\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"it est \"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"1591 \"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"et il\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" f\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"ait beau à Paris av\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"ec 18°C.\"}}\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0}\n\nevent: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\",\"stop_sequence\":null,\"stop_details\":null,\"container\":null},\"usage\":{\"input_tokens\":611,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"output_tokens\":28,\"output_tokens_details\":{\"thinking_tokens\":0}}}\n\nevent: message_stop\ndata: {\"type\":\"message_stop\"}\n\n"
}
}
]
}
@@ -1,37 +0,0 @@
{
"version": 1,
"metadata": {
"model": "anthropic.claude-opus-5",
"tags": [
"prefix:bedrock-mantle-messages",
"provider:amazon-bedrock",
"protocol:anthropic-messages",
"reasoning",
"effort-update"
],
"name": "bedrock-mantle-messages/strips-unsupported-mid-conversation-effort-updates-on-opus-5-0",
"recordedAt": "2026-10-04T03:56:23.956Z"
},
"interactions": [
{
"transport": "http",
"request": {
"method": "POST",
"url": "https://bedrock-mantle.us-east-1.api.aws/anthropic/v1/messages",
"headers": {
"anthropic-beta": "interleaved-thinking-2025-05-14",
"anthropic-version": "2023-06-01",
"content-type": "application/json"
},
"body": "{\"model\":\"anthropic.claude-opus-5\",\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"What is 12 + 30?\"}]},{\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"42\"}]},{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"Add 8 to that result. Reply with only the integer.\",\"cache_control\":{\"type\":\"ephemeral\"}}]}],\"stream\":true,\"max_tokens\":1024,\"thinking\":{\"type\":\"adaptive\",\"display\":\"summarized\"},\"output_config\":{\"effort\":\"low\"}}"
},
"response": {
"status": 200,
"headers": {
"content-type": "text/event-stream; charset=utf-8"
},
"body": "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"model\":\"claude-opus-5\",\"id\":\"msg_bdrk_5mwa5agz3cvj44w72lno46ljoylvzjhipc4jmraridpzxktjquha\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"container\":null,\"stop_reason\":null,\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":38,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":1,\"service_tier\":\"standard\"}}}\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"50\"}}\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0}\n\nevent: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\",\"stop_sequence\":null,\"stop_details\":null,\"container\":null},\"usage\":{\"input_tokens\":38,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"output_tokens\":3,\"output_tokens_details\":{\"thinking_tokens\":0}}}\n\nevent: message_stop\ndata: {\"type\":\"message_stop\"}\n\n"
}
}
]
}
@@ -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" } },
})
+68
View File
@@ -7,6 +7,67 @@ const configuration = (provider: string, message: string) =>
expect.objectContaining({ _tag: "ProviderConfiguration", provider, message })
describe("provider package entrypoints", () => {
test("semantic API aliases expose the same contract", async () => {
const modules = await Promise.all([
import("@opencode/ai/providers/openai"),
import("@opencode/ai/providers/openai/responses"),
import("@opencode/ai/providers/openai/chat"),
import("@opencode/ai/providers/anthropic"),
import("@opencode/ai/providers/anthropic-compatible"),
import("@opencode/ai/providers/openai-compatible"),
import("@opencode/ai/providers/openai-compatible/responses"),
import("@opencode/ai/providers/amazon-bedrock"),
import("@opencode/ai/providers/azure"),
import("@opencode/ai/providers/azure/responses"),
import("@opencode/ai/providers/azure/chat"),
import("@opencode/ai/providers/google"),
import("@opencode/ai/providers/google-vertex"),
import("@opencode/ai/providers/google-vertex/gemini"),
import("@opencode/ai/providers/google-vertex/chat"),
import("@opencode/ai/providers/google-vertex/responses"),
import("@opencode/ai/providers/google-vertex/messages"),
import("@opencode/ai/providers/openrouter"),
import("@opencode/ai/providers/xai"),
import("@opencode/ai/providers/amazon-bedrock/mantle"),
import("@opencode/ai/providers/amazon-bedrock/mantle/chat"),
import("@opencode/ai/providers/amazon-bedrock/mantle/responses"),
import("@opencode/ai/providers/togetherai"),
import("@opencode/ai/providers/cerebras"),
import("@opencode/ai/providers/deepinfra"),
import("@opencode/ai/providers/groq"),
import("@opencode/ai/providers/baseten"),
import("@opencode/ai/providers/deepseek"),
import("@opencode/ai/providers/fireworks"),
import("@opencode/ai/providers/cloudflare-ai-gateway"),
import("@opencode/ai/providers/cloudflare-workers-ai"),
import("@opencode/ai/providers/minimax"),
import("@opencode/ai/providers/minimax/messages"),
import("@opencode/ai/providers/minimax/chat"),
import("@opencode/ai/providers/minimax/responses"),
import("@opencode/ai/providers/moonshot"),
import("@opencode/ai/providers/moonshot/chat"),
import("@opencode/ai/providers/moonshot/messages"),
import("@opencode/ai/providers/moonshot/responses"),
import("@opencode/ai/providers/zai"),
import("@opencode/ai/providers/zai/chat"),
import("@opencode/ai/providers/zai-coding-plan"),
import("@opencode/ai/providers/zai-coding-plan/chat"),
import("@opencode/ai/providers/zai-coding-plan/messages"),
import("@opencode/ai/providers/zai-coding-plan/responses"),
import("@opencode/ai/providers/alibaba"),
import("@opencode/ai/providers/alibaba/chat"),
import("@opencode/ai/providers/alibaba/messages"),
import("@opencode/ai/providers/alibaba/responses"),
])
for (const module of modules) expect(module.model).toBeFunction()
expect(modules[0].model).toBe(modules[1].model)
expect(modules[8].model).toBe(modules[9].model)
expect(modules[12].model).toBe(modules[13].model)
expect(modules[19].model).toBe(modules[21].model)
expect(modules[19].model).not.toBe(modules[20].model)
})
test("maps Alibaba API entrypoints onto explicit regional routes", async () => {
const modules = await Promise.all([
import("@opencode/ai/providers/alibaba"),
@@ -14,6 +75,7 @@ describe("provider package entrypoints", () => {
import("@opencode/ai/providers/alibaba/messages"),
import("@opencode/ai/providers/alibaba/responses"),
])
expect(modules[0].model).toBe(modules[1].model)
const settings = {
region: "eu-central-1",
workspaceID: "llm-fixture",
@@ -41,6 +103,7 @@ describe("provider package entrypoints", () => {
import("@opencode/ai/providers/moonshot/messages"),
import("@opencode/ai/providers/moonshot/responses"),
])
expect(modules[0].model).toBe(modules[1].model)
const settings = {
apiKey: "fixture",
baseURL: "https://gateway.example/v1",
@@ -84,6 +147,7 @@ describe("provider package entrypoints", () => {
import("@opencode/ai/providers/minimax/chat"),
import("@opencode/ai/providers/minimax/responses"),
])
expect(modules[0].model).toBe(modules[1].model)
const settings = {
apiKey: "fixture",
baseURL: "https://gateway.example/v1",
@@ -110,6 +174,8 @@ describe("provider package entrypoints", () => {
import("@opencode/ai/providers/zai-coding-plan/messages"),
import("@opencode/ai/providers/zai-coding-plan/responses"),
])
expect(modules[0].model).toBe(modules[1].model)
expect(modules[2].model).toBe(modules[3].model)
const routes = [
"zai-chat",
"zai-chat",
@@ -392,6 +458,7 @@ describe("provider package entrypoints", () => {
test("selects Vertex entrypoints with the same model contract", async () => {
const GoogleVertex = await import("@opencode/ai/providers/google-vertex")
const GoogleVertexGemini = await import("@opencode/ai/providers/google-vertex/gemini")
const GoogleVertexChat = await import("@opencode/ai/providers/google-vertex/chat")
const GoogleVertexResponses = await import("@opencode/ai/providers/google-vertex/responses")
const GoogleVertexMessages = await import("@opencode/ai/providers/google-vertex/messages")
@@ -416,6 +483,7 @@ describe("provider package entrypoints", () => {
project: "vertex-project",
})
expect(GoogleVertexGemini.model).toBe(GoogleVertex.model)
expect(gemini.route.id).toBe("google-vertex-gemini")
expect(gemini.route.protocol).toBe("gemini")
expect(gemini.route.endpoint.baseURL).toBe("https://aiplatform.googleapis.com/v1/publishers/google")
@@ -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." }] },
])
+21 -254
View File
@@ -1,8 +1,9 @@
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { HttpClientRequest } from "effect/unstable/http"
import { LLM, LLMEvent, LLMRequest, Message, ToolDefinition } from "../../src/index.js"
import { LLM, Message } from "../../src/index.js"
import { AmazonBedrockMantle } from "../../src/providers.js"
import { model } from "../../src/providers/amazon-bedrock/mantle.js"
import { OpenResponses } from "../../src/protocols/open-responses.js"
import { compileRequest, LLMClient } from "../../src/route/client.js"
import { it } from "../lib/effect.js"
@@ -18,14 +19,14 @@ const credentials = {
}
describe("Amazon Bedrock Mantle provider", () => {
it.effect("uses Responses by default and exposes Chat and Messages explicitly", () =>
it.effect("uses Responses by default and exposes Chat explicitly", () =>
Effect.gen(function* () {
const provider = AmazonBedrockMantle.configure({ credentials })
expect(provider.model).toBe(provider.responses)
expect(AmazonBedrockMantle.model).toBe(AmazonBedrockMantle.responsesModel)
expect(model).toBe(AmazonBedrockMantle.responsesModel)
expect(provider.model("openai.gpt-oss-120b").route.transport).toBe(OpenResponses.httpTransport)
const chat = yield* compileRequest(LLM.request({ model: provider.chat("openai.gpt-oss-120b"), prompt: "Hi" }))
const messages = yield* compileRequest(
LLM.request({ model: provider.messages("anthropic.claude-opus-4-8"), prompt: "Hi", cache: "none" }),
)
const responses = yield* compileRequest(
LLM.request({ model: provider.model("openai.gpt-oss-120b"), prompt: "Hi" }),
)
@@ -35,11 +36,6 @@ describe("Amazon Bedrock Mantle provider", () => {
protocol: "openai-chat",
body: { model: "openai.gpt-oss-120b" },
})
expect(messages).toMatchObject({
route: "bedrock-mantle-messages",
protocol: "anthropic-messages",
body: { model: "anthropic.claude-opus-4-8", stream: true },
})
expect(responses).toMatchObject({
route: "bedrock-mantle-responses",
protocol: "open-responses",
@@ -47,77 +43,43 @@ describe("Amazon Bedrock Mantle provider", () => {
})
expect(provider.model("openai.gpt-oss-120b").route.providerMetadataKey).toBe("mantle")
expect(provider.chat("openai.gpt-oss-120b").route.providerMetadataKey).toBe("mantle")
expect(provider.messages("anthropic.claude-opus-4-8").route.providerMetadataKey).toBe("mantle")
}),
)
it.effect("preserves configured top-p generation defaults for Chat, Messages, and Responses", () =>
it.effect("preserves configured top-p generation defaults for Chat and Responses", () =>
Effect.gen(function* () {
const settings = { apiKey: "test-key", topP: 0.8 }
const chat = yield* compileRequest(
LLM.request({ model: AmazonBedrockMantle.chatModel("openai.gpt-oss-safeguard-20b", settings), prompt: "Hi" }),
)
const messages = yield* compileRequest(
LLM.request({ model: AmazonBedrockMantle.messagesModel("anthropic.claude-opus-4-8", settings), prompt: "Hi" }),
)
const responses = yield* compileRequest(
LLM.request({ model: AmazonBedrockMantle.responsesModel("openai.gpt-oss-120b", settings), prompt: "Hi" }),
)
expect(chat.body.top_p).toBe(0.8)
expect(messages.body.top_p).toBe(0.8)
expect(responses.body.top_p).toBe(0.8)
}),
)
it.effect("uses the Mantle endpoint and signing service across Responses and Messages", () =>
it.effect("uses the Mantle endpoint and signing service", () =>
Effect.gen(function* () {
const seen: Array<{ readonly url: string; readonly authorization: string | undefined }> = []
const configured = AmazonBedrockMantle.configure({ credentials, region: "us-west-1" })
for (const selected of [
configured.responses("openai.gpt-oss-120b"),
configured.messages("anthropic.claude-opus-4-8"),
]) {
yield* LLMClient.generate(LLM.request({ model: selected, prompt: "Hi" })).pipe(
Effect.provide(
dynamicResponse((input) =>
Effect.gen(function* () {
const request = yield* HttpClientRequest.toWeb(input.request)
seen.push({ url: request.url, authorization: request.headers.get("authorization") ?? undefined })
return input.respond("", { headers: { "content-type": "text/event-stream" } })
}),
),
const model = AmazonBedrockMantle.configure({ credentials, region: "us-west-1" }).responses("openai.gpt-oss-120b")
yield* LLMClient.generate(LLM.request({ model, prompt: "Hi" })).pipe(
Effect.provide(
dynamicResponse((input) =>
Effect.gen(function* () {
const request = yield* HttpClientRequest.toWeb(input.request)
seen.push({ url: request.url, authorization: request.headers.get("authorization") ?? undefined })
return input.respond("", { headers: { "content-type": "text/event-stream" } })
}),
),
Effect.flip,
)
}
expect(seen.map((item) => item.url)).toEqual([
"https://bedrock-mantle.us-west-1.api.aws/v1/responses",
"https://bedrock-mantle.us-west-1.api.aws/anthropic/v1/messages",
])
expect(seen.every((item) => item.authorization?.includes("/us-west-1/bedrock-mantle/aws4_request"))).toBe(true)
}).pipe(withProcessEnv({ AWS_BEARER_TOKEN_BEDROCK: undefined })),
)
it.effect("applies inline cache breakpoints on Mantle Messages", () =>
Effect.gen(function* () {
const model = AmazonBedrockMantle.configure({ apiKey: "test-key" }).messages("anthropic.claude-opus-4-8")
const prepared = yield* compileRequest(
LLM.request({
model,
system: "You are concise.",
messages: [Message.user("Hello")],
cache: "auto",
}),
),
Effect.flip,
)
expect(prepared.body.system).toEqual([
{ type: "text", text: "You are concise.", cache_control: { type: "ephemeral" } },
])
expect(prepared.body.messages).toEqual([
{ role: "user", content: [{ type: "text", text: "Hello", cache_control: { type: "ephemeral" } }] },
])
expect(seen[0]?.url).toBe("https://bedrock-mantle.us-west-1.api.aws/v1/responses")
expect(seen[0]?.authorization).toContain("/us-west-1/bedrock-mantle/aws4_request")
}),
)
@@ -239,198 +201,3 @@ describe("Amazon Bedrock Mantle recorded", () => {
}),
)
})
const recordedMessages = recordedTests({
prefix: "bedrock-mantle-messages",
provider: "amazon-bedrock",
protocol: "anthropic-messages",
requires: ["AWS_BEARER_TOKEN_BEDROCK"],
options: { redact: { allowRequestHeaders: ["anthropic-version", "anthropic-beta"] } },
})
const mantleMessages = (modelID: string) =>
AmazonBedrockMantle.configure({
apiKey: process.env.AWS_BEARER_TOKEN_BEDROCK ?? "fixture",
region: "us-east-1",
}).messages(modelID)
const weatherTool = ToolDefinition.make({
name: "get_weather",
description: "Get the current weather in a city",
inputSchema: {
type: "object",
properties: { city: { type: "string", enum: ["Paris"] } },
required: ["city"],
additionalProperties: false,
},
})
describe("Amazon Bedrock Mantle Messages recorded", () => {
recordedMessages.effect.with(
"replays signed thinking through a tool loop and native system update",
{ tags: ["tool", "tool-loop", "reasoning", "system-update"], metadata: { model: "anthropic.claude-opus-4-8" } },
() =>
Effect.gen(function* () {
const model = mantleMessages("anthropic.claude-opus-4-8")
const initial = LLM.request({
model,
system: "You are a concise assistant.",
prompt:
"First calculate 37 * 43. Then call get_weather for Paris. After receiving the tool result, state both the product and the weather.",
tools: [weatherTool],
providerOptions: {
thinking: { type: "adaptive", display: "summarized" },
effort: "medium",
},
generation: { maxTokens: 2048 },
})
const first = yield* LLMClient.generate(initial)
expect(first.finishReason.normalized).toBe("tool-calls")
expect(first.toolCalls).toMatchObject([{ name: "get_weather", input: { city: "Paris" } }])
expect(first.reasoning.length).toBeGreaterThan(0)
expect(first.events.some(LLMEvent.is.toolInputDelta)).toBe(true)
const reasoningPart = first.message.content.find((part) => part.type === "reasoning")
const signature = (reasoningPart?.providerMetadata?.mantle as { readonly signature?: unknown } | undefined)
?.signature
expect(typeof signature).toBe("string")
const followUp = LLMRequest.update(initial, {
messages: [
...initial.messages,
first.message,
...first.toolCalls.map((call) =>
Message.tool({ id: call.id, name: call.name, result: { condition: "sunny", temperatureC: 18 } }),
),
Message.system("Reply in French in one short sentence."),
],
})
const compiled = yield* compileRequest(followUp)
expect(compiled.body.messages[1]?.content[0]).toEqual({
type: "thinking",
thinking: first.reasoning,
signature: signature as string,
})
expect(compiled.body.messages.at(-1)).toEqual({
role: "system",
content: [
{ type: "text", text: "Reply in French in one short sentence.", cache_control: { type: "ephemeral" } },
],
})
const second = yield* LLMClient.generate(followUp)
expect(second.finishReason.normalized).toBe("stop")
expect(second.text).toContain("1591")
expect(second.text).toContain("18")
}),
120_000,
)
recordedMessages.effect.with(
"lowers system updates to wrapped user text on Haiku 4.5 with budget thinking",
{ tags: ["reasoning", "system-update"], metadata: { model: "anthropic.claude-haiku-4-5" } },
() =>
Effect.gen(function* () {
const request = LLM.request({
model: mantleMessages("anthropic.claude-haiku-4-5"),
messages: [Message.user("What is 19 multiplied by 23?"), Message.system("Reply with only the integer.")],
providerOptions: {
thinking: { type: "enabled", budgetTokens: 1024 },
},
generation: { maxTokens: 2048 },
})
const compiled = yield* compileRequest(request)
expect(compiled.body.thinking).toEqual({ type: "enabled", budget_tokens: 1024 })
expect(compiled.body.messages.some((message) => message.role === "system")).toBe(false)
const response = yield* LLMClient.generate(request)
expect(response.finishReason.normalized).toBe("stop")
expect(response.reasoning.length).toBeGreaterThan(0)
expect(response.text.trim()).toContain("437")
}),
120_000,
)
recordedMessages.effect.with(
"applies mid-conversation effort updates and thinking block binding on Opus 5.5",
{ tags: ["reasoning", "effort-update"], metadata: { model: "anthropic.claude-opus-5-5" } },
() =>
Effect.gen(function* () {
const model = mantleMessages("anthropic.claude-opus-5-5")
const firstRequest = LLM.request({
model,
prompt: "Compute 37 * 43 step by step, then reply with only the integer.",
providerOptions: {
thinking: { type: "adaptive", display: "summarized" },
effort: "high",
},
generation: { maxTokens: 2048 },
})
const firstCompiled = yield* compileRequest(firstRequest)
expect(firstCompiled.body.thinking).toEqual({
type: "adaptive",
display: "summarized",
block_binding: { prefix_mismatch_behavior: "drop_block" },
})
const first = yield* LLMClient.generate(firstRequest)
expect(first.finishReason.normalized).toBe("stop")
expect(first.reasoning.length).toBeGreaterThan(0)
expect(first.text.replaceAll(",", "")).toContain("1591")
const secondRequest = LLM.request({
model,
messages: [
...firstRequest.messages,
first.message,
Message.effort({ effort: "low", previous: "high" }),
Message.user("Add 9 to that result. Reply with only the integer."),
],
providerOptions: {
thinking: { type: "adaptive", display: "summarized" },
effort: "low",
},
generation: { maxTokens: 2048 },
})
const secondCompiled = yield* compileRequest(secondRequest)
expect(secondCompiled.body.output_config).toEqual({ effort: "high" })
expect(secondCompiled.body.messages.filter((message) => message.role === "system")).toEqual([
{ role: "system", content: [], output_config: { effort: "low" } },
])
const second = yield* LLMClient.generate(secondRequest)
expect(second.finishReason.normalized).toBe("stop")
expect(second.text.replaceAll(",", "")).toContain("1600")
}),
120_000,
)
recordedMessages.effect.with(
"strips unsupported mid-conversation effort updates on Opus 5.0",
{ tags: ["reasoning", "effort-update"], metadata: { model: "anthropic.claude-opus-5" } },
() =>
Effect.gen(function* () {
const request = LLM.request({
model: mantleMessages("anthropic.claude-opus-5"),
messages: [
Message.user("What is 12 + 30?"),
Message.assistant("42"),
Message.effort({ effort: "low", previous: "high" }),
Message.user("Add 8 to that result. Reply with only the integer."),
],
providerOptions: {
thinking: { type: "adaptive", display: "summarized" },
effort: "low",
},
generation: { maxTokens: 1024 },
})
const compiled = yield* compileRequest(request)
expect(compiled.body.output_config).toEqual({ effort: "low" })
expect(compiled.body.messages.some((message) => message.role === "system")).toBe(false)
const response = yield* LLMClient.generate(request)
expect(response.finishReason.normalized).toBe("stop")
expect(response.text.trim()).toContain("50")
}),
120_000,
)
})
@@ -43,7 +43,6 @@ describe("native OpenAI-compatible providers", () => {
[Azure.configure({ resourceName: "resource", apiKey: "test" }).responses("model"), "azure"],
[AmazonBedrock.configure({ apiKey: "test" }).model("model"), "bedrock"],
[AmazonBedrockMantle.configure({ apiKey: "test" }).chat("model"), "mantle"],
[AmazonBedrockMantle.configure({ apiKey: "test" }).messages("model"), "mantle"],
[AmazonBedrockMantle.configure({ apiKey: "test" }).responses("model"), "mantle"],
[Google.configure({ apiKey: "test" }).model("model"), "google"],
[GoogleVertex.configure(vertex).model("model"), "vertex"],
@@ -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)
}
}),
)
@@ -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()
+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),
@@ -1,12 +1,8 @@
import { expect, test } from "bun:test"
import { hasCustomAgent, resolveAgent } from "./agent"
test("hasCustomAgent detects only explicitly custom agents", () => {
expect(hasCustomAgent([{ native: true }, { native: false }])).toBe(true)
expect(hasCustomAgent([{ native: true }, {}])).toBe(false)
})
import { resolveAgent } from "./agent"
const agents = [{ name: "plan" }, { name: "build" }, { name: "custom" }]
const rows: { name: string; agents: { name: string }[]; requested?: string; expected: string }[] = [
{ name: "the requested available agent", agents, requested: "custom", expected: "custom" },
{ name: "build without a request", agents, requested: undefined, expected: "build" },
@@ -1,7 +1,3 @@
export function hasCustomAgent(items: Array<{ native?: boolean }>) {
return items.some((item) => item.native === false)
}
export function resolveAgent<T extends { name: string }>(items: T[], name?: string) {
return items.find((item) => item.name === name) ?? items.find((item) => item.name === "build") ?? items[0]
}
@@ -9,7 +9,7 @@ import { useSettings } from "@/settings/model"
import { useProviders } from "@/providers/catalog/providers"
import { Persist, persisted } from "@/runtime/persistence/storage"
import { Persistence } from "@/runtime/persistence/schema"
import { hasCustomAgent, resolveAgent } from "./agent"
import { resolveAgent } from "./agent"
import { cycleModelVariant, getConfiguredAgentVariant, resolveModelVariant } from "./variant"
import { useWorkspaceLocation } from "@/workspaces/location"
import { useData } from "@/runtime/server/current"
@@ -98,7 +98,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
),
)
const agentsVisible = createMemo(() => settings.visibility.customAgents() || hasCustomAgent(list()))
const agentsVisible = settings.visibility.customAgents
const connected = createMemo(() => new Set(providers.connected().map((item) => item.id)))
const [saved, setSaved, , savedReady] = persisted(
@@ -266,6 +266,11 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
},
}
createEffect(() => {
if (agentsVisible() || agent.current()?.name === "build") return
agent.set("build")
})
const current = createMemo(() => {
if (!configuredModel.ready()) return
@@ -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)}>
+1 -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",
@@ -511,17 +503,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",
@@ -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))
},
},
Loaded 100 of 332 files, more files were not shown because too many files have changed in this diff. Show more